@fission-ai/openspec
Version:
AI-native system for spec-driven development
869 lines (846 loc) • 33.4 kB
JavaScript
import * as fs from 'node:fs';
import * as path from 'node:path';
import ora from 'ora';
import { stringify as stringifyYaml } from 'yaml';
import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, listSchemas, } from '../core/artifact-graph/resolver.js';
import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
/**
* Check all three locations for a schema and return which ones exist.
*/
function checkAllLocations(name, projectRoot) {
const locations = [];
// Project location
const projectDir = path.join(getProjectSchemasDir(projectRoot), name);
const projectSchemaPath = path.join(projectDir, 'schema.yaml');
locations.push({
source: 'project',
path: projectDir,
exists: fs.existsSync(projectSchemaPath),
});
// User location
const userDir = path.join(getUserSchemasDir(), name);
const userSchemaPath = path.join(userDir, 'schema.yaml');
locations.push({
source: 'user',
path: userDir,
exists: fs.existsSync(userSchemaPath),
});
// Package location
const packageDir = path.join(getPackageSchemasDir(), name);
const packageSchemaPath = path.join(packageDir, 'schema.yaml');
locations.push({
source: 'package',
path: packageDir,
exists: fs.existsSync(packageSchemaPath),
});
return locations;
}
/**
* Get resolution info for a schema including shadow detection.
*/
function getSchemaResolution(name, projectRoot) {
const locations = checkAllLocations(name, projectRoot);
const existingLocations = locations.filter((loc) => loc.exists);
if (existingLocations.length === 0) {
return null;
}
const active = existingLocations[0];
const shadows = existingLocations.slice(1).map((loc) => ({
source: loc.source,
path: loc.path,
}));
return {
name,
source: active.source,
path: active.path,
shadows,
};
}
/**
* Get all schemas with resolution info.
*/
function getAllSchemasWithResolution(projectRoot) {
const schemaNames = listSchemas(projectRoot);
const results = [];
for (const name of schemaNames) {
const resolution = getSchemaResolution(name, projectRoot);
if (resolution) {
results.push(resolution);
}
}
return results;
}
/**
* Validate a schema and return issues.
*/
function validateSchema(schemaDir, verbose = false) {
const issues = [];
const schemaPath = path.join(schemaDir, 'schema.yaml');
// Check schema.yaml exists
if (verbose) {
console.log(' Checking schema.yaml exists...');
}
if (!fs.existsSync(schemaPath)) {
issues.push({
level: 'error',
path: 'schema.yaml',
message: 'schema.yaml not found',
});
return { valid: false, issues };
}
// Parse YAML
if (verbose) {
console.log(' Parsing YAML...');
}
let content;
try {
content = fs.readFileSync(schemaPath, 'utf-8');
}
catch (err) {
issues.push({
level: 'error',
path: 'schema.yaml',
message: `Failed to read file: ${err.message}`,
});
return { valid: false, issues };
}
// Validate against Zod schema
if (verbose) {
console.log(' Validating schema structure...');
}
let schema;
try {
schema = parseSchema(content);
}
catch (err) {
if (err instanceof SchemaValidationError) {
issues.push({
level: 'error',
path: 'schema.yaml',
message: err.message,
});
}
else {
issues.push({
level: 'error',
path: 'schema.yaml',
message: `Parse error: ${err.message}`,
});
}
return { valid: false, issues };
}
// Check template files exist
// Templates can be in schemaDir directly or in a templates/ subdirectory
if (verbose) {
console.log(' Checking template files...');
}
for (const artifact of schema.artifacts) {
// Try templates subdirectory first (standard location), then root
const templatePathInTemplates = path.join(schemaDir, 'templates', artifact.template);
const templatePathInRoot = path.join(schemaDir, artifact.template);
if (!fs.existsSync(templatePathInTemplates) && !fs.existsSync(templatePathInRoot)) {
issues.push({
level: 'error',
path: `artifacts.${artifact.id}.template`,
message: `Template file '${artifact.template}' not found for artifact '${artifact.id}'`,
});
}
}
// Dependency graph validation is already done by parseSchema
// (it throws on cycles and invalid references)
if (verbose) {
console.log(' Dependency graph validation passed (via parseSchema)');
}
return { valid: issues.length === 0, issues };
}
/**
* Validate schema name format (kebab-case).
*/
function isValidSchemaName(name) {
return /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name);
}
/**
* Copy a directory recursively.
*/
function copyDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath);
}
else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Default artifacts with descriptions for schema init.
*/
const DEFAULT_ARTIFACTS = [
{
id: 'proposal',
description: 'High-level description of the change, its motivation, and scope',
generates: 'proposal.md',
template: 'proposal.md',
},
{
id: 'specs',
description: 'Detailed specifications with requirements and scenarios',
generates: 'specs/**/*.md',
template: 'specs/spec.md',
},
{
id: 'design',
description: 'Technical design decisions and implementation approach',
generates: 'design.md',
template: 'design.md',
},
{
id: 'tasks',
description: 'Implementation checklist with trackable tasks',
generates: 'tasks.md',
template: 'tasks.md',
},
];
/**
* Register the schema command and all its subcommands.
*/
export function registerSchemaCommand(program) {
const schemaCmd = program
.command('schema')
.description('Manage workflow schemas [experimental]');
// Experimental warning
schemaCmd.hook('preAction', () => {
console.error('Note: Schema commands are experimental and may change.');
});
// schema which
schemaCmd
.command('which [name]')
.description('Show where a schema resolves from')
.option('--json', 'Output as JSON')
.option('--all', 'List all schemas with their resolution sources')
.action(async (name, options) => {
try {
const projectRoot = process.cwd();
if (options?.all) {
// List all schemas
const schemas = getAllSchemasWithResolution(projectRoot);
if (options?.json) {
console.log(JSON.stringify(schemas, null, 2));
}
else {
if (schemas.length === 0) {
console.log('No schemas found.');
return;
}
// Group by source
const bySource = {
project: schemas.filter((s) => s.source === 'project'),
user: schemas.filter((s) => s.source === 'user'),
package: schemas.filter((s) => s.source === 'package'),
};
if (bySource.project.length > 0) {
console.log('\nProject schemas:');
for (const schema of bySource.project) {
const shadowInfo = schema.shadows.length > 0
? ` (shadows: ${schema.shadows.map((s) => s.source).join(', ')})`
: '';
console.log(` ${schema.name}${shadowInfo}`);
}
}
if (bySource.user.length > 0) {
console.log('\nUser schemas:');
for (const schema of bySource.user) {
const shadowInfo = schema.shadows.length > 0
? ` (shadows: ${schema.shadows.map((s) => s.source).join(', ')})`
: '';
console.log(` ${schema.name}${shadowInfo}`);
}
}
if (bySource.package.length > 0) {
console.log('\nPackage schemas:');
for (const schema of bySource.package) {
console.log(` ${schema.name}`);
}
}
}
return;
}
if (!name) {
console.error('Error: Schema name is required (or use --all to list all schemas)');
process.exitCode = 1;
return;
}
const resolution = getSchemaResolution(name, projectRoot);
if (!resolution) {
const available = listSchemas(projectRoot);
if (options?.json) {
console.log(JSON.stringify({
error: `Schema '${name}' not found`,
available,
}, null, 2));
}
else {
console.error(`Error: Schema '${name}' not found`);
console.error(`Available schemas: ${available.join(', ')}`);
}
process.exitCode = 1;
return;
}
if (options?.json) {
console.log(JSON.stringify(resolution, null, 2));
}
else {
console.log(`Schema: ${resolution.name}`);
console.log(`Source: ${resolution.source}`);
console.log(`Path: ${resolution.path}`);
if (resolution.shadows.length > 0) {
console.log('\nShadows:');
for (const shadow of resolution.shadows) {
console.log(` ${shadow.source}: ${shadow.path}`);
}
}
}
}
catch (error) {
console.error(`Error: ${error.message}`);
process.exitCode = 1;
}
});
// schema validate
schemaCmd
.command('validate [name]')
.description('Validate a schema structure and templates')
.option('--json', 'Output as JSON')
.option('--verbose', 'Show detailed validation steps')
.action(async (name, options) => {
try {
const projectRoot = process.cwd();
if (!name) {
// Validate all project schemas
const projectSchemasDir = getProjectSchemasDir(projectRoot);
if (!fs.existsSync(projectSchemasDir)) {
if (options?.json) {
console.log(JSON.stringify({
valid: true,
message: 'No project schemas directory found',
schemas: [],
}, null, 2));
}
else {
console.log('No project schemas directory found.');
}
return;
}
const entries = fs.readdirSync(projectSchemasDir, { withFileTypes: true });
const schemaResults = [];
let anyInvalid = false;
for (const entry of entries) {
if (!entry.isDirectory())
continue;
const schemaDir = path.join(projectSchemasDir, entry.name);
const schemaPath = path.join(schemaDir, 'schema.yaml');
if (!fs.existsSync(schemaPath))
continue;
if (options?.verbose && !options?.json) {
console.log(`\nValidating ${entry.name}...`);
}
const result = validateSchema(schemaDir, options?.verbose && !options?.json);
schemaResults.push({
name: entry.name,
path: schemaDir,
valid: result.valid,
issues: result.issues,
});
if (!result.valid) {
anyInvalid = true;
}
}
if (options?.json) {
console.log(JSON.stringify({
valid: !anyInvalid,
schemas: schemaResults,
}, null, 2));
}
else {
if (schemaResults.length === 0) {
console.log('No schemas found in project.');
return;
}
console.log('\nValidation Results:');
for (const result of schemaResults) {
const status = result.valid ? '✓' : '✗';
console.log(` ${status} ${result.name}`);
for (const issue of result.issues) {
console.log(` ${issue.level}: ${issue.message}`);
}
}
if (anyInvalid) {
process.exitCode = 1;
}
}
return;
}
// Validate specific schema
const schemaDir = getSchemaDir(name, projectRoot);
if (!schemaDir) {
const available = listSchemas(projectRoot);
if (options?.json) {
console.log(JSON.stringify({
valid: false,
error: `Schema '${name}' not found`,
available,
}, null, 2));
}
else {
console.error(`Error: Schema '${name}' not found`);
console.error(`Available schemas: ${available.join(', ')}`);
}
process.exitCode = 1;
return;
}
if (options?.verbose && !options?.json) {
console.log(`Validating ${name}...`);
}
const result = validateSchema(schemaDir, options?.verbose && !options?.json);
if (options?.json) {
console.log(JSON.stringify({
name,
path: schemaDir,
valid: result.valid,
issues: result.issues,
}, null, 2));
}
else {
if (result.valid) {
console.log(`✓ Schema '${name}' is valid`);
}
else {
console.log(`✗ Schema '${name}' has errors:`);
for (const issue of result.issues) {
console.log(` ${issue.level}: ${issue.message}`);
}
process.exitCode = 1;
}
}
}
catch (error) {
if (options?.json) {
console.log(JSON.stringify({
valid: false,
error: error.message,
}, null, 2));
}
else {
console.error(`Error: ${error.message}`);
}
process.exitCode = 1;
}
});
// schema fork
schemaCmd
.command('fork <source> [name]')
.description('Copy an existing schema to project for customization')
.option('--json', 'Output as JSON')
.option('--force', 'Overwrite existing destination')
.action(async (source, name, options) => {
const spinner = options?.json ? null : ora();
try {
const projectRoot = process.cwd();
const destinationName = name || `${source}-custom`;
// Validate destination name
if (!isValidSchemaName(destinationName)) {
if (options?.json) {
console.log(JSON.stringify({
forked: false,
error: `Invalid schema name '${destinationName}'. Use kebab-case (e.g., my-workflow)`,
}, null, 2));
}
else {
console.error(`Error: Invalid schema name '${destinationName}'`);
console.error('Schema names must be kebab-case (e.g., my-workflow)');
}
process.exitCode = 1;
return;
}
// Find source schema
const sourceDir = getSchemaDir(source, projectRoot);
if (!sourceDir) {
const available = listSchemas(projectRoot);
if (options?.json) {
console.log(JSON.stringify({
forked: false,
error: `Schema '${source}' not found`,
available,
}, null, 2));
}
else {
console.error(`Error: Schema '${source}' not found`);
console.error(`Available schemas: ${available.join(', ')}`);
}
process.exitCode = 1;
return;
}
// Determine source location
const sourceResolution = getSchemaResolution(source, projectRoot);
const sourceLocation = sourceResolution?.source || 'package';
// Check destination
const destinationDir = path.join(getProjectSchemasDir(projectRoot), destinationName);
if (fs.existsSync(destinationDir)) {
if (!options?.force) {
if (options?.json) {
console.log(JSON.stringify({
forked: false,
error: `Schema '${destinationName}' already exists`,
suggestion: 'Use --force to overwrite',
}, null, 2));
}
else {
console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);
console.error('Use --force to overwrite');
}
process.exitCode = 1;
return;
}
// Remove existing
if (spinner)
spinner.start(`Removing existing schema '${destinationName}'...`);
fs.rmSync(destinationDir, { recursive: true });
}
// Copy schema
if (spinner)
spinner.start(`Forking '${source}' to '${destinationName}'...`);
copyDirRecursive(sourceDir, destinationDir);
// Update name in schema.yaml
const destSchemaPath = path.join(destinationDir, 'schema.yaml');
const schemaContent = fs.readFileSync(destSchemaPath, 'utf-8');
const schema = parseSchema(schemaContent);
schema.name = destinationName;
fs.writeFileSync(destSchemaPath, stringifyYaml(schema));
if (spinner)
spinner.succeed(`Forked '${source}' to '${destinationName}'`);
if (options?.json) {
console.log(JSON.stringify({
forked: true,
source,
sourcePath: sourceDir,
sourceLocation,
destination: destinationName,
destinationPath: destinationDir,
}, null, 2));
}
else {
console.log(`\nSource: ${sourceDir} (${sourceLocation})`);
console.log(`Destination: ${destinationDir}`);
console.log(`\nYou can now customize the schema at:`);
console.log(` ${destinationDir}/schema.yaml`);
}
}
catch (error) {
if (spinner)
spinner.fail(`Fork failed`);
if (options?.json) {
console.log(JSON.stringify({
forked: false,
error: error.message,
}, null, 2));
}
else {
console.error(`Error: ${error.message}`);
}
process.exitCode = 1;
}
});
// schema init
schemaCmd
.command('init <name>')
.description('Create a new project-local schema')
.option('--json', 'Output as JSON')
.option('--description <text>', 'Schema description')
.option('--artifacts <list>', 'Comma-separated artifact IDs (proposal,specs,design,tasks)')
.option('--default', 'Set as project default schema')
.option('--no-default', 'Do not prompt to set as default')
.option('--force', 'Overwrite existing schema')
.action(async (name, options) => {
const spinner = options?.json ? null : ora();
try {
const projectRoot = process.cwd();
// Validate name
if (!isValidSchemaName(name)) {
if (options?.json) {
console.log(JSON.stringify({
created: false,
error: `Invalid schema name '${name}'. Use kebab-case (e.g., my-workflow)`,
}, null, 2));
}
else {
console.error(`Error: Invalid schema name '${name}'`);
console.error('Schema names must be kebab-case (e.g., my-workflow)');
}
process.exitCode = 1;
return;
}
const schemaDir = path.join(getProjectSchemasDir(projectRoot), name);
// Check if exists
if (fs.existsSync(schemaDir)) {
if (!options?.force) {
if (options?.json) {
console.log(JSON.stringify({
created: false,
error: `Schema '${name}' already exists`,
suggestion: 'Use --force to overwrite or "openspec schema fork" to copy',
}, null, 2));
}
else {
console.error(`Error: Schema '${name}' already exists at ${schemaDir}`);
console.error('Use --force to overwrite or "openspec schema fork" to copy');
}
process.exitCode = 1;
return;
}
if (spinner)
spinner.start(`Removing existing schema '${name}'...`);
fs.rmSync(schemaDir, { recursive: true });
}
// Determine artifacts and description
let description;
let selectedArtifactIds;
// Check if we have explicit flags (non-interactive mode)
const hasExplicitOptions = options?.description !== undefined || options?.artifacts !== undefined;
const isInteractive = !options?.json && !hasExplicitOptions && process.stdout.isTTY;
if (isInteractive) {
// Interactive mode
const { input, checkbox, confirm } = await import('@inquirer/prompts');
description = await input({
message: 'Schema description:',
default: `Custom workflow schema for ${name}`,
});
const artifactChoices = DEFAULT_ARTIFACTS.map((a) => ({
name: a.id,
value: a.id,
checked: true,
}));
selectedArtifactIds = await checkbox({
message: 'Select artifacts to include:',
choices: artifactChoices,
});
if (selectedArtifactIds.length === 0) {
console.error('Error: At least one artifact must be selected');
process.exitCode = 1;
return;
}
// Ask about setting as default (unless --no-default was passed)
if (options?.default === undefined) {
const setAsDefault = await confirm({
message: 'Set as project default schema?',
default: false,
});
if (setAsDefault) {
options = { ...options, default: true };
}
}
}
else {
// Non-interactive mode
description = options?.description || `Custom workflow schema for ${name}`;
if (options?.artifacts) {
selectedArtifactIds = options.artifacts.split(',').map((a) => a.trim());
// Validate artifact IDs
const validIds = DEFAULT_ARTIFACTS.map((a) => a.id);
for (const id of selectedArtifactIds) {
if (!validIds.includes(id)) {
if (options?.json) {
console.log(JSON.stringify({
created: false,
error: `Unknown artifact '${id}'`,
valid: validIds,
}, null, 2));
}
else {
console.error(`Error: Unknown artifact '${id}'`);
console.error(`Valid artifacts: ${validIds.join(', ')}`);
}
process.exitCode = 1;
return;
}
}
}
else {
// Default to all artifacts
selectedArtifactIds = DEFAULT_ARTIFACTS.map((a) => a.id);
}
}
// Create schema directory
if (spinner)
spinner.start(`Creating schema '${name}'...`);
fs.mkdirSync(schemaDir, { recursive: true });
// Build artifacts array with proper dependencies
const selectedArtifacts = selectedArtifactIds.map((id) => {
const template = DEFAULT_ARTIFACTS.find((a) => a.id === id);
const artifact = {
id: template.id,
generates: template.generates,
description: template.description,
template: template.template,
requires: [],
};
// Set up dependencies based on typical workflow
if (id === 'specs' && selectedArtifactIds.includes('proposal')) {
artifact.requires = ['proposal'];
}
else if (id === 'design' && selectedArtifactIds.includes('specs')) {
artifact.requires = ['specs'];
}
else if (id === 'tasks') {
const requires = [];
if (selectedArtifactIds.includes('design'))
requires.push('design');
else if (selectedArtifactIds.includes('specs'))
requires.push('specs');
artifact.requires = requires;
}
return artifact;
});
// Create schema.yaml
const schema = {
name,
version: 1,
description,
artifacts: selectedArtifacts,
};
// Add apply phase if tasks is included
if (selectedArtifactIds.includes('tasks')) {
schema.apply = {
requires: ['tasks'],
tracks: 'tasks.md',
};
}
fs.writeFileSync(path.join(schemaDir, 'schema.yaml'), stringifyYaml(schema));
// Create template files in templates/ subdirectory (standard location)
const templatesDir = path.join(schemaDir, 'templates');
for (const artifact of selectedArtifacts) {
const templatePath = path.join(templatesDir, artifact.template);
const templateDir = path.dirname(templatePath);
if (!fs.existsSync(templateDir)) {
fs.mkdirSync(templateDir, { recursive: true });
}
// Create default template content
const templateContent = createDefaultTemplate(artifact.id);
fs.writeFileSync(templatePath, templateContent);
}
// Update config if --default
if (options?.default) {
const configPath = path.join(projectRoot, 'openspec', 'config.yaml');
if (fs.existsSync(configPath)) {
const { parse: parseYaml, stringify: stringifyYaml2 } = await import('yaml');
const configContent = fs.readFileSync(configPath, 'utf-8');
const config = parseYaml(configContent) || {};
config.defaultSchema = name;
fs.writeFileSync(configPath, stringifyYaml2(config));
}
else {
// Create config file
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(configPath, stringifyYaml({ defaultSchema: name }));
}
}
if (spinner)
spinner.succeed(`Created schema '${name}'`);
if (options?.json) {
console.log(JSON.stringify({
created: true,
path: schemaDir,
schema: name,
artifacts: selectedArtifactIds,
setAsDefault: options?.default || false,
}, null, 2));
}
else {
console.log(`\nSchema created at: ${schemaDir}`);
console.log(`\nArtifacts: ${selectedArtifactIds.join(', ')}`);
if (options?.default) {
console.log(`\nSet as project default schema.`);
}
console.log(`\nNext steps:`);
console.log(` 1. Edit ${schemaDir}/schema.yaml to customize artifacts`);
console.log(` 2. Modify templates in the schema directory`);
console.log(` 3. Use with: openspec new --schema ${name}`);
}
}
catch (error) {
if (spinner)
spinner.fail(`Creation failed`);
if (options?.json) {
console.log(JSON.stringify({
created: false,
error: error.message,
}, null, 2));
}
else {
console.error(`Error: ${error.message}`);
}
process.exitCode = 1;
}
});
}
/**
* Create default template content for an artifact.
*/
function createDefaultTemplate(artifactId) {
switch (artifactId) {
case 'proposal':
return `## Why
<!-- Describe the motivation for this change -->
## What Changes
<!-- Describe what will change -->
## Capabilities
### New Capabilities
<!-- List new capabilities -->
### Modified Capabilities
<!-- List modified capabilities -->
## Impact
<!-- Describe the impact on existing functionality -->
`;
case 'specs':
return `## ADDED Requirements
### Requirement: Example requirement
Description of the requirement.
#### Scenario: Example scenario
- **WHEN** some condition
- **THEN** some outcome
`;
case 'design':
return `## Context
<!-- Background and context -->
## Goals / Non-Goals
**Goals:**
<!-- List goals -->
**Non-Goals:**
<!-- List non-goals -->
## Decisions
### 1. Decision Name
Description and rationale.
**Alternatives considered:**
- Alternative 1: Rejected because...
## Risks / Trade-offs
<!-- List risks and trade-offs -->
`;
case 'tasks':
return `## Implementation Tasks
- [ ] Task 1
- [ ] Task 2
- [ ] Task 3
`;
default:
return `## ${artifactId}
<!-- Add content here -->
`;
}
}
//# sourceMappingURL=schema.js.map