1
0
mirror of synced 2025-12-19 18:10:59 -05:00

Merge pull request #41806 from github/repo-sync

Repo sync
This commit is contained in:
docs-bot
2025-12-11 16:47:41 -08:00
committed by GitHub
8 changed files with 583 additions and 254 deletions

View File

@@ -16,9 +16,6 @@ category:
- Author and optimize with Copilot
---
> [!NOTE]
> "GitHub Copilot app modernization upgrade for Java" and "GitHub Copilot app modernization Upgrade for .NET" are currently in {% data variables.release-phases.public_preview %} and subject to change.
## Introduction
{% data variables.product.prodname_copilot %} can help streamline the process of modernizing and upgrading your Java and .NET applications. {% data variables.product.prodname_copilot_short %} will analyze the project, generate a plan, automatically fix issues it encounters when carrying out the plan, and produce a summary.
@@ -32,7 +29,7 @@ You can upgrade a Git-based Maven or Gradle Java project using {% data variables
* For Maven-based projects, access to the public Maven Central repository.
* Installed versions of both the source and target JDKs.
For the next steps, see [Quickstart: upgrade a Java project with GitHub Copilot App Modernization - upgrade for Java (preview)](https://learn.microsoft.com/en-gb/java/upgrade/quickstart-upgrade) on Microsoft Learn.
For the next steps, see [Quickstart: upgrade a Java project with GitHub Copilot App Modernization - upgrade for Java](https://learn.microsoft.com/en-gb/java/upgrade/quickstart-upgrade) on Microsoft Learn.
## Upgrading .NET projects

View File

@@ -50,7 +50,7 @@ The `failed to connect to host` error occurs when {% data variables.product.comp
To check whether a host name resolves to an IP address, you can use `nslookup`. For example, if your payload URL is `https://octodex.github.com/webhooks`, you can run `nslookup octodex.github.com`. If the host name could not be resolved to an IP address, the nslookup command will indicate that the server can't find the host name.
You should make sure that your server allows connections from {% data variables.product.company_short %}'s IP addresses. You can use the `GET /meta` endpoint to find the current list of {% data variables.product.company_short %}'s IP addresses. See [AUTOTITLE](/rest/meta/meta#get-github-meta-information). Ensure connectivity is allowed from the IP addresses listed in the `hooks` section. {% data variables.product.company_short %} occasionally makes changes to its IP addresses, so you should update your IP allow list periodically.
You should make sure that your server allows connections from {% data variables.product.company_short %}'s IP addresses. You can use the `GET /meta` endpoint to find the current list of {% data variables.product.company_short %}'s IP addresses. For more information, see [AUTOTITLE](/rest/meta/meta#get-github-meta-information). {% data variables.product.company_short %} occasionally makes changes to its IP addresses, so you should update your IP allow list periodically.
## Failed to connect to network

View File

@@ -0,0 +1,25 @@
import { execSync } from 'child_process'
/**
* Ensure GitHub token is available, exiting process if not found
*/
export function ensureGitHubToken(): void {
if (!process.env.GITHUB_TOKEN) {
try {
const token = execSync('gh auth token', { encoding: 'utf8' }).trim()
if (token) {
process.env.GITHUB_TOKEN = token
return
}
} catch {
// gh CLI not available or not authenticated
}
console.warn(`🔑 A token is needed to run this script. Please do one of the following and try again:
1. Add a GITHUB_TOKEN to a local .env file.
2. Install https://cli.github.com and authenticate via 'gh auth login'.
`)
process.exit(1)
}
}

View File

@@ -1,4 +1,6 @@
const modelsCompletionsEndpoint = 'https://models.github.ai/inference/chat/completions'
const API_TIMEOUT_MS = 180000 // 3 minutes
const DEFAULT_MODEL = 'openai/gpt-4o'
interface ChatMessage {
role: string
@@ -42,16 +44,16 @@ export async function callModelsApi(
// Set default model if none specified
if (!promptWithContent.model) {
promptWithContent.model = 'openai/gpt-4o'
promptWithContent.model = DEFAULT_MODEL
if (verbose) {
console.log('⚠️ No model specified, using default: openai/gpt-4o')
console.log(`⚠️ No model specified, using default: ${DEFAULT_MODEL}`)
}
}
try {
// Create an AbortController for timeout handling
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 180000) // 3 minutes
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
const startTime = Date.now()
if (verbose) {
@@ -123,7 +125,7 @@ export async function callModelsApi(
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new Error('API call timed out after 3 minutes')
throw new Error(`API call timed out after ${API_TIMEOUT_MS / 1000} seconds`)
}
console.error('Error calling GitHub Models REST API:', error.message)
}

View File

@@ -0,0 +1,161 @@
import fs from 'fs'
import path from 'path'
import yaml from 'js-yaml'
import readFrontmatter from '@/frame/lib/read-frontmatter'
import { schema } from '@/frame/lib/frontmatter'
const MAX_DIRECTORY_DEPTH = 20
/**
* Enhanced recursive markdown file finder with symlink, depth, and root path checks
*/
export function findMarkdownFiles(
dir: string,
rootDir: string,
depth: number = 0,
maxDepth: number = MAX_DIRECTORY_DEPTH,
visited: Set<string> = new Set(),
): string[] {
const markdownFiles: string[] = []
let realDir: string
try {
realDir = fs.realpathSync(dir)
} catch {
// If we can't resolve real path, skip this directory
return []
}
// Prevent escaping root directory
if (!realDir.startsWith(rootDir)) {
return []
}
// Prevent symlink loops
if (visited.has(realDir)) {
return []
}
visited.add(realDir)
// Prevent excessive depth
if (depth > maxDepth) {
return []
}
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(realDir, { withFileTypes: true })
} catch {
// If we can't read directory, skip
return []
}
for (const entry of entries) {
const fullPath = path.join(realDir, entry.name)
let realFullPath: string
try {
realFullPath = fs.realpathSync(fullPath)
} catch {
continue
}
// Prevent escaping root directory for files
if (!realFullPath.startsWith(rootDir)) {
continue
}
if (entry.isDirectory()) {
markdownFiles.push(...findMarkdownFiles(realFullPath, rootDir, depth + 1, maxDepth, visited))
} else if (entry.isFile() && entry.name.endsWith('.md')) {
markdownFiles.push(realFullPath)
}
}
return markdownFiles
}
interface FrontmatterProperties {
intro?: string
[key: string]: unknown
}
/**
* Function to merge new frontmatter properties into existing file while preserving formatting.
* Uses surgical replacement to only modify the specific field(s) being updated,
* preserving all original YAML formatting for unchanged fields.
*/
export function mergeFrontmatterProperties(filePath: string, newPropertiesYaml: string): string {
const content = fs.readFileSync(filePath, 'utf8')
const parsed = readFrontmatter(content)
if (parsed.errors && parsed.errors.length > 0) {
throw new Error(
`Failed to parse frontmatter: ${parsed.errors.map((e) => e.message).join(', ')}`,
)
}
if (!parsed.content) {
throw new Error('Failed to parse content from file')
}
try {
// Clean up the AI response - remove markdown code blocks if present
let cleanedYaml = newPropertiesYaml.trim()
cleanedYaml = cleanedYaml.replace(/^```ya?ml\s*\n/i, '')
cleanedYaml = cleanedYaml.replace(/\n```\s*$/i, '')
cleanedYaml = cleanedYaml.trim()
const newProperties = yaml.load(cleanedYaml) as FrontmatterProperties
// Security: Validate against prototype pollution using the official frontmatter schema
const allowedKeys = Object.keys(schema.properties)
const sanitizedProperties = Object.fromEntries(
Object.entries(newProperties).filter(([key]) => {
if (allowedKeys.includes(key)) {
return true
}
console.warn(`Filtered out potentially unsafe frontmatter key: ${key}`)
return false
}),
)
// Split content into lines for surgical replacement
const lines = content.split('\n')
let inFrontmatter = false
let frontmatterEndIndex = -1
// Find frontmatter boundaries
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '---') {
if (!inFrontmatter) {
inFrontmatter = true
} else {
frontmatterEndIndex = i
break
}
}
}
// Replace each field value while preserving everything else
for (const [key, value] of Object.entries(sanitizedProperties)) {
const formattedValue = typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : value
// Find the line with this field
for (let i = 1; i < frontmatterEndIndex; i++) {
const line = lines[i]
if (line.startsWith(`${key}:`)) {
// Simple replacement: keep the field name and spacing, replace the value
const colonIndex = line.indexOf(':')
const leadingSpace = line.substring(colonIndex + 1, colonIndex + 2) // Usually a space
lines[i] = `${key}:${leadingSpace}${formattedValue}`
// Remove any continuation lines (multi-line values)
const j = i + 1
while (j < frontmatterEndIndex && lines[j].startsWith(' ')) {
lines.splice(j, 1)
frontmatterEndIndex--
}
break
}
}
}
return lines.join('\n')
} catch (error) {
console.error('Failed to parse AI response as YAML:')
console.error('Raw AI response:', JSON.stringify(newPropertiesYaml))
throw new Error(`Failed to parse new frontmatter properties: ${error}`)
}
}

View File

@@ -0,0 +1,112 @@
import { fileURLToPath } from 'url'
import fs from 'fs'
import yaml from 'js-yaml'
import path from 'path'
import { callModelsApi } from '@/ai-tools/lib/call-models-api'
export interface PromptMessage {
content: string
role: string
}
export interface PromptData {
messages: PromptMessage[]
model?: string
temperature?: number
max_tokens?: number
}
/**
* Get the prompts directory path
*/
export function getPromptsDir(): string {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
return path.join(__dirname, '../prompts')
}
/**
* Dynamically discover available editor types from prompt files
*/
export function getAvailableEditorTypes(promptDir: string): string[] {
const editorTypes: string[] = []
try {
const promptFiles = fs.readdirSync(promptDir)
for (const file of promptFiles) {
if (file.endsWith('.md')) {
const editorName = path.basename(file, '.md')
editorTypes.push(editorName)
}
}
} catch {
console.warn('Could not read prompts directory, using empty editor types')
}
return editorTypes
}
/**
* Get formatted description of available refinement types
*/
export function getRefinementDescriptions(editorTypes: string[]): string {
return editorTypes.join(', ')
}
/**
* Call an editor with the given content and options
*/
export async function callEditor(
editorType: string,
content: string,
promptDir: string,
writeMode: boolean,
verbose = false,
promptContent?: string, // Optional: use this instead of reading from file
): Promise<string> {
let markdownPrompt: string
if (promptContent) {
// Use provided prompt content (e.g., from Copilot Space)
markdownPrompt = promptContent
} else {
// Read from file
const markdownPromptPath = path.join(promptDir, `${editorType}.md`)
if (!fs.existsSync(markdownPromptPath)) {
throw new Error(`Prompt file not found: ${markdownPromptPath}`)
}
markdownPrompt = fs.readFileSync(markdownPromptPath, 'utf8')
}
const promptTemplatePath = path.join(promptDir, 'prompt-template.yml')
const prompt = yaml.load(fs.readFileSync(promptTemplatePath, 'utf8')) as PromptData
// Validate the prompt template has required properties
if (!prompt.messages || !Array.isArray(prompt.messages)) {
throw new Error('Invalid prompt template: missing or invalid messages array')
}
for (const msg of prompt.messages) {
msg.content = msg.content.replace('{{markdownPrompt}}', markdownPrompt)
msg.content = msg.content.replace('{{input}}', content)
// Replace writeMode template variable with simple string replacement
msg.content = msg.content.replace(
/<!-- IF_WRITE_MODE -->/g,
writeMode ? '' : '<!-- REMOVE_START -->',
)
msg.content = msg.content.replace(
/<!-- ELSE_WRITE_MODE -->/g,
writeMode ? '<!-- REMOVE_START -->' : '',
)
msg.content = msg.content.replace(
/<!-- END_WRITE_MODE -->/g,
writeMode ? '' : '<!-- REMOVE_END -->',
)
// Remove sections marked for removal
msg.content = msg.content.replace(/<!-- REMOVE_START -->[\s\S]*?<!-- REMOVE_END -->/g, '')
}
return callModelsApi(prompt, verbose)
}

View File

@@ -0,0 +1,109 @@
/**
* Copilot Space API response types
*/
export interface SpaceResource {
id: number
resource_type: string
copilot_chat_attachment_id: string | null
metadata: {
name: string
text: string
}
}
export interface SpaceData {
id: number
number: number
name: string
description: string
general_instructions: string
resources_attributes: SpaceResource[]
html_url: string
created_at: string
updated_at: string
}
/**
* Parse a Copilot Space URL to extract org and space ID
*/
export function parseSpaceUrl(url: string): { org: string; id: string } {
// Expected format: https://api.github.com/orgs/{org}/copilot-spaces/{id}
const match = url.match(/\/orgs\/([^/]+)\/copilot-spaces\/(\d+)/)
if (!match) {
throw new Error(
`Invalid Copilot Space URL format. Expected: https://api.github.com/orgs/{org}/copilot-spaces/{id}`,
)
}
return {
org: match[1],
id: match[2],
}
}
/**
* Fetch a Copilot Space from the GitHub API
*/
export async function fetchCopilotSpace(spaceUrl: string): Promise<SpaceData> {
const { org, id } = parseSpaceUrl(spaceUrl)
const apiUrl = `https://api.github.com/orgs/${org}/copilot-spaces/${id}`
const response = await fetch(apiUrl, {
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
})
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Copilot Space not found: ${apiUrl}`)
} else if (response.status === 401 || response.status === 403) {
throw new Error(
`Authentication failed. Check your GitHub token has access to Copilot Spaces.`,
)
} else {
throw new Error(`Failed to fetch Copilot Space: ${response.status} ${response.statusText}`)
}
}
return (await response.json()) as SpaceData
}
/**
* Convert a Copilot Space to a markdown prompt file
*/
export function convertSpaceToPrompt(space: SpaceData): string {
const timestamp = new Date().toISOString()
const lines: string[] = []
// Header with metadata
lines.push(`<!-- Generated from Copilot Space: ${space.name} -->`)
lines.push(`<!-- Space ID: ${space.number} | Generated: ${timestamp} -->`)
lines.push(`<!-- Space URL: ${space.html_url} -->`)
lines.push('')
// General instructions
if (space.general_instructions) {
lines.push(space.general_instructions.trim())
lines.push('')
}
// Add each resource as a context section
if (space.resources_attributes && space.resources_attributes.length > 0) {
for (const resource of space.resources_attributes) {
if (resource.resource_type === 'free_text' && resource.metadata) {
lines.push('---')
lines.push('')
lines.push(`# Context: ${resource.metadata.name}`)
lines.push('')
lines.push(resource.metadata.text.trim())
lines.push('')
}
}
}
return lines.join('\n')
}

View File

@@ -1,123 +1,36 @@
import { fileURLToPath } from 'url'
import { Command } from 'commander'
import fs from 'fs'
import yaml from 'js-yaml'
import path from 'path'
import ora from 'ora'
import { execSync } from 'child_process'
import { callModelsApi } from '@/ai-tools/lib/call-models-api'
import { execFileSync } from 'child_process'
import dotenv from 'dotenv'
import readFrontmatter from '@/frame/lib/read-frontmatter'
import { schema } from '@/frame/lib/frontmatter'
import { findMarkdownFiles, mergeFrontmatterProperties } from '@/ai-tools/lib/file-utils'
import {
getPromptsDir,
getAvailableEditorTypes,
getRefinementDescriptions,
callEditor,
} from '@/ai-tools/lib/prompt-utils'
import { fetchCopilotSpace, convertSpaceToPrompt } from '@/ai-tools/lib/spaces-utils'
import { ensureGitHubToken } from '@/ai-tools/lib/auth-utils'
dotenv.config({ quiet: true })
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const promptDir = path.join(__dirname, '../prompts')
const promptTemplatePath = path.join(promptDir, 'prompt-template.yml')
const promptDir = getPromptsDir()
if (!process.env.GITHUB_TOKEN) {
// Try to find a token via the CLI before throwing an error
const token = execSync('gh auth token').toString()
if (token.startsWith('gh')) {
process.env.GITHUB_TOKEN = token
} else {
console.warn(`🔑 A token is needed to run this script. Please do one of the following and try again:
// Ensure GitHub token is available
ensureGitHubToken()
1. Add a GITHUB_TOKEN to a local .env file.
2. Install https://cli.github.com and authenticate via 'gh auth login'.
`)
process.exit(1)
}
}
// Dynamically discover available editor types from prompt files
const getAvailableEditorTypes = (): string[] => {
const editorTypes: string[] = []
try {
const promptFiles = fs.readdirSync(promptDir)
for (const file of promptFiles) {
if (file.endsWith('.md')) {
const editorName = path.basename(file, '.md')
editorTypes.push(editorName)
}
}
} catch {
console.warn('Could not read prompts directory, using empty editor types')
}
return editorTypes
}
const editorTypes = getAvailableEditorTypes()
// Enhanced recursive markdown file finder with symlink, depth, and root path checks
const findMarkdownFiles = (
dir: string,
rootDir: string,
depth: number = 0,
maxDepth: number = 20,
visited: Set<string> = new Set(),
): string[] => {
const markdownFiles: string[] = []
let realDir: string
try {
realDir = fs.realpathSync(dir)
} catch {
// If we can't resolve real path, skip this directory
return []
}
// Prevent escaping root directory
if (!realDir.startsWith(rootDir)) {
return []
}
// Prevent symlink loops
if (visited.has(realDir)) {
return []
}
visited.add(realDir)
// Prevent excessive depth
if (depth > maxDepth) {
return []
}
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(realDir, { withFileTypes: true })
} catch {
// If we can't read directory, skip
return []
}
for (const entry of entries) {
const fullPath = path.join(realDir, entry.name)
let realFullPath: string
try {
realFullPath = fs.realpathSync(fullPath)
} catch {
continue
}
// Prevent escaping root directory for files
if (!realFullPath.startsWith(rootDir)) {
continue
}
if (entry.isDirectory()) {
markdownFiles.push(...findMarkdownFiles(realFullPath, rootDir, depth + 1, maxDepth, visited))
} else if (entry.isFile() && entry.name.endsWith('.md')) {
markdownFiles.push(realFullPath)
}
}
return markdownFiles
}
const refinementDescriptions = (): string => {
return editorTypes.join(', ')
}
const editorTypes = getAvailableEditorTypes(promptDir)
interface CliOptions {
verbose?: boolean
prompt?: string[]
refine?: string[]
files: string[]
files?: string[]
write?: boolean
exportSpace?: string
space?: string
output?: string
}
const program = new Command()
@@ -130,41 +43,115 @@ program
'-w, --write',
'Write changes back to the original files (default: output to console only)',
)
.option('-p, --prompt <type...>', `Specify one or more prompt type: ${refinementDescriptions()}`)
.option(
'-p, --prompt <type...>',
`Specify one or more prompt type: ${getRefinementDescriptions(editorTypes)}`,
)
.option(
'-r, --refine <type...>',
`(Deprecated: use --prompt) Specify one or more prompt type: ${refinementDescriptions()}`,
`(Deprecated: use --prompt) Specify one or more prompt type: ${getRefinementDescriptions(editorTypes)}`,
)
.requiredOption(
'-f, --files <files...>',
'One or more content file paths in the content directory',
.option(
'--export-space <url>',
'Export a Copilot Space to a prompt file (format: https://api.github.com/orgs/{org}/copilot-spaces/{id})',
)
.option(
'--space <url>',
'Use a Copilot Space as prompt source (format: https://api.github.com/orgs/{org}/copilot-spaces/{id})',
)
.option(
'--output <filename>',
'Output filename for exported Space prompt (use with --export-space)',
)
.option('-f, --files <files...>', 'One or more content file paths in the content directory')
.action((options: CliOptions) => {
;(async () => {
// Handle export-space workflow (standalone, doesn't process files)
if (options.exportSpace) {
if (!options.output) {
console.error('Error: --export-space requires --output option')
process.exit(1)
}
const spinner = ora('Fetching Copilot Space...').start()
try {
const space = await fetchCopilotSpace(options.exportSpace)
spinner.text = `Converting Space "${space.name}" to prompt format...`
const promptContent = convertSpaceToPrompt(space)
const outputPath = path.join(promptDir, options.output)
fs.writeFileSync(outputPath, promptContent, 'utf8')
spinner.succeed(`Exported Space to: ${outputPath}`)
console.log(`\nSpace: ${space.name}`)
console.log(`Resources: ${space.resources_attributes?.length || 0} items`)
console.log(`\nYou can now use it with: --prompt ${path.basename(options.output, '.md')}`)
return
} catch (error) {
spinner.fail(`Failed to export Space: ${(error as Error).message}`)
process.exit(1)
}
}
// Validate mutually exclusive options
if (options.space && options.prompt) {
console.error('Error: Cannot use both --space and --prompt options')
process.exit(1)
}
// Files are required for processing workflows
if (!options.files || options.files.length === 0) {
console.error('Error: --files option is required (unless using --export-space)')
process.exit(1)
}
const spinner = ora('Starting AI review...').start()
const files = options.files
// Handle both --prompt and --refine options for backwards compatibility
const prompts = options.prompt || options.refine
let prompts: string[] = []
let promptContent: string | undefined
if (!prompts || prompts.length === 0) {
spinner.fail('No prompt type specified. Use --prompt or --refine with one or more types.')
process.exitCode = 1
return
}
// Handle Space workflow (in-memory)
if (options.space) {
try {
spinner.text = 'Fetching Copilot Space...'
const space = await fetchCopilotSpace(options.space)
promptContent = convertSpaceToPrompt(space)
prompts = [space.name] // Use space name for display
// Validate that all requested editor types exist
const availableEditors = editorTypes
for (const editor of prompts) {
if (!availableEditors.includes(editor)) {
spinner.fail(
`Unknown prompt type: ${editor}. Available types: ${availableEditors.join(', ')}`,
)
if (options.verbose) {
console.log(`Using Space: ${space.name} (ID: ${space.number})`)
console.log(`Resources: ${space.resources_attributes?.length || 0} items`)
}
} catch (error) {
spinner.fail(`Failed to fetch Space: ${(error as Error).message}`)
process.exit(1)
}
} else {
// Handle local prompt workflow
prompts = options.prompt || options.refine || []
if (prompts.length === 0) {
spinner.fail('No prompt type specified. Use --prompt, --refine, or --space.')
process.exitCode = 1
return
}
}
// Validate local prompt types exist (skip for Space workflow)
if (!options.space) {
const availableEditors = editorTypes
for (const editor of prompts) {
if (!availableEditors.includes(editor)) {
spinner.fail(
`Unknown prompt type: ${editor}. Available types: ${availableEditors.join(', ')}`,
)
process.exitCode = 1
return
}
}
}
if (options.verbose) {
console.log(`Processing ${files.length} files with prompts: ${prompts.join(', ')}`)
}
@@ -208,12 +195,20 @@ program
const relativePath = path.relative(process.cwd(), fileToProcess)
spinner.text = `Processing: ${relativePath}`
try {
// Resolve Liquid references before processing
if (options.verbose) {
console.log(`Resolving Liquid references in: ${relativePath}`)
}
runResolveLiquid('resolve', [fileToProcess], options.verbose || false)
const content = fs.readFileSync(fileToProcess, 'utf8')
const answer = await callEditor(
editorType,
content,
promptDir,
options.write || false,
options.verbose || false,
promptContent, // Pass Space prompt content if using --space
)
spinner.stop()
@@ -235,10 +230,26 @@ program
}
console.log(answer)
}
// Always restore Liquid references after processing (even in non-write mode)
if (options.verbose) {
console.log(`Restoring Liquid references in: ${relativePath}`)
}
runResolveLiquid('restore', [fileToProcess], options.verbose || false)
} catch (err) {
const error = err as Error
spinner.fail(`Error processing ${relativePath}: ${error.message}`)
process.exitCode = 1
// Still try to restore Liquid references on error
try {
runResolveLiquid('restore', [fileToProcess], false)
} catch (restoreError) {
// Log restore failures in verbose mode for debugging
if (options.verbose) {
console.error(`Warning: Failed to restore Liquid references: ${restoreError}`)
}
}
} finally {
spinner.stop()
}
@@ -263,6 +274,39 @@ program
program.parse(process.argv)
/**
* Run resolve-liquid command on specified file paths
*/
function runResolveLiquid(
command: 'resolve' | 'restore',
filePaths: string[],
verbose: boolean = false,
): void {
const args = [command, '--paths', ...filePaths]
if (command === 'resolve') {
args.push('--recursive')
}
if (verbose) {
args.push('--verbose')
}
try {
// Run resolve-liquid via tsx
const resolveLiquidPath = path.join(
process.cwd(),
'src/content-render/scripts/resolve-liquid.ts',
)
execFileSync('npx', ['tsx', resolveLiquidPath, ...args], {
stdio: verbose ? 'inherit' : 'pipe',
})
} catch (error) {
if (verbose) {
console.error(`Error running resolve-liquid ${command}:`, error)
}
// Don't fail the entire process if resolve-liquid fails
}
}
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n🛑 Process interrupted by user')
@@ -273,124 +317,3 @@ process.on('SIGTERM', () => {
console.log('\n\n🛑 Process terminated')
process.exit(0)
})
interface PromptMessage {
content: string
role: string
}
interface PromptData {
messages: PromptMessage[]
model?: string
temperature?: number
max_tokens?: number
}
// Function to merge new frontmatter properties into existing file while preserving formatting
function mergeFrontmatterProperties(filePath: string, newPropertiesYaml: string): string {
const content = fs.readFileSync(filePath, 'utf8')
const parsed = readFrontmatter(content)
if (parsed.errors && parsed.errors.length > 0) {
throw new Error(
`Failed to parse frontmatter: ${parsed.errors.map((e) => e.message).join(', ')}`,
)
}
if (!parsed.content) {
throw new Error('Failed to parse content from file')
}
try {
// Clean up the AI response - remove markdown code blocks if present
let cleanedYaml = newPropertiesYaml.trim()
cleanedYaml = cleanedYaml.replace(/^```ya?ml\s*\n/i, '')
cleanedYaml = cleanedYaml.replace(/\n```\s*$/i, '')
cleanedYaml = cleanedYaml.trim()
interface FrontmatterProperties {
intro?: string
[key: string]: unknown
}
const newProperties = yaml.load(cleanedYaml) as FrontmatterProperties
// Security: Validate against prototype pollution using the official frontmatter schema
const allowedKeys = Object.keys(schema.properties)
const sanitizedProperties = Object.fromEntries(
Object.entries(newProperties).filter(([key]) => {
if (allowedKeys.includes(key)) {
return true
}
console.warn(`Filtered out potentially unsafe frontmatter key: ${key}`)
return false
}),
)
// Merge new properties with existing frontmatter
const mergedData: FrontmatterProperties = { ...parsed.data, ...sanitizedProperties }
// Manually ensure intro is wrapped in single quotes in the final output
let result = readFrontmatter.stringify(parsed.content, mergedData)
// Post-process to ensure intro field has single quotes
if (newProperties.intro) {
const introValue = newProperties.intro.toString()
// Replace any quote style on intro with single quotes
result = result.replace(
/^intro:\s*(['"`]?)([^'"`\n\r]+)\1?\s*$/m,
`intro: '${introValue.replace(/'/g, "''")}'`, // Escape single quotes by doubling them
)
}
return result
} catch (error) {
console.error('Failed to parse AI response as YAML:')
console.error('Raw AI response:', JSON.stringify(newPropertiesYaml))
throw new Error(`Failed to parse new frontmatter properties: ${error}`)
}
}
async function callEditor(
editorType: string,
content: string,
writeMode: boolean,
verbose = false,
): Promise<string> {
const markdownPromptPath = path.join(promptDir, `${String(editorType)}.md`)
if (!fs.existsSync(markdownPromptPath)) {
throw new Error(`Prompt file not found: ${markdownPromptPath}`)
}
const markdownPrompt = fs.readFileSync(markdownPromptPath, 'utf8')
const prompt = yaml.load(fs.readFileSync(promptTemplatePath, 'utf8')) as PromptData
// Validate the prompt template has required properties
if (!prompt.messages || !Array.isArray(prompt.messages)) {
throw new Error('Invalid prompt template: missing or invalid messages array')
}
for (const msg of prompt.messages) {
msg.content = msg.content.replace('{{markdownPrompt}}', markdownPrompt)
msg.content = msg.content.replace('{{input}}', content)
// Replace writeMode template variable with simple string replacement
msg.content = msg.content.replace(
/<!-- IF_WRITE_MODE -->/g,
writeMode ? '' : '<!-- REMOVE_START -->',
)
msg.content = msg.content.replace(
/<!-- ELSE_WRITE_MODE -->/g,
writeMode ? '<!-- REMOVE_START -->' : '',
)
msg.content = msg.content.replace(
/<!-- END_WRITE_MODE -->/g,
writeMode ? '' : '<!-- REMOVE_END -->',
)
// Remove sections marked for removal
msg.content = msg.content.replace(/<!-- REMOVE_START -->[\s\S]*?<!-- REMOVE_END -->/g, '')
}
return callModelsApi(prompt, verbose)
}