Support Copilot Spaces in ai-tools (#58845)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
25
src/ai-tools/lib/auth-utils.ts
Normal file
25
src/ai-tools/lib/auth-utils.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
const modelsCompletionsEndpoint = 'https://models.github.ai/inference/chat/completions'
|
const modelsCompletionsEndpoint = 'https://models.github.ai/inference/chat/completions'
|
||||||
|
const API_TIMEOUT_MS = 180000 // 3 minutes
|
||||||
|
const DEFAULT_MODEL = 'openai/gpt-4o'
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
role: string
|
role: string
|
||||||
@@ -42,16 +44,16 @@ export async function callModelsApi(
|
|||||||
|
|
||||||
// Set default model if none specified
|
// Set default model if none specified
|
||||||
if (!promptWithContent.model) {
|
if (!promptWithContent.model) {
|
||||||
promptWithContent.model = 'openai/gpt-4o'
|
promptWithContent.model = DEFAULT_MODEL
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
console.log('⚠️ No model specified, using default: openai/gpt-4o')
|
console.log(`⚠️ No model specified, using default: ${DEFAULT_MODEL}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create an AbortController for timeout handling
|
// Create an AbortController for timeout handling
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 180000) // 3 minutes
|
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
|
||||||
|
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
@@ -123,7 +125,7 @@ export async function callModelsApi(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.name === 'AbortError') {
|
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)
|
console.error('Error calling GitHub Models REST API:', error.message)
|
||||||
}
|
}
|
||||||
|
|||||||
161
src/ai-tools/lib/file-utils.ts
Normal file
161
src/ai-tools/lib/file-utils.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/ai-tools/lib/prompt-utils.ts
Normal file
112
src/ai-tools/lib/prompt-utils.ts
Normal 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)
|
||||||
|
}
|
||||||
109
src/ai-tools/lib/spaces-utils.ts
Normal file
109
src/ai-tools/lib/spaces-utils.ts
Normal 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')
|
||||||
|
}
|
||||||
@@ -1,123 +1,36 @@
|
|||||||
import { fileURLToPath } from 'url'
|
|
||||||
import { Command } from 'commander'
|
import { Command } from 'commander'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import yaml from 'js-yaml'
|
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import ora from 'ora'
|
import ora from 'ora'
|
||||||
import { execSync } from 'child_process'
|
import { execFileSync } from 'child_process'
|
||||||
import { callModelsApi } from '@/ai-tools/lib/call-models-api'
|
|
||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
import readFrontmatter from '@/frame/lib/read-frontmatter'
|
import { findMarkdownFiles, mergeFrontmatterProperties } from '@/ai-tools/lib/file-utils'
|
||||||
import { schema } from '@/frame/lib/frontmatter'
|
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 })
|
dotenv.config({ quiet: true })
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const promptDir = getPromptsDir()
|
||||||
const promptDir = path.join(__dirname, '../prompts')
|
|
||||||
const promptTemplatePath = path.join(promptDir, 'prompt-template.yml')
|
|
||||||
|
|
||||||
if (!process.env.GITHUB_TOKEN) {
|
// Ensure GitHub token is available
|
||||||
// Try to find a token via the CLI before throwing an error
|
ensureGitHubToken()
|
||||||
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:
|
|
||||||
|
|
||||||
1. Add a GITHUB_TOKEN to a local .env file.
|
const editorTypes = getAvailableEditorTypes(promptDir)
|
||||||
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(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CliOptions {
|
interface CliOptions {
|
||||||
verbose?: boolean
|
verbose?: boolean
|
||||||
prompt?: string[]
|
prompt?: string[]
|
||||||
refine?: string[]
|
refine?: string[]
|
||||||
files: string[]
|
files?: string[]
|
||||||
write?: boolean
|
write?: boolean
|
||||||
|
exportSpace?: string
|
||||||
|
space?: string
|
||||||
|
output?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const program = new Command()
|
const program = new Command()
|
||||||
@@ -130,41 +43,115 @@ program
|
|||||||
'-w, --write',
|
'-w, --write',
|
||||||
'Write changes back to the original files (default: output to console only)',
|
'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(
|
.option(
|
||||||
'-r, --refine <type...>',
|
'-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(
|
.option(
|
||||||
'-f, --files <files...>',
|
'--export-space <url>',
|
||||||
'One or more content file paths in the content directory',
|
'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) => {
|
.action((options: CliOptions) => {
|
||||||
;(async () => {
|
;(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 spinner = ora('Starting AI review...').start()
|
||||||
|
|
||||||
const files = options.files
|
const files = options.files
|
||||||
// Handle both --prompt and --refine options for backwards compatibility
|
let prompts: string[] = []
|
||||||
const prompts = options.prompt || options.refine
|
let promptContent: string | undefined
|
||||||
|
|
||||||
if (!prompts || prompts.length === 0) {
|
// Handle Space workflow (in-memory)
|
||||||
spinner.fail('No prompt type specified. Use --prompt or --refine with one or more types.')
|
if (options.space) {
|
||||||
process.exitCode = 1
|
try {
|
||||||
return
|
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
|
if (options.verbose) {
|
||||||
const availableEditors = editorTypes
|
console.log(`Using Space: ${space.name} (ID: ${space.number})`)
|
||||||
for (const editor of prompts) {
|
console.log(`Resources: ${space.resources_attributes?.length || 0} items`)
|
||||||
if (!availableEditors.includes(editor)) {
|
}
|
||||||
spinner.fail(
|
} catch (error) {
|
||||||
`Unknown prompt type: ${editor}. Available types: ${availableEditors.join(', ')}`,
|
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
|
process.exitCode = 1
|
||||||
return
|
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) {
|
if (options.verbose) {
|
||||||
console.log(`Processing ${files.length} files with prompts: ${prompts.join(', ')}`)
|
console.log(`Processing ${files.length} files with prompts: ${prompts.join(', ')}`)
|
||||||
}
|
}
|
||||||
@@ -208,12 +195,20 @@ program
|
|||||||
const relativePath = path.relative(process.cwd(), fileToProcess)
|
const relativePath = path.relative(process.cwd(), fileToProcess)
|
||||||
spinner.text = `Processing: ${relativePath}`
|
spinner.text = `Processing: ${relativePath}`
|
||||||
try {
|
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 content = fs.readFileSync(fileToProcess, 'utf8')
|
||||||
const answer = await callEditor(
|
const answer = await callEditor(
|
||||||
editorType,
|
editorType,
|
||||||
content,
|
content,
|
||||||
|
promptDir,
|
||||||
options.write || false,
|
options.write || false,
|
||||||
options.verbose || false,
|
options.verbose || false,
|
||||||
|
promptContent, // Pass Space prompt content if using --space
|
||||||
)
|
)
|
||||||
spinner.stop()
|
spinner.stop()
|
||||||
|
|
||||||
@@ -235,10 +230,26 @@ program
|
|||||||
}
|
}
|
||||||
console.log(answer)
|
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) {
|
} catch (err) {
|
||||||
const error = err as Error
|
const error = err as Error
|
||||||
spinner.fail(`Error processing ${relativePath}: ${error.message}`)
|
spinner.fail(`Error processing ${relativePath}: ${error.message}`)
|
||||||
process.exitCode = 1
|
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 {
|
} finally {
|
||||||
spinner.stop()
|
spinner.stop()
|
||||||
}
|
}
|
||||||
@@ -263,6 +274,39 @@ program
|
|||||||
|
|
||||||
program.parse(process.argv)
|
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
|
// Handle graceful shutdown
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log('\n\n🛑 Process interrupted by user')
|
console.log('\n\n🛑 Process interrupted by user')
|
||||||
@@ -273,124 +317,3 @@ process.on('SIGTERM', () => {
|
|||||||
console.log('\n\n🛑 Process terminated')
|
console.log('\n\n🛑 Process terminated')
|
||||||
process.exit(0)
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user