diff --git a/docs/cli.md b/docs/cli.md index f48d1bc28..e9ee1f41d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -93,8 +93,13 @@ openspec init [path] [options] | `--tools ` | Configure AI tools non-interactively. Use `all`, `none`, or comma-separated list | | `--force` | Auto-cleanup legacy files without prompting | | `--profile ` | Override global profile for this init run (`core` or `custom`) | +| `--schema ` | Write the named workflow schema to `openspec/config.yaml` | +| `--schema-source ` | Import a schema bundle directory | `--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`). +`--schema-source` copies the schema bundle directory into `openspec/schemas//` and +uses the source `schema.yaml` `name` field unless `--schema ` is provided. +When both are provided, the names must match. **Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `opencode`, `pi`, `qoder`, `lingma`, `qwen`, `roocode`, `trae`, `windsurf` @@ -110,6 +115,12 @@ openspec init ./my-project # Non-interactive: configure for Claude and Cursor openspec init --tools claude,cursor +# Initialize with a custom schema already available to OpenSpec +openspec init --schema my-workflow + +# Import a schema bundle into the project during initialization +openspec init --schema-source ../omnidev/schemas/my-workflow + # Configure for all supported tools openspec init --tools all diff --git a/docs/customization.md b/docs/customization.md index 3c20a1d65..f316db5c7 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -24,7 +24,20 @@ The `openspec/config.yaml` file is the easiest way to customize OpenSpec for you openspec init ``` -This walks you through creating a config interactively. Or create one manually: +This creates `openspec/config.yaml` with the default schema. If your team schema +is already available to OpenSpec, initialize with it directly: + +```bash +openspec init --schema my-workflow +``` + +If your schema lives outside the project, import it during initialization: + +```bash +openspec init --schema-source ../omnidev/schemas/my-workflow +``` + +Or create one manually: ```yaml # openspec/config.yaml diff --git a/src/cli/index.ts b/src/cli/index.ts index baa3e48fa..a287fce00 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -101,7 +101,12 @@ program .option('--tools ', toolsOptionDescription) .option('--force', 'Auto-cleanup legacy files without prompting') .option('--profile ', 'Override global config profile (core or custom)') - .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string }) => { + .option('--schema ', 'Workflow schema to write to openspec/config.yaml') + .option('--schema-source ', 'Import a schema bundle directory') + .action(async ( + targetPath = '.', + options?: { tools?: string; force?: boolean; profile?: string; schema?: string; schemaSource?: string } + ) => { try { // Validate that the path is a valid directory const resolvedPath = path.resolve(targetPath); @@ -127,6 +132,8 @@ program tools: options?.tools, force: options?.force, profile: options?.profile, + schema: options?.schema, + schemaSource: options?.schemaSource, }); await initCommand.execute(targetPath); } catch (error) { diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 9b629b5f7..953777cfc 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -44,6 +44,26 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Configure AI tools non-interactively (e.g., "all", "none", or comma-separated tool IDs)', takesValue: true, }, + { + name: 'force', + description: 'Auto-cleanup legacy files without prompting', + }, + { + name: 'profile', + description: 'Override global config profile for this init run', + takesValue: true, + values: ['core', 'custom'], + }, + { + name: 'schema', + description: 'Workflow schema to write to openspec/config.yaml', + takesValue: true, + }, + { + name: 'schema-source', + description: 'Import a schema bundle directory', + takesValue: true, + }, ], }, { diff --git a/src/core/init.ts b/src/core/init.ts index aa38408f2..08d4769a3 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -45,6 +45,7 @@ import { getGlobalConfig, type Delivery, type Profile } from './global-config.js import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; import { migrateIfNeeded } from './migration.js'; +import { parseSchema, resolveSchema, type SchemaYaml } from './artifact-graph/index.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -83,6 +84,15 @@ type InitCommandOptions = { force?: boolean; interactive?: boolean; profile?: string; + schema?: string; + schemaSource?: string; +}; + +type SchemaSetupPlan = { + name: string; + importSourceDir?: string; + importDestinationDir?: string; + shouldImport?: boolean; }; // ----------------------------------------------------------------------------- @@ -94,12 +104,16 @@ export class InitCommand { private readonly force: boolean; private readonly interactiveOption?: boolean; private readonly profileOverride?: string; + private readonly schemaOverride?: string; + private readonly schemaSource?: string; constructor(options: InitCommandOptions = {}) { this.toolsArg = options.tools; this.force = options.force ?? false; this.interactiveOption = options.interactive; this.profileOverride = options.profile; + this.schemaOverride = options.schema; + this.schemaSource = options.schemaSource; } async execute(targetPath: string): Promise { @@ -110,6 +124,10 @@ export class InitCommand { // Validation happens silently in the background const extendMode = await this.validate(projectPath, openspecPath); + // Validate explicit overrides early so invalid values fail before setup changes files. + this.resolveProfileOverride(); + const schemaPlan = this.resolveSchemaSetupPlan(projectPath, openspecPath); + // Check for legacy artifacts and handle cleanup await this.handleLegacyCleanup(projectPath, extendMode); @@ -128,10 +146,6 @@ export class InitCommand { await showWelcomeScreen(); } - // Validate profile override early so invalid values fail before tool setup. - // The resolved value is consumed later when generation reads effective config. - this.resolveProfileOverride(); - // Get tool states before processing const toolStates = getToolStates(projectPath); @@ -144,14 +158,17 @@ export class InitCommand { // Create directory structure and config await this.createDirectoryStructure(openspecPath, extendMode); + // Import schema before generating tool files so invalid import destinations fail early. + await this.importSchemaSource(schemaPlan); + // Generate skills and commands for each tool const results = await this.generateSkillsAndCommands(projectPath, validatedTools); // Create config.yaml if needed - const configStatus = await this.createConfig(openspecPath, extendMode); + const configStatus = await this.createConfig(openspecPath, schemaPlan.name); // Display success message - this.displaySuccessMessage(projectPath, validatedTools, results, configStatus); + this.displaySuccessMessage(projectPath, validatedTools, results, configStatus, schemaPlan.name); } // ═══════════════════════════════════════════════════════════ @@ -189,6 +206,189 @@ export class InitCommand { throw new Error(`Invalid profile "${this.profileOverride}". Available profiles: core, custom`); } + /** + * Resolves the schema name and optional import operation for initialization. + */ + private resolveSchemaSetupPlan(projectPath: string, openspecPath: string): SchemaSetupPlan { + const schemaOverride = this.normalizeOptionalSchemaName(this.schemaOverride); + + if (this.schemaSource === undefined) { + const schemaName = schemaOverride ?? DEFAULT_SCHEMA; + if (schemaOverride) { + resolveSchema(schemaName, projectPath); + } + return { name: schemaName }; + } + + const source = this.resolveSchemaSource(this.schemaSource); + const sourceSchema = this.loadSchemaFromSource(source.schemaPath); + const sourceSchemaName = this.validateSchemaName(sourceSchema.name, 'schema source name'); + const schemaName = schemaOverride ?? sourceSchemaName; + + if (schemaOverride && schemaOverride !== sourceSchemaName) { + throw new Error( + `Schema source declares name '${sourceSchemaName}', but --schema was '${schemaOverride}'. ` + + 'Use a matching schema name or update the source schema.yaml.' + ); + } + + this.validateSchemaSourceTemplates(source.sourceDir, sourceSchema); + + const destinationDir = path.join(openspecPath, 'schemas', schemaName); + const sourceDir = FileSystemUtils.canonicalizeExistingPath(source.sourceDir); + const destinationExists = fs.existsSync(destinationDir); + const destinationDirResolved = destinationExists + ? FileSystemUtils.canonicalizeExistingPath(destinationDir) + : path.resolve(destinationDir); + const shouldImport = sourceDir !== destinationDirResolved; + + if (destinationExists && shouldImport && !this.force) { + throw new Error( + `Schema '${schemaName}' already exists at ${destinationDir}. Use --force to overwrite it.` + ); + } + + if (shouldImport && this.isSameOrDescendant(destinationDirResolved, sourceDir)) { + throw new Error('Cannot import schema because the source is inside the destination directory.'); + } + if (shouldImport && this.isSameOrDescendant(sourceDir, destinationDirResolved)) { + throw new Error('Cannot import schema because the destination is inside the source directory.'); + } + + return { + name: schemaName, + importSourceDir: sourceDir, + importDestinationDir: destinationDir, + shouldImport, + }; + } + + /** + * Normalizes an optional CLI schema name while preserving resolver-compatible names. + */ + private normalizeOptionalSchemaName(schemaName: string | undefined): string | undefined { + if (schemaName === undefined) { + return undefined; + } + + const normalized = schemaName.trim().replace(/\.ya?ml$/, ''); + if (normalized.length === 0) { + throw new Error('The --schema option requires a non-empty schema name.'); + } + return normalized; + } + + /** + * Validates schema names that will be used as project-local directory names. + */ + private validateSchemaName(schemaName: string, label: string): string { + const normalized = schemaName.trim(); + if (normalized.length === 0) { + throw new Error(`The ${label} must not be empty.`); + } + if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(normalized)) { + throw new Error(`Invalid ${label} '${schemaName}'. Use kebab-case (e.g., my-workflow).`); + } + return normalized; + } + + /** + * Resolves a schema bundle directory and its required schema.yaml file. + */ + private resolveSchemaSource(sourcePath: string): { sourceDir: string; schemaPath: string } { + const resolvedPath = path.resolve(sourcePath); + + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Schema source not found: ${sourcePath}`); + } + + const stats = fs.statSync(resolvedPath); + if (!stats.isDirectory()) { + throw new Error(`Schema source must be a directory containing schema.yaml: ${sourcePath}`); + } + + const schemaPath = path.join(resolvedPath, 'schema.yaml'); + if (!fs.existsSync(schemaPath)) { + throw new Error(`Schema source directory must contain schema.yaml: ${sourcePath}`); + } + + return { sourceDir: resolvedPath, schemaPath }; + } + + /** + * Loads and validates a schema.yaml file from a schema source bundle. + */ + private loadSchemaFromSource(schemaPath: string): SchemaYaml { + try { + return parseSchema(fs.readFileSync(schemaPath, 'utf-8')); + } catch (error) { + throw new Error(`Invalid schema source at ${schemaPath}: ${(error as Error).message}`); + } + } + + /** + * Ensures every artifact template referenced by the schema source exists within the bundle. + */ + private validateSchemaSourceTemplates( + sourceDir: string, + schema: SchemaYaml + ): void { + const canonicalSourceDir = FileSystemUtils.canonicalizeExistingPath(sourceDir); + + for (const artifact of schema.artifacts) { + const templatePathInTemplates = path.resolve(sourceDir, 'templates', artifact.template); + const templatePathInRoot = path.resolve(sourceDir, artifact.template); + const templatePaths = [templatePathInTemplates, templatePathInRoot]; + + if (templatePaths.some((templatePath) => !this.isSameOrDescendant(sourceDir, templatePath))) { + throw new Error( + `Schema source template '${artifact.template}' for artifact '${artifact.id}' must stay within the schema source directory.` + ); + } + + const existingTemplatePaths = templatePaths.filter((templatePath) => fs.existsSync(templatePath)); + + if (existingTemplatePaths.length === 0) { + throw new Error( + `Schema source is missing template '${artifact.template}' for artifact '${artifact.id}'.` + ); + } + + if (existingTemplatePaths.some((templatePath) => { + const canonicalTemplatePath = FileSystemUtils.canonicalizeExistingPath(templatePath); + return !this.isSameOrDescendant(canonicalSourceDir, canonicalTemplatePath); + })) { + throw new Error( + `Schema source template '${artifact.template}' for artifact '${artifact.id}' must stay within the schema source directory.` + ); + } + } + } + + /** + * Checks whether one path is equal to or nested under another path. + */ + private isSameOrDescendant(parentPath: string, candidatePath: string): boolean { + const relative = path.relative(parentPath, candidatePath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + } + + /** + * Copies an external schema bundle into the project-local schemas directory. + */ + private async importSchemaSource(plan: SchemaSetupPlan): Promise { + if (!plan.importSourceDir || !plan.importDestinationDir || !plan.shouldImport) { + return; + } + + if (fs.existsSync(plan.importDestinationDir)) { + await fs.promises.rm(plan.importDestinationDir, { recursive: true, force: true }); + } + + await fs.promises.mkdir(path.dirname(plan.importDestinationDir), { recursive: true }); + await fs.promises.cp(plan.importSourceDir, plan.importDestinationDir, { recursive: true }); + } + // ═══════════════════════════════════════════════════════════ // LEGACY CLEANUP // ═══════════════════════════════════════════════════════════ @@ -595,7 +795,7 @@ export class InitCommand { // CONFIG FILE // ═══════════════════════════════════════════════════════════ - private async createConfig(openspecPath: string, extendMode: boolean): Promise<'created' | 'exists' | 'skipped'> { + private async createConfig(openspecPath: string, schemaName: string): Promise<'created' | 'exists' | 'skipped'> { const configPath = path.join(openspecPath, 'config.yaml'); const configYmlPath = path.join(openspecPath, 'config.yml'); const configYamlExists = fs.existsSync(configPath); @@ -605,13 +805,19 @@ export class InitCommand { return 'exists'; } - // In non-interactive mode without --force, skip config creation - if (!this.canPromptInteractively() && !this.force) { + // In non-interactive mode without --force, keep the historical skip behavior + // unless the user explicitly requested a schema to persist. + if ( + !this.canPromptInteractively() && + !this.force && + this.schemaOverride === undefined && + this.schemaSource === undefined + ) { return 'skipped'; } try { - const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA }); + const yamlContent = serializeConfig({ schema: schemaName }); await FileSystemUtils.writeFile(configPath, yamlContent); return 'created'; } catch { @@ -634,7 +840,8 @@ export class InitCommand { removedCommandCount: number; removedSkillCount: number; }, - configStatus: 'created' | 'exists' | 'skipped' + configStatus: 'created' | 'exists' | 'skipped', + schemaName: string ): void { console.log(); console.log(chalk.bold('OpenSpec Setup Complete')); @@ -685,7 +892,7 @@ export class InitCommand { // Config status if (configStatus === 'created') { - console.log(`Config: openspec/config.yaml (schema: ${DEFAULT_SCHEMA})`); + console.log(`Config: openspec/config.yaml (schema: ${schemaName})`); } else if (configStatus === 'exists') { // Show actual filename (config.yaml or config.yml) const configYaml = path.join(projectPath, OPENSPEC_DIR_NAME, 'config.yaml'); diff --git a/test/cli-e2e/basic.test.ts b/test/cli-e2e/basic.test.ts index 22657513d..974aec946 100644 --- a/test/cli-e2e/basic.test.ts +++ b/test/cli-e2e/basic.test.ts @@ -56,6 +56,8 @@ describe('openspec CLI e2e basics', () => { expect(normalizedOutput).toContain( `Use "all", "none", or a comma-separated list of: ${expectedTools}` ); + expect(normalizedOutput).toContain('--schema '); + expect(normalizedOutput).toContain('--schema-source '); }); it('reports the package version', async () => { @@ -181,6 +183,64 @@ describe('openspec CLI e2e basics', () => { expect(await fileExists(cursorSkillPath)).toBe(false); }); + it('initializes with --schema option', async () => { + const projectDir = await prepareFixture('tmp-init'); + const emptyProjectDir = path.join(projectDir, '..', 'empty-project'); + await fs.mkdir(emptyProjectDir, { recursive: true }); + + const result = await runCLI( + ['init', '--tools', 'none', '--schema', 'workspace-planning'], + { cwd: emptyProjectDir } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('OpenSpec Setup Complete'); + expect(result.stdout).toContain('schema: workspace-planning'); + + const configPath = path.join(emptyProjectDir, 'openspec', 'config.yaml'); + const content = await fs.readFile(configPath, 'utf-8'); + expect(content).toContain('schema: workspace-planning'); + }); + + it('initializes with --schema-source option', async () => { + const projectDir = await prepareFixture('tmp-init'); + const emptyProjectDir = path.join(projectDir, '..', 'empty-project'); + const sourceDir = path.join(projectDir, '..', 'omnidev-flow'); + await fs.mkdir(path.join(emptyProjectDir), { recursive: true }); + await fs.mkdir(path.join(sourceDir, 'templates'), { recursive: true }); + await fs.writeFile( + path.join(sourceDir, 'schema.yaml'), + `name: omnidev-flow +version: 1 +description: Omnidev workflow +artifacts: + - id: proposal + description: Proposal + generates: proposal.md + template: proposal.md + requires: [] +` + ); + await fs.writeFile(path.join(sourceDir, 'templates', 'proposal.md'), '# Proposal\n'); + + const result = await runCLI( + ['init', '--tools', 'none', '--schema-source', sourceDir], + { cwd: emptyProjectDir } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('schema: omnidev-flow'); + + const configPath = path.join(emptyProjectDir, 'openspec', 'config.yaml'); + const importedSchemaPath = path.join( + emptyProjectDir, + 'openspec', + 'schemas', + 'omnidev-flow', + 'schema.yaml' + ); + expect(await fs.readFile(configPath, 'utf-8')).toContain('schema: omnidev-flow'); + expect(await fileExists(importedSchemaPath)).toBe(true); + }); + it('returns error for invalid tool names', async () => { const projectDir = await prepareFixture('tmp-init'); const emptyProjectDir = path.join(projectDir, '..', 'empty-project'); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed..6ffe6a1b8 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -77,6 +77,226 @@ describe('InitCommand', () => { expect(content).toContain('schema: spec-driven'); }); + it('should create config.yaml with a specified built-in schema', async () => { + const initCommand = new InitCommand({ + tools: 'none', + force: true, + schema: 'workspace-planning', + }); + + await initCommand.execute(testDir); + + const configPath = path.join(testDir, 'openspec', 'config.yaml'); + const content = await fs.readFile(configPath, 'utf-8'); + expect(content).toContain('schema: workspace-planning'); + }); + + it('should create config.yaml for explicit schema in non-interactive mode without force', async () => { + const initCommand = new InitCommand({ + tools: 'none', + schema: 'workspace-planning', + }); + + await initCommand.execute(testDir); + + const configPath = path.join(testDir, 'openspec', 'config.yaml'); + const content = await fs.readFile(configPath, 'utf-8'); + expect(content).toContain('schema: workspace-planning'); + }); + + it('should create config.yaml with a specified project-local schema', async () => { + const schemaDir = path.join(testDir, 'openspec', 'schemas', 'team-flow'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + `name: team-flow +version: 1 +description: Team workflow +artifacts: + - id: proposal + description: Proposal + generates: proposal.md + template: proposal.md + requires: [] +` + ); + + const initCommand = new InitCommand({ + tools: 'none', + force: true, + schema: 'team-flow', + }); + + await initCommand.execute(testDir); + + const configPath = path.join(testDir, 'openspec', 'config.yaml'); + const content = await fs.readFile(configPath, 'utf-8'); + expect(content).toContain('schema: team-flow'); + }); + + it('should import schema source directory and infer schema name', async () => { + const sourceDir = path.join(testDir, 'external-schemas', 'team-flow'); + await createSchemaBundle(sourceDir, 'team-flow'); + + const initCommand = new InitCommand({ + tools: 'none', + schemaSource: sourceDir, + }); + + await initCommand.execute(testDir); + + const importedSchemaPath = path.join(testDir, 'openspec', 'schemas', 'team-flow', 'schema.yaml'); + const importedTemplatePath = path.join(testDir, 'openspec', 'schemas', 'team-flow', 'templates', 'proposal.md'); + const configPath = path.join(testDir, 'openspec', 'config.yaml'); + + expect(await fileExists(importedSchemaPath)).toBe(true); + expect(await fileExists(importedTemplatePath)).toBe(true); + expect(await fs.readFile(configPath, 'utf-8')).toContain('schema: team-flow'); + }); + + it('should reject schema source when path is not a directory', async () => { + const sourceDir = path.join(testDir, 'external-schemas', 'file-flow'); + await createSchemaBundle(sourceDir, 'file-flow'); + + const initCommand = new InitCommand({ + tools: 'none', + schemaSource: path.join(sourceDir, 'schema.yaml'), + }); + + await expect(initCommand.execute(testDir)).rejects.toThrow( + /Schema source must be a directory containing schema.yaml/ + ); + expect(await directoryExists(path.join(testDir, 'openspec'))).toBe(false); + }); + + it('should reject schema source when explicit schema name does not match', async () => { + const sourceDir = path.join(testDir, 'external-schemas', 'team-flow'); + await createSchemaBundle(sourceDir, 'team-flow'); + + const initCommand = new InitCommand({ + tools: 'none', + force: true, + schema: 'other-flow', + schemaSource: sourceDir, + }); + + await expect(initCommand.execute(testDir)).rejects.toThrow( + /Schema source declares name 'team-flow', but --schema was 'other-flow'/ + ); + expect(await directoryExists(path.join(testDir, 'openspec'))).toBe(false); + }); + + it('should reject schema source with missing templates', async () => { + const sourceDir = path.join(testDir, 'external-schemas', 'broken-flow'); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.writeFile( + path.join(sourceDir, 'schema.yaml'), + `name: broken-flow +version: 1 +description: Broken workflow +artifacts: + - id: proposal + description: Proposal + generates: proposal.md + template: proposal.md + requires: [] +` + ); + + const initCommand = new InitCommand({ + tools: 'none', + force: true, + schemaSource: sourceDir, + }); + + await expect(initCommand.execute(testDir)).rejects.toThrow( + /Schema source is missing template 'proposal.md'/ + ); + expect(await directoryExists(path.join(testDir, 'openspec'))).toBe(false); + }); + + it('should reject schema source template paths outside the bundle root', async () => { + const sourceDir = path.join(testDir, 'external-schemas', 'escaping-flow'); + const sharedDir = path.join(testDir, 'external-schemas', 'shared'); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.mkdir(sharedDir, { recursive: true }); + await fs.writeFile(path.join(sharedDir, 'proposal.md'), '# Shared Proposal\n'); + await fs.writeFile( + path.join(sourceDir, 'schema.yaml'), + `name: escaping-flow +version: 1 +description: Escaping workflow +artifacts: + - id: proposal + description: Proposal + generates: proposal.md + template: ../shared/proposal.md + requires: [] +` + ); + + const initCommand = new InitCommand({ + tools: 'none', + force: true, + schemaSource: sourceDir, + }); + + await expect(initCommand.execute(testDir)).rejects.toThrow( + /must stay within the schema source directory/ + ); + expect(await directoryExists(path.join(testDir, 'openspec'))).toBe(false); + }); + + it.skipIf(process.platform === 'win32')('should reject schema source template symlinks outside the bundle root', async () => { + const sourceDir = path.join(testDir, 'external-schemas', 'symlink-flow'); + const sharedDir = path.join(testDir, 'external-schemas', 'shared'); + const templatesDir = path.join(sourceDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + await fs.mkdir(sharedDir, { recursive: true }); + await fs.writeFile(path.join(sharedDir, 'proposal.md'), '# Shared Proposal\n'); + await fs.symlink( + path.join(sharedDir, 'proposal.md'), + path.join(templatesDir, 'proposal.md') + ); + await fs.writeFile( + path.join(sourceDir, 'schema.yaml'), + `name: symlink-flow +version: 1 +description: Symlink workflow +artifacts: + - id: proposal + description: Proposal + generates: proposal.md + template: proposal.md + requires: [] +` + ); + + const initCommand = new InitCommand({ + tools: 'none', + force: true, + schemaSource: sourceDir, + }); + + await expect(initCommand.execute(testDir)).rejects.toThrow( + /must stay within the schema source directory/ + ); + expect(await directoryExists(path.join(testDir, 'openspec'))).toBe(false); + }); + + it('should reject an unknown schema', async () => { + const initCommand = new InitCommand({ + tools: 'none', + force: true, + schema: 'missing-schema', + }); + + await expect(initCommand.execute(testDir)).rejects.toThrow( + /Schema 'missing-schema' not found/ + ); + expect(await directoryExists(path.join(testDir, 'openspec'))).toBe(false); + }); + it('should create core profile skills for Claude Code by default', async () => { const initCommand = new InitCommand({ tools: 'claude', force: true }); @@ -776,6 +996,24 @@ async function fileExists(filePath: string): Promise { } } +async function createSchemaBundle(schemaDir: string, name: string): Promise { + await fs.mkdir(path.join(schemaDir, 'templates'), { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + `name: ${name} +version: 1 +description: Test workflow +artifacts: + - id: proposal + description: Proposal + generates: proposal.md + template: proposal.md + requires: [] +` + ); + await fs.writeFile(path.join(schemaDir, 'templates', 'proposal.md'), '# Proposal\n'); +} + async function directoryExists(dirPath: string): Promise { try { const stats = await fs.stat(dirPath);