mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-15 05:30:21 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f5448dec | ||
|
|
7553e83fb9 | ||
|
|
2501a41097 | ||
|
|
9111a7654d |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@fission-ai/openspec": patch
|
||||
---
|
||||
|
||||
Switching to skills-only delivery no longer deletes a command file you edited. `openspec init` and `openspec update` now compare each generated command file against what OpenSpec would write before removing it; anything that differs is left on disk and listed by path, with a line telling you to move what you want into the skill and delete the rest by hand. The comparison ignores a UTF-8 BOM and CRLF line endings, so a Windows checkout is not mistaken for an edit, and it accounts for files written under a different profile than the one now configured. A file that cannot be read or deleted, or a symbolic link that points outside the project, is left alone and listed separately, so one bad entry no longer stops the rest of the cleanup.
|
||||
@@ -26,3 +26,15 @@ overrides:
|
||||
# Dev-only (transitive via @vitest/ui); never in the published CLI. Remove once
|
||||
# transitive fflate is >=0.8.3 (check: pnpm why fflate).
|
||||
fflate@<0.8.3: '>=0.8.3 <0.9'
|
||||
|
||||
auditConfig:
|
||||
ignoreGhsas:
|
||||
# GHSA-vfj7-8cjw-p6xm — braces <=3.0.3 stack exhaustion on deeply nested
|
||||
# patterns. Runtime (fast-glob > micromatch > braces), and no patched braces
|
||||
# exists, so no override can fix it. Accepted residual risk: schema artifact
|
||||
# `generates` and `apply.tracks` patterns reach fast-glob through
|
||||
# resolveArtifactOutputs without a brace-depth guard, and the 10,000-character
|
||||
# input limit does not prevent stack exhaustion. A crafted project-local or
|
||||
# user schema can still terminate the openspec process. Remove this exception
|
||||
# once braces ships a patched release (check: pnpm why braces; pnpm audit --prod).
|
||||
- GHSA-vfj7-8cjw-p6xm
|
||||
|
||||
+15
-22
@@ -61,6 +61,7 @@ import {
|
||||
type ToolSkillStatus,
|
||||
formatIdeRestart,
|
||||
} from './shared/index.js';
|
||||
import { reconcileCommandFiles, formatKeptCommandFiles } from './shared/command-reconcile.js';
|
||||
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';
|
||||
@@ -958,6 +959,7 @@ export class InitCommand {
|
||||
commandsSkipped: string[];
|
||||
skillsInvocableCommandSkips: string[];
|
||||
removedCommandCount: number;
|
||||
leftCommandFiles: { kept: string[]; unchecked: string[] };
|
||||
removedSkillCount: number;
|
||||
}> {
|
||||
const createdTools: typeof tools = [];
|
||||
@@ -966,6 +968,7 @@ export class InitCommand {
|
||||
const commandsSkipped: string[] = [];
|
||||
const skillsInvocableCommandSkips: string[] = [];
|
||||
let removedCommandCount = 0;
|
||||
const leftCommandFiles = { kept: [] as string[], unchecked: [] as string[] };
|
||||
let removedSkillCount = 0;
|
||||
|
||||
// Read global config for profile and delivery settings (use --profile override if set)
|
||||
@@ -1039,7 +1042,10 @@ export class InitCommand {
|
||||
}
|
||||
}
|
||||
if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
|
||||
removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
|
||||
const reconciled = await reconcileCommandFiles(projectPath, tool.value, workflows);
|
||||
removedCommandCount += reconciled.removed.length;
|
||||
leftCommandFiles.kept.push(...reconciled.kept);
|
||||
leftCommandFiles.unchecked.push(...reconciled.unchecked);
|
||||
}
|
||||
if (tool.value === 'github-copilot' && writeCopilotCloud) {
|
||||
await writeCopilotCloudFiles(projectPath);
|
||||
@@ -1079,6 +1085,7 @@ export class InitCommand {
|
||||
commandsSkipped,
|
||||
skillsInvocableCommandSkips,
|
||||
removedCommandCount,
|
||||
leftCommandFiles,
|
||||
removedSkillCount,
|
||||
};
|
||||
}
|
||||
@@ -1189,6 +1196,7 @@ export class InitCommand {
|
||||
commandsSkipped: string[];
|
||||
skillsInvocableCommandSkips: string[];
|
||||
removedCommandCount: number;
|
||||
leftCommandFiles: { kept: string[]; unchecked: string[] };
|
||||
removedSkillCount: number;
|
||||
},
|
||||
configStatus: 'created' | 'exists' | 'skipped',
|
||||
@@ -1301,6 +1309,12 @@ export class InitCommand {
|
||||
if (results.removedCommandCount > 0) {
|
||||
console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`));
|
||||
}
|
||||
for (const [headline, ...detail] of formatKeptCommandFiles(results.leftCommandFiles)) {
|
||||
console.log(chalk.yellow(headline));
|
||||
for (const line of detail) {
|
||||
console.log(chalk.dim(line));
|
||||
}
|
||||
}
|
||||
if (results.removedSkillCount > 0) {
|
||||
console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));
|
||||
}
|
||||
@@ -1514,25 +1528,4 @@ export class InitCommand {
|
||||
return removed;
|
||||
}
|
||||
|
||||
private async removeCommandFiles(projectPath: string, toolId: string): Promise<number> {
|
||||
let removed = 0;
|
||||
const adapter = CommandAdapterRegistry.get(toolId);
|
||||
if (!adapter) return 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
await fs.promises.unlink(fullPath);
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Delivery } from './global-config.js';
|
||||
import { ALL_WORKFLOWS } from './profiles.js';
|
||||
import { CommandAdapterRegistry } from './command-generation/index.js';
|
||||
import { getConfiguredTools } from './shared/index.js';
|
||||
import { classifyCommandFiles } from './shared/command-reconcile.js';
|
||||
import {
|
||||
shouldGenerateCommandsForTool,
|
||||
shouldGenerateSkillsForTool,
|
||||
@@ -163,12 +164,11 @@ export function hasToolProfileOrDeliveryDrift(
|
||||
}
|
||||
}
|
||||
} else if (shouldReconcileCommandFilesForTool(toolId, delivery) && adapter) {
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return true;
|
||||
}
|
||||
// Only generated output is drift. Files cleanup keeps (edits, older
|
||||
// versions, unreadable entries) stay on disk by design; counting them would
|
||||
// re-sync on every update.
|
||||
if (classifyCommandFiles(projectPath, toolId, knownDesiredWorkflows).generated.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Command File Reconciliation
|
||||
*
|
||||
* Removing generated command files when delivery becomes skills-only.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import {
|
||||
generateCommands,
|
||||
CommandAdapterRegistry,
|
||||
} from '../command-generation/index.js';
|
||||
import type { GeneratedCommand } from '../command-generation/types.js';
|
||||
import { ALL_WORKFLOWS } from '../profiles.js';
|
||||
import { getCommandContents } from './skill-generation.js';
|
||||
import { normalizeCommandContent } from './tool-detection.js';
|
||||
|
||||
export interface CommandReconcileResult {
|
||||
/** Project-relative paths of generated command files that were deleted. */
|
||||
removed: string[];
|
||||
/** Project-relative paths left on disk because they are not our output. */
|
||||
kept: string[];
|
||||
/**
|
||||
* Project-relative paths left on disk because they could not be checked or
|
||||
* removed: unreadable, undeletable, or a symlink that cannot be proven to stay
|
||||
* inside the project. Reported apart from `kept`, since no edit was found.
|
||||
*/
|
||||
unchecked: string[];
|
||||
}
|
||||
|
||||
interface CommandFileEntry {
|
||||
cmdPath: string;
|
||||
fullPath: string;
|
||||
}
|
||||
|
||||
interface CommandFileClassification {
|
||||
/** Files whose content matches generated output, safe to delete. */
|
||||
generated: CommandFileEntry[];
|
||||
kept: string[];
|
||||
unchecked: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts a tool's command files on disk into generated output, files the user
|
||||
* owns, and files that could not be checked.
|
||||
*
|
||||
* Command files carry no version stamp, so content equality is the only signal
|
||||
* for "this is still our generated output". A file that does not match is the
|
||||
* user's, whether they edited it or an older OpenSpec version wrote it.
|
||||
*
|
||||
* Content depends on which workflows were installed when a file was written,
|
||||
* because cross-workflow references resolve against that set. Three candidate
|
||||
* sets are therefore compared: the workflows whose command files are on disk
|
||||
* (the closest reconstruction of the original inputs), the set this run is
|
||||
* using, and the full set. A file written under a wider or narrower profile
|
||||
* than the one now configured still matches.
|
||||
*/
|
||||
export function classifyCommandFiles(
|
||||
projectPath: string,
|
||||
toolId: string,
|
||||
workflows: readonly string[]
|
||||
): CommandFileClassification {
|
||||
const generated: CommandFileEntry[] = [];
|
||||
const kept: string[] = [];
|
||||
const unchecked: string[] = [];
|
||||
|
||||
const adapter = CommandAdapterRegistry.get(toolId);
|
||||
if (!adapter) return { generated, kept, unchecked };
|
||||
|
||||
const present: (CommandFileEntry & { workflow: string })[] = [];
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
let fullPath: string;
|
||||
try {
|
||||
fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
|
||||
} catch {
|
||||
// A symlink that escapes the project or dangles cannot be proven
|
||||
// confined. Never read or delete through it, and do not let one such
|
||||
// entry abort cleanup of the tool's other command files.
|
||||
unchecked.push(cmdPath);
|
||||
continue;
|
||||
}
|
||||
if (fs.existsSync(fullPath)) {
|
||||
present.push({ workflow, cmdPath, fullPath });
|
||||
}
|
||||
}
|
||||
|
||||
if (present.length === 0) return { generated, kept, unchecked };
|
||||
|
||||
const candidateSets = [present.map((entry) => entry.workflow), workflows, ALL_WORKFLOWS];
|
||||
const generatedByPath = new Map<string, string[]>();
|
||||
|
||||
for (const workflowSet of candidateSets) {
|
||||
let commands: GeneratedCommand[];
|
||||
try {
|
||||
commands = generateCommands(getCommandContents(workflowSet), adapter);
|
||||
} catch {
|
||||
// A workflow set this CLI cannot render leaves nothing to compare
|
||||
// against; files it would have covered are kept rather than guessed at.
|
||||
continue;
|
||||
}
|
||||
for (const cmd of commands) {
|
||||
const variants = generatedByPath.get(cmd.path) ?? [];
|
||||
variants.push(normalizeCommandContent(cmd.fileContent));
|
||||
generatedByPath.set(cmd.path, variants);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { cmdPath, fullPath } of present) {
|
||||
try {
|
||||
const onDisk = normalizeCommandContent(fs.readFileSync(fullPath, 'utf-8'));
|
||||
if ((generatedByPath.get(cmdPath) ?? []).includes(onDisk)) {
|
||||
generated.push({ cmdPath, fullPath });
|
||||
} else {
|
||||
kept.push(cmdPath);
|
||||
}
|
||||
} catch {
|
||||
// An unreadable file cannot be proven to be generated output.
|
||||
unchecked.push(cmdPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { generated, kept, unchecked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a tool's generated command files, keeping back any the user owns.
|
||||
* A file that does not match generated output is left on disk and reported,
|
||||
* so moving to skills-only delivery can never silently discard a hand-edited
|
||||
* command. See `classifyCommandFiles` for how a match is decided.
|
||||
*/
|
||||
export async function reconcileCommandFiles(
|
||||
projectPath: string,
|
||||
toolId: string,
|
||||
workflows: readonly string[]
|
||||
): Promise<CommandReconcileResult> {
|
||||
const { generated, kept, unchecked } = classifyCommandFiles(projectPath, toolId, workflows);
|
||||
const removed: string[] = [];
|
||||
|
||||
for (const { cmdPath, fullPath } of generated) {
|
||||
try {
|
||||
await fs.promises.unlink(fullPath);
|
||||
removed.push(cmdPath);
|
||||
} catch {
|
||||
// A matched file that cannot be deleted is still on disk. Report it
|
||||
// rather than passing over it in silence: the user needs to know the
|
||||
// directory was not fully cleared.
|
||||
unchecked.push(cmdPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, kept, unchecked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the report for command files `reconcileCommandFiles` left on disk.
|
||||
* Each block is a headline, its paths, then what to do; edits and files that
|
||||
* could not be checked are told apart, because the fix for each differs.
|
||||
* Returns an empty array when nothing was left behind.
|
||||
*/
|
||||
export function formatKeptCommandFiles(
|
||||
result: Pick<CommandReconcileResult, 'kept' | 'unchecked'>
|
||||
): string[][] {
|
||||
const blocks: string[][] = [];
|
||||
const files = (count: number) => `command ${count === 1 ? 'file' : 'files'}`;
|
||||
|
||||
if (result.kept.length > 0) {
|
||||
blocks.push([
|
||||
`Kept ${result.kept.length} ${files(result.kept.length)} that ${
|
||||
result.kept.length === 1 ? 'does' : 'do'
|
||||
} not match generated output (edited, or written by an older OpenSpec version):`,
|
||||
...result.kept.map((cmdPath) => ` ${cmdPath}`),
|
||||
'Move anything worth keeping into the skill, then delete by hand.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (result.unchecked.length > 0) {
|
||||
blocks.push([
|
||||
`Left ${result.unchecked.length} ${files(result.unchecked.length)} in place because ${
|
||||
result.unchecked.length === 1 ? 'it' : 'they'
|
||||
} could not be read or removed:`,
|
||||
...result.unchecked.map((cmdPath) => ` ${cmdPath}`),
|
||||
'Check the file permissions or symbolic links, then delete by hand.',
|
||||
]);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
getToolVersionStatus,
|
||||
getConfiguredTools,
|
||||
getAllToolVersionStatus,
|
||||
normalizeCommandContent,
|
||||
} from './tool-detection.js';
|
||||
|
||||
export {
|
||||
|
||||
@@ -152,7 +152,7 @@ export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string)
|
||||
* CRLF line endings, which a Windows clone with `core.autocrlf` reintroduces on
|
||||
* every checkout of committed command files.
|
||||
*/
|
||||
function normalizeCommandContent(content: string): string {
|
||||
export function normalizeCommandContent(content: string): string {
|
||||
return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
|
||||
+16
-31
@@ -30,6 +30,7 @@ import {
|
||||
type ToolVersionStatus,
|
||||
formatIdeRestart,
|
||||
} from './shared/index.js';
|
||||
import { reconcileCommandFiles, formatKeptCommandFiles } from './shared/command-reconcile.js';
|
||||
import {
|
||||
detectLegacyArtifacts,
|
||||
cleanupLegacyArtifacts,
|
||||
@@ -315,6 +316,7 @@ export class UpdateCommand {
|
||||
const skillsInvocableCommandSkips: string[] = [];
|
||||
const zeroArtifactTools: string[] = [];
|
||||
let removedCommandCount = 0;
|
||||
const leftCommandFiles = { kept: [] as string[], unchecked: [] as string[] };
|
||||
let removedSkillCount = 0;
|
||||
let removedDeselectedCommandCount = 0;
|
||||
let removedDeselectedSkillCount = 0;
|
||||
@@ -405,7 +407,14 @@ export class UpdateCommand {
|
||||
|
||||
// Delete command files if delivery is skills-only
|
||||
if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
|
||||
removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId);
|
||||
const reconciled = await reconcileCommandFiles(
|
||||
resolvedProjectPath,
|
||||
toolId,
|
||||
toolWorkflows
|
||||
);
|
||||
removedCommandCount += reconciled.removed.length;
|
||||
leftCommandFiles.kept.push(...reconciled.kept);
|
||||
leftCommandFiles.unchecked.push(...reconciled.unchecked);
|
||||
}
|
||||
|
||||
spinner.succeed(`Updated ${tool.name}`);
|
||||
@@ -448,6 +457,12 @@ export class UpdateCommand {
|
||||
if (removedCommandCount > 0) {
|
||||
console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`));
|
||||
}
|
||||
for (const [headline, ...detail] of formatKeptCommandFiles(leftCommandFiles)) {
|
||||
console.log(chalk.yellow(headline));
|
||||
for (const line of detail) {
|
||||
console.log(chalk.dim(line));
|
||||
}
|
||||
}
|
||||
if (removedSkillCount > 0) {
|
||||
console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));
|
||||
}
|
||||
@@ -887,36 +902,6 @@ export class UpdateCommand {
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes command files for workflows when delivery changed to skills-only.
|
||||
* Returns the number of files removed.
|
||||
*/
|
||||
private async removeCommandFiles(
|
||||
projectPath: string,
|
||||
toolId: string,
|
||||
): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
const adapter = CommandAdapterRegistry.get(toolId);
|
||||
if (!adapter) return 0;
|
||||
|
||||
for (const workflow of ALL_WORKFLOWS) {
|
||||
const cmdPath = adapter.getFilePath(workflow);
|
||||
const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
await fs.promises.unlink(fullPath);
|
||||
removed++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes command files for workflows that are no longer selected in the active profile.
|
||||
* Returns the number of files removed.
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
reconcileCommandFiles,
|
||||
formatKeptCommandFiles,
|
||||
} from '../../src/core/shared/command-reconcile.js';
|
||||
import { getCommandContents } from '../../src/core/shared/skill-generation.js';
|
||||
import {
|
||||
CommandAdapterRegistry,
|
||||
generateCommands,
|
||||
} from '../../src/core/command-generation/index.js';
|
||||
import { ALL_WORKFLOWS } from '../../src/core/profiles.js';
|
||||
|
||||
const WORKFLOWS = ['propose', 'apply', 'archive'] as const;
|
||||
|
||||
async function writeGeneratedCommands(
|
||||
projectPath: string,
|
||||
toolId: string,
|
||||
workflows: readonly string[]
|
||||
): Promise<string[]> {
|
||||
const adapter = CommandAdapterRegistry.get(toolId)!;
|
||||
const generated = generateCommands(getCommandContents(workflows), adapter);
|
||||
|
||||
for (const cmd of generated) {
|
||||
const full = path.join(projectPath, cmd.path);
|
||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, cmd.fileContent);
|
||||
}
|
||||
|
||||
return generated.map((cmd) => cmd.path);
|
||||
}
|
||||
|
||||
describe('reconcileCommandFiles', () => {
|
||||
let projectPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-reconcile-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(projectPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('removes command files it generated', async () => {
|
||||
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
|
||||
|
||||
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
|
||||
|
||||
expect(result.removed.sort()).toEqual([...written].sort());
|
||||
expect(result.kept).toEqual([]);
|
||||
for (const cmdPath of written) {
|
||||
await expect(fs.access(path.join(projectPath, cmdPath))).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a hand-edited command file and reports it', async () => {
|
||||
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
|
||||
const edited = written.find((p) => p.endsWith('propose.md'))!;
|
||||
const editedFull = path.join(projectPath, edited);
|
||||
const original = await fs.readFile(editedFull, 'utf-8');
|
||||
await fs.writeFile(editedFull, `${original}\n<!-- our team's extra step -->\n`);
|
||||
|
||||
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
|
||||
|
||||
expect(result.kept).toEqual([edited]);
|
||||
expect(result.removed).not.toContain(edited);
|
||||
// The edit survives byte-for-byte: kept means untouched, not rewritten.
|
||||
expect(await fs.readFile(editedFull, 'utf-8')).toBe(
|
||||
`${original}\n<!-- our team's extra step -->\n`
|
||||
);
|
||||
expect(formatKeptCommandFiles(result)).toEqual([
|
||||
[
|
||||
'Kept 1 command file that does not match generated output (edited, or written by an older OpenSpec version):',
|
||||
` ${edited}`,
|
||||
'Move anything worth keeping into the skill, then delete by hand.',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a BOM and CRLF checkout as untouched, not edited', async () => {
|
||||
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
|
||||
const checkedOut = path.join(projectPath, written[0]);
|
||||
const original = await fs.readFile(checkedOut, 'utf-8');
|
||||
await fs.writeFile(checkedOut, `${original.replace(/\n/g, '\r\n')}`);
|
||||
|
||||
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
|
||||
|
||||
expect(result.kept).toEqual([]);
|
||||
expect(result.removed).toContain(written[0]);
|
||||
});
|
||||
|
||||
it('removes a file left over from a wider profile than the one in use', async () => {
|
||||
// Written when every workflow was installed, reconciled against a narrower
|
||||
// set: still our generated output, so it must not be reported as an edit.
|
||||
const written = await writeGeneratedCommands(projectPath, 'claude', ALL_WORKFLOWS);
|
||||
|
||||
const result = await reconcileCommandFiles(projectPath, 'claude', ['propose']);
|
||||
|
||||
expect(result.kept).toEqual([]);
|
||||
expect(result.removed.sort()).toEqual([...written].sort());
|
||||
});
|
||||
|
||||
it('keeps and reports a file it cannot read rather than passing over it', async () => {
|
||||
// A file left on disk must always be named. Silently skipping an
|
||||
// unreadable one would tell the user the directory was cleared when it
|
||||
// was not.
|
||||
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
|
||||
const unreadable = written.find((p) => p.endsWith('apply.md'))!;
|
||||
// A directory at the command path exists but cannot be read as a file
|
||||
// (EISDIR), on every platform and without mocking the read.
|
||||
await fs.rm(path.join(projectPath, unreadable));
|
||||
await fs.mkdir(path.join(projectPath, unreadable));
|
||||
|
||||
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
|
||||
// Reported as unchecked, not as an edit: no mismatch was established.
|
||||
expect(result.unchecked).toEqual([unreadable]);
|
||||
expect(result.kept).toEqual([]);
|
||||
expect(result.removed).not.toContain(unreadable);
|
||||
expect(formatKeptCommandFiles(result)).toEqual([
|
||||
[
|
||||
'Left 1 command file in place because it could not be read or removed:',
|
||||
` ${unreadable}`,
|
||||
'Check the file permissions or symbolic links, then delete by hand.',
|
||||
],
|
||||
]);
|
||||
|
||||
// Still on disk, which is what being reported as kept has to mean.
|
||||
await expect(fs.access(path.join(projectPath, unreadable))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'never follows a command symlink that escapes the project, and still cleans the rest',
|
||||
async () => {
|
||||
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
|
||||
const linked = written.find((p) => p.endsWith('apply.md'))!;
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-reconcile-outside-'));
|
||||
const outsideFile = path.join(outsideDir, 'apply.md');
|
||||
// Same bytes as generated output, so only the confinement check can
|
||||
// stop it being deleted through the link.
|
||||
await fs.copyFile(path.join(projectPath, linked), outsideFile);
|
||||
await fs.rm(path.join(projectPath, linked));
|
||||
await fs.symlink(outsideFile, path.join(projectPath, linked));
|
||||
|
||||
try {
|
||||
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
|
||||
|
||||
expect(result.unchecked).toEqual([linked]);
|
||||
// One bad entry must not abort cleanup of the tool's other files.
|
||||
expect(result.removed.sort()).toEqual(written.filter((p) => p !== linked).sort());
|
||||
await expect(fs.access(outsideFile)).resolves.toBeUndefined();
|
||||
await expect(fs.lstat(path.join(projectPath, linked))).resolves.toBeTruthy();
|
||||
} finally {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'reports a dangling command symlink instead of failing the run',
|
||||
async () => {
|
||||
const written = await writeGeneratedCommands(projectPath, 'claude', WORKFLOWS);
|
||||
const dangling = written.find((p) => p.endsWith('apply.md'))!;
|
||||
await fs.rm(path.join(projectPath, dangling));
|
||||
await fs.symlink(path.join(projectPath, 'does-not-exist.md'), path.join(projectPath, dangling));
|
||||
|
||||
const result = await reconcileCommandFiles(projectPath, 'claude', WORKFLOWS);
|
||||
|
||||
expect(result.unchecked).toEqual([dangling]);
|
||||
expect(result.removed).toHaveLength(written.length - 1);
|
||||
}
|
||||
);
|
||||
|
||||
it('does nothing for a tool with no command adapter', async () => {
|
||||
const result = await reconcileCommandFiles(projectPath, 'codex', WORKFLOWS);
|
||||
|
||||
expect(result).toEqual({ removed: [], kept: [], unchecked: [] });
|
||||
});
|
||||
|
||||
it('reports nothing when nothing was left behind', () => {
|
||||
expect(formatKeptCommandFiles({ kept: [], unchecked: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1401,6 +1401,23 @@ describe('InitCommand', () => {
|
||||
expect(getConsoleOutput()).toContain('Restart your IDE to refresh skills.');
|
||||
});
|
||||
|
||||
it('keeps a command file the user edited when init reconciles to skills-only', async () => {
|
||||
// `init --force` over an existing project reconciles the same way `update`
|
||||
// does, so it must not discard a hand-edited command either.
|
||||
await new InitCommand({ tools: 'claude', force: true }).execute(testDir);
|
||||
const proposeCmd = path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md');
|
||||
const applyCmd = path.join(testDir, '.claude', 'commands', 'opsx', 'apply.md');
|
||||
const edited = `${await fs.readFile(proposeCmd, 'utf-8')}\n<!-- team step -->\n`;
|
||||
await fs.writeFile(proposeCmd, edited);
|
||||
|
||||
saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'skills' });
|
||||
await new InitCommand({ tools: 'claude', force: true }).execute(testDir);
|
||||
|
||||
expect(await fs.readFile(proposeCmd, 'utf-8')).toBe(edited);
|
||||
expect(await fileExists(applyCmd)).toBe(false);
|
||||
expect(getConsoleOutput()).toContain('Kept 1 command file that does not match');
|
||||
});
|
||||
|
||||
it('should create skills for multiple tools at once', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'claude,cursor', force: true });
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
WORKFLOW_TO_SKILL_DIR,
|
||||
} from '../../src/core/profile-sync-drift.js';
|
||||
import { CORE_WORKFLOWS } from '../../src/core/profiles.js';
|
||||
import { CommandAdapterRegistry } from '../../src/core/command-generation/index.js';
|
||||
import { CommandAdapterRegistry, generateCommands } from '../../src/core/command-generation/index.js';
|
||||
import { getCommandContents } from '../../src/core/shared/index.js';
|
||||
|
||||
function writeSkill(projectDir: string, workflowId: string): void {
|
||||
const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId as keyof typeof WORKFLOW_TO_SKILL_DIR];
|
||||
@@ -26,6 +27,16 @@ function writeCommand(projectDir: string, workflowId: string): void {
|
||||
fs.writeFileSync(fullPath, `# ${workflowId}\n`);
|
||||
}
|
||||
|
||||
function writeGeneratedCommand(projectDir: string, workflowId: string): string {
|
||||
const adapter = CommandAdapterRegistry.get('claude');
|
||||
if (!adapter) throw new Error('Claude adapter unavailable in test environment');
|
||||
const [generated] = generateCommands(getCommandContents([workflowId]), adapter);
|
||||
const fullPath = path.join(projectDir, generated.path);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, generated.fileContent);
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
function setupCoreSkills(projectDir: string): void {
|
||||
for (const workflow of CORE_WORKFLOWS) {
|
||||
writeSkill(projectDir, workflow);
|
||||
@@ -65,12 +76,24 @@ describe('profile sync drift detection', () => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('detects drift for skills-only delivery when commands still exist', () => {
|
||||
it('detects drift for skills-only delivery when generated commands still exist', () => {
|
||||
setupCoreSkills(tempDir);
|
||||
for (const workflow of CORE_WORKFLOWS) {
|
||||
writeGeneratedCommand(tempDir, workflow);
|
||||
}
|
||||
|
||||
const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'skills');
|
||||
expect(hasDrift).toBe(true);
|
||||
});
|
||||
|
||||
it('does not report drift for skills-only delivery when only kept command files remain', () => {
|
||||
// Cleanup leaves edited or older-version command files on disk. Counting
|
||||
// them as drift would re-sync on every update without ever clearing them.
|
||||
setupCoreSkills(tempDir);
|
||||
setupCoreCommands(tempDir);
|
||||
|
||||
const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'skills');
|
||||
expect(hasDrift).toBe(true);
|
||||
expect(hasDrift).toBe(false);
|
||||
});
|
||||
|
||||
it('detects drift for commands-only delivery when skills still exist', () => {
|
||||
|
||||
@@ -7,6 +7,11 @@ import { FileSystemUtils } from '../../src/utils/file-system.js';
|
||||
import { OPENSPEC_MARKERS } from '../../src/core/config.js';
|
||||
import type { GlobalConfig } from '../../src/core/global-config.js';
|
||||
import { generateCopilotSetupSteps, persistCopilotCloudOptIn } from '../../src/core/github-copilot/cloud-agent.js';
|
||||
import { getCommandContents } from '../../src/core/shared/index.js';
|
||||
import {
|
||||
CommandAdapterRegistry,
|
||||
generateCommands,
|
||||
} from '../../src/core/command-generation/index.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
@@ -58,6 +63,24 @@ function resetMockConfig() {
|
||||
mockState.config = { featureFlags: {}, profile: 'core', delivery: 'both' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the command file OpenSpec itself would generate for `workflow`.
|
||||
* Fixtures that stand in a placeholder no longer exercise cleanup, because
|
||||
* cleanup now only deletes files whose content matches generated output.
|
||||
*/
|
||||
async function writeGeneratedCommand(
|
||||
projectDir: string,
|
||||
toolId: string,
|
||||
workflow: string
|
||||
): Promise<string> {
|
||||
const adapter = CommandAdapterRegistry.get(toolId)!;
|
||||
const [generated] = generateCommands(getCommandContents([workflow]), adapter);
|
||||
const full = path.join(projectDir, generated.path);
|
||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, generated.fileContent);
|
||||
return full;
|
||||
}
|
||||
|
||||
async function markCodexTarget(skillsDir: string): Promise<void> {
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'codex\n');
|
||||
@@ -1477,6 +1500,48 @@ metadata:
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrating to skills-only delivery', () => {
|
||||
it('deletes generated command files but keeps one the user edited', async () => {
|
||||
// The migration path for #2022: switching delivery to skills must clear
|
||||
// the generated commands without discarding a user's own work.
|
||||
await new InitCommand({ tools: 'claude', force: true }).execute(testDir);
|
||||
const proposeCmd = path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md');
|
||||
const applyCmd = path.join(testDir, '.claude', 'commands', 'opsx', 'apply.md');
|
||||
const edited = `${await fs.readFile(proposeCmd, 'utf-8')}\n<!-- team step -->\n`;
|
||||
await fs.writeFile(proposeCmd, edited);
|
||||
|
||||
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'skills' });
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
await new UpdateCommand({ force: true }).execute(testDir);
|
||||
|
||||
// The skill is in place, so the workflow still works...
|
||||
expect(
|
||||
await FileSystemUtils.fileExists(
|
||||
path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md')
|
||||
)
|
||||
).toBe(true);
|
||||
// ...the untouched command is gone...
|
||||
expect(await FileSystemUtils.fileExists(applyCmd)).toBe(false);
|
||||
// ...and the edited one survives byte-for-byte, named in the output.
|
||||
expect(await fs.readFile(proposeCmd, 'utf-8')).toBe(edited);
|
||||
const logs = consoleSpy.mock.calls.flat().map(String);
|
||||
expect(
|
||||
logs.some((entry) => entry.includes('Kept 1 command file that does not match'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
logs.some((entry) => entry.includes(path.join('.claude', 'commands', 'opsx', 'propose.md')))
|
||||
).toBe(true);
|
||||
|
||||
// A kept file is not drift: the next plain update finds nothing to sync.
|
||||
consoleSpy.mockClear();
|
||||
await new UpdateCommand().execute(testDir);
|
||||
const nextLogs = consoleSpy.mock.calls.flat().map(String);
|
||||
expect(nextLogs.some((entry) => entry.includes('config sync'))).toBe(false);
|
||||
expect(nextLogs.some((entry) => entry.includes('Kept 1 command file'))).toBe(false);
|
||||
expect(await fs.readFile(proposeCmd, 'utf-8')).toBe(edited);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-tool support', () => {
|
||||
it('should update multiple configured tools', async () => {
|
||||
// Set up Claude
|
||||
@@ -4291,8 +4356,7 @@ content
|
||||
);
|
||||
|
||||
const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');
|
||||
await fs.mkdir(commandsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(commandsDir, 'explore.md'), 'old command');
|
||||
await writeGeneratedCommand(testDir, 'claude', 'explore');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user