@fission-ai/openspec 1.14.3 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +12 -10
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/completion.js +4 -2
  4. package/dist/commands/feedback.js +33 -20
  5. package/dist/commands/install-standalone.d.ts +27 -0
  6. package/dist/commands/install-standalone.js +150 -0
  7. package/dist/commands/schema.js +10 -3
  8. package/dist/commands/validate.js +33 -3
  9. package/dist/commands/workflow/instructions.js +15 -4
  10. package/dist/commands/workflow/new-change.js +12 -4
  11. package/dist/commands/workflow/status.js +5 -2
  12. package/dist/commands/workflow/templates.js +5 -2
  13. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  14. package/dist/core/artifact-graph/resolver.js +11 -0
  15. package/dist/core/available-tools.d.ts +2 -1
  16. package/dist/core/available-tools.js +8 -28
  17. package/dist/core/completion-tip.d.ts +23 -0
  18. package/dist/core/completion-tip.js +4 -4
  19. package/dist/core/completions/command-registry.js +15 -0
  20. package/dist/core/completions/installers/zsh-installer.d.ts +9 -1
  21. package/dist/core/completions/installers/zsh-installer.js +64 -19
  22. package/dist/core/config-schema.d.ts +1 -0
  23. package/dist/core/config-schema.js +1 -0
  24. package/dist/core/file-view.d.ts +13 -0
  25. package/dist/core/file-view.js +14 -0
  26. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  27. package/dist/core/github-copilot/cloud-agent.js +13 -9
  28. package/dist/core/global-config.d.ts +4 -0
  29. package/dist/core/global-config.js +5 -1
  30. package/dist/core/id.d.ts +8 -0
  31. package/dist/core/id.js +15 -0
  32. package/dist/core/legacy-cleanup.d.ts +11 -1
  33. package/dist/core/legacy-cleanup.js +15 -18
  34. package/dist/core/migration.d.ts +27 -1
  35. package/dist/core/migration.js +77 -29
  36. package/dist/core/parsers/code-fence.js +9 -1
  37. package/dist/core/parsers/requirement-blocks.d.ts +30 -0
  38. package/dist/core/parsers/requirement-blocks.js +22 -9
  39. package/dist/core/parsers/requirement-text.d.ts +14 -0
  40. package/dist/core/parsers/requirement-text.js +39 -0
  41. package/dist/core/parsers/spec-structure.js +1 -1
  42. package/dist/core/profile-sync-drift.d.ts +4 -3
  43. package/dist/core/profile-sync-drift.js +19 -16
  44. package/dist/core/project-config.d.ts +10 -0
  45. package/dist/core/project-config.js +26 -0
  46. package/dist/core/root-selection.d.ts +6 -0
  47. package/dist/core/root-selection.js +37 -0
  48. package/dist/core/shared/tool-detection.d.ts +12 -4
  49. package/dist/core/shared/tool-detection.js +29 -27
  50. package/dist/core/shared-skill-target.d.ts +12 -5
  51. package/dist/core/shared-skill-target.js +34 -23
  52. package/dist/core/standalone-tip.d.ts +6 -0
  53. package/dist/core/standalone-tip.js +38 -0
  54. package/dist/core/store/git.d.ts +22 -1
  55. package/dist/core/store/git.js +59 -4
  56. package/dist/core/templates/workflows/verify-change.js +4 -4
  57. package/dist/core/update-plan.d.ts +127 -0
  58. package/dist/core/update-plan.js +390 -0
  59. package/dist/core/update.d.ts +45 -1
  60. package/dist/core/update.js +279 -137
  61. package/dist/core/validation/validator.d.ts +27 -2
  62. package/dist/core/validation/validator.js +58 -4
  63. package/dist/core/version-check.d.ts +8 -1
  64. package/dist/core/version-check.js +48 -5
  65. package/dist/utils/executable-path.d.ts +5 -0
  66. package/dist/utils/executable-path.js +44 -0
  67. package/package.json +5 -4
  68. package/schemas/spec-driven/schema.yaml +1 -0
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Update Plan
3
+ *
4
+ * The write seam `update` uses for every file it generates or removes.
5
+ *
6
+ * A real run forwards to the filesystem. A dry run records what the same call
7
+ * would have done and writes nothing, so the preview and the real update come
8
+ * from one code path instead of two descriptions of it.
9
+ */
10
+ import path from 'path';
11
+ import * as fs from 'fs';
12
+ import os from 'os';
13
+ import { FileSystemUtils } from '../utils/file-system.js';
14
+ export const realFileOps = {
15
+ async writeFile(filePath, content) {
16
+ await FileSystemUtils.writeFile(filePath, content);
17
+ },
18
+ async removeFile(filePath) {
19
+ await fs.promises.unlink(filePath);
20
+ },
21
+ async removeDirectory(dirPath) {
22
+ await fs.promises.rm(dirPath, { recursive: true, force: true });
23
+ },
24
+ async removeEmptyDirectory(dirPath) {
25
+ await fs.promises.rmdir(dirPath);
26
+ },
27
+ };
28
+ export class UpdatePlanRecorder {
29
+ projectPath;
30
+ notes = [];
31
+ failures = [];
32
+ /**
33
+ * The tree as the real update would have it so far: absolute path to planned
34
+ * content, a moved directory, or null once planned away (a removed directory
35
+ * takes everything under it). Steps that read what an earlier step left
36
+ * behind read this through `view`.
37
+ */
38
+ staged = new Map();
39
+ /** Every file path the plan has touched, with the tool that touched it last. */
40
+ touched = new Map();
41
+ constructor(projectPath) {
42
+ this.projectPath = projectPath;
43
+ }
44
+ async writeFile(filePath, content, tool) {
45
+ const target = path.resolve(filePath);
46
+ this.probeWrite(target);
47
+ this.stage(target, content, tool);
48
+ }
49
+ async removeFile(filePath, tool) {
50
+ const target = path.resolve(filePath);
51
+ this.probeRemove(target);
52
+ this.stage(target, null, tool);
53
+ }
54
+ async removeEmptyDirectory(dirPath) {
55
+ // An empty directory holds no file, so there is nothing to list.
56
+ this.staged.set(path.resolve(dirPath), null);
57
+ }
58
+ async removeDirectory(dirPath, tool) {
59
+ const target = path.resolve(dirPath);
60
+ // A symlinked skills directory - what a dotfile manager leaves behind - is
61
+ // removed as the link itself: `fs.rm` with `recursive` unlinks it and never
62
+ // touches the tree it points at, so the link is the one entry that goes.
63
+ let stats = null;
64
+ try {
65
+ stats = fs.lstatSync(target);
66
+ }
67
+ catch {
68
+ stats = null;
69
+ }
70
+ if (stats?.isSymbolicLink()) {
71
+ this.stage(target, null, tool);
72
+ return;
73
+ }
74
+ // Otherwise every file under it goes - on disk, and any the plan put there.
75
+ // Each is cleared on its own: a file's own entry outranks its directory's.
76
+ for (const filePath of this.filesUnder(target))
77
+ this.stage(filePath, null, tool);
78
+ this.staged.set(target, null);
79
+ }
80
+ /**
81
+ * A rename, as the legacy-root migration makes it: whatever the entry is
82
+ * moves, a directory with everything in it.
83
+ */
84
+ async moveFile(source, destination, tool) {
85
+ const from = path.resolve(source);
86
+ const to = path.resolve(destination);
87
+ this.probeCreatableIn(path.dirname(to));
88
+ if (this.entryKind(from).kind === 'directory') {
89
+ if (this.entryKind(to).kind !== 'missing') {
90
+ throw errnoError('ENOTEMPTY', 'directory not empty', 'rename', to);
91
+ }
92
+ const files = this.filesUnder(from);
93
+ const contents = files.map((filePath) => [filePath, this.view.read(filePath)]);
94
+ for (const filePath of files)
95
+ this.stage(filePath, null, tool);
96
+ this.staged.set(from, null);
97
+ this.staged.set(to, STAGED_DIRECTORY);
98
+ for (const [filePath, content] of contents) {
99
+ this.stage(path.join(to, path.relative(from, filePath)), content, tool);
100
+ }
101
+ return;
102
+ }
103
+ const content = this.view.read(from);
104
+ this.probeWrite(to);
105
+ this.probeRemove(from);
106
+ this.stage(from, null, tool);
107
+ this.stage(to, content, tool);
108
+ }
109
+ /**
110
+ * The tree with every planned write, move and removal applied. A path the
111
+ * plan has not touched answers exactly as the disk does.
112
+ */
113
+ view = {
114
+ exists: (filePath) => this.entryKind(path.resolve(filePath)).kind !== 'missing',
115
+ isDirectory: (filePath) => this.entryKind(path.resolve(filePath)).kind === 'directory',
116
+ read: (filePath) => {
117
+ const target = path.resolve(filePath);
118
+ const staged = this.stagedEntry(target);
119
+ if (staged === undefined)
120
+ return fs.readFileSync(target, 'utf-8');
121
+ if (staged === null)
122
+ throw errnoError('ENOENT', 'no such file or directory', 'open', target);
123
+ if (staged === STAGED_DIRECTORY) {
124
+ throw errnoError('EISDIR', 'illegal operation on a directory', 'read', target);
125
+ }
126
+ return staged;
127
+ },
128
+ };
129
+ addNote(code, message) {
130
+ if (this.notes.some((note) => note.code === code))
131
+ return;
132
+ this.notes.push({ code, message });
133
+ }
134
+ addFailure(tool, error) {
135
+ this.failures.push({ tool, error });
136
+ }
137
+ /**
138
+ * Each touched path, compared as it is on disk now with how the plan leaves
139
+ * it. A file written and then removed, or rewritten with the bytes it
140
+ * already had, is no change at all.
141
+ */
142
+ getPlan() {
143
+ const byPath = new Map();
144
+ for (const [filePath, tool] of this.touched) {
145
+ const before = originalEntry(filePath);
146
+ const after = this.stagedEntry(filePath);
147
+ if (after === undefined || after === STAGED_DIRECTORY)
148
+ continue;
149
+ let action;
150
+ if (after === null) {
151
+ if (before !== undefined)
152
+ action = 'delete';
153
+ }
154
+ else if (before === undefined) {
155
+ action = 'add';
156
+ }
157
+ else if (before !== after) {
158
+ action = 'update';
159
+ }
160
+ if (!action)
161
+ continue;
162
+ const displayPath = this.toDisplayPath(filePath);
163
+ byPath.set(displayPath, { path: displayPath, action, tool });
164
+ }
165
+ const changes = [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
166
+ return { changes, notes: [...this.notes], failures: [...this.failures] };
167
+ }
168
+ stage(target, entry, tool) {
169
+ this.staged.set(target, entry);
170
+ this.touched.set(target, tool);
171
+ }
172
+ /**
173
+ * What the plan has put at a path: content, a moved directory, null once
174
+ * planned away, or undefined when the plan has not touched it.
175
+ */
176
+ stagedEntry(target) {
177
+ for (let current = target;; current = path.dirname(current)) {
178
+ const staged = this.staged.get(current);
179
+ if (staged === null)
180
+ return null;
181
+ if (staged !== undefined && current === target)
182
+ return staged;
183
+ if (path.dirname(current) === current)
184
+ return undefined;
185
+ }
186
+ }
187
+ /** Whether the plan has put any file or directory below `directory`. */
188
+ hasStagedDescendant(directory) {
189
+ const prefix = directory.endsWith(path.sep) ? directory : directory + path.sep;
190
+ for (const key of this.staged.keys()) {
191
+ if (!key.startsWith(prefix))
192
+ continue;
193
+ const entry = this.stagedEntry(key);
194
+ if (entry !== null && entry !== undefined)
195
+ return true;
196
+ }
197
+ return false;
198
+ }
199
+ /** Files under a directory in the tree as planned: those on disk, and those the plan added. */
200
+ filesUnder(directory) {
201
+ const files = new Set();
202
+ for (const filePath of listFilesRecursively(directory)) {
203
+ if (this.stagedEntry(filePath) !== null)
204
+ files.add(filePath);
205
+ }
206
+ const prefix = directory + path.sep;
207
+ for (const key of this.staged.keys()) {
208
+ if (key.startsWith(prefix) && typeof this.stagedEntry(key) === 'string')
209
+ files.add(key);
210
+ }
211
+ return [...files];
212
+ }
213
+ /**
214
+ * What is at a path in the tree as planned, and whether that is what is on
215
+ * disk (so its permissions are real).
216
+ */
217
+ entryKind(target) {
218
+ const staged = this.stagedEntry(target);
219
+ if (staged === null) {
220
+ // Removed, but the plan may have written into it again since.
221
+ return this.hasStagedDescendant(target)
222
+ ? { kind: 'directory', onDisk: false }
223
+ : { kind: 'missing', onDisk: false };
224
+ }
225
+ if (staged === STAGED_DIRECTORY)
226
+ return { kind: 'directory', onDisk: false };
227
+ if (typeof staged === 'string') {
228
+ return { kind: 'file', onDisk: fs.existsSync(target) };
229
+ }
230
+ try {
231
+ return { kind: fs.statSync(target).isDirectory() ? 'directory' : 'file', onDisk: true };
232
+ }
233
+ catch (error) {
234
+ const code = error.code;
235
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
236
+ throw error;
237
+ }
238
+ return this.hasStagedDescendant(target)
239
+ ? { kind: 'directory', onDisk: false }
240
+ : { kind: 'missing', onDisk: false };
241
+ }
242
+ /**
243
+ * Fails the way the real write would, without writing: a directory where the
244
+ * file belongs, a file where one of its directories belongs, or no
245
+ * permission. Recording the write instead would plan past the point where
246
+ * the real update stops for this tool, listing files it never writes.
247
+ */
248
+ probeWrite(target) {
249
+ const entry = this.entryKind(target);
250
+ if (entry.kind === 'directory') {
251
+ throw errnoError('EISDIR', 'illegal operation on a directory', 'open', target);
252
+ }
253
+ if (entry.kind === 'file') {
254
+ if (entry.onDisk)
255
+ fs.accessSync(target, fs.constants.W_OK);
256
+ return;
257
+ }
258
+ this.probeCreatableIn(path.dirname(target));
259
+ }
260
+ /** Fails the way unlinking the file would. */
261
+ probeRemove(target) {
262
+ const entry = this.entryKind(target);
263
+ if (entry.kind === 'directory') {
264
+ throw errnoError('EISDIR', 'illegal operation on a directory', 'unlink', target);
265
+ }
266
+ if (entry.kind === 'file' && entry.onDisk) {
267
+ fs.accessSync(path.dirname(target), fs.constants.W_OK);
268
+ }
269
+ }
270
+ /**
271
+ * The nearest existing ancestor has to be a directory the update can create
272
+ * entries in; anything it would create below that starts out empty.
273
+ */
274
+ probeCreatableIn(directory) {
275
+ for (let current = directory;; current = path.dirname(current)) {
276
+ const entry = this.entryKind(current);
277
+ if (entry.kind === 'file') {
278
+ throw errnoError('ENOTDIR', 'not a directory', 'mkdir', current);
279
+ }
280
+ if (entry.kind === 'directory') {
281
+ if (entry.onDisk)
282
+ fs.accessSync(current, fs.constants.W_OK);
283
+ return;
284
+ }
285
+ if (path.dirname(current) === current)
286
+ return;
287
+ }
288
+ }
289
+ toDisplayPath(filePath) {
290
+ const absolute = path.resolve(filePath);
291
+ const relative = path.relative(this.projectPath, absolute);
292
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
293
+ return FileSystemUtils.toPosixPath(relative);
294
+ }
295
+ // Same home resolution the skill paths use, so a global skill is named
296
+ // against the home the generator wrote it to.
297
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? os.homedir();
298
+ const fromHome = path.relative(home, absolute);
299
+ if (fromHome && !fromHome.startsWith('..') && !path.isAbsolute(fromHome)) {
300
+ return `~/${FileSystemUtils.toPosixPath(fromHome)}`;
301
+ }
302
+ return FileSystemUtils.toPosixPath(absolute);
303
+ }
304
+ }
305
+ /** Marks a path the plan has moved a whole directory to. */
306
+ const STAGED_DIRECTORY = Symbol('staged directory');
307
+ /**
308
+ * A path as it is on disk now: a file's content (read through a symlink that
309
+ * points at a file), a symlink to anything else as an entry of its own, or
310
+ * undefined when absent or a directory.
311
+ *
312
+ * The file is judged on the handle it is read from, so nothing can swap it
313
+ * between the check and the read. O_NONBLOCK keeps a FIFO from hanging the
314
+ * plan; Windows has no such flag and no FIFOs to worry about.
315
+ */
316
+ function originalEntry(target) {
317
+ let descriptor;
318
+ try {
319
+ descriptor = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
320
+ if (fs.fstatSync(descriptor).isFile()) {
321
+ return fs.readFileSync(descriptor, 'utf-8');
322
+ }
323
+ }
324
+ catch {
325
+ // Missing, a dangling link, or a directory on a platform that refuses to
326
+ // open one: decided below.
327
+ }
328
+ finally {
329
+ if (descriptor !== undefined)
330
+ fs.closeSync(descriptor);
331
+ }
332
+ try {
333
+ return `symlink:${fs.readlinkSync(target)}`;
334
+ }
335
+ catch {
336
+ return undefined;
337
+ }
338
+ }
339
+ /** An error shaped like the one Node's own fs call would throw. */
340
+ function errnoError(code, description, syscall, target) {
341
+ return Object.assign(new Error(`${code}: ${description}, ${syscall} '${target}'`), {
342
+ code,
343
+ syscall,
344
+ path: target,
345
+ });
346
+ }
347
+ function listFilesRecursively(dirPath) {
348
+ let entries;
349
+ try {
350
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
351
+ }
352
+ catch {
353
+ return [];
354
+ }
355
+ const files = [];
356
+ for (const entry of entries) {
357
+ const full = path.join(dirPath, entry.name);
358
+ if (entry.isDirectory()) {
359
+ files.push(...listFilesRecursively(full));
360
+ }
361
+ else {
362
+ files.push(full);
363
+ }
364
+ }
365
+ return files;
366
+ }
367
+ /** Human-readable rendering of a dry-run plan. */
368
+ export function formatUpdatePlan(plan) {
369
+ const lines = [];
370
+ if (plan.changes.length === 0) {
371
+ lines.push('No changes: skills and commands are up to date.');
372
+ }
373
+ else {
374
+ lines.push(`${plan.changes.length} file(s) would change:`);
375
+ const width = Math.max(...plan.changes.map((change) => change.action.length));
376
+ for (const change of plan.changes) {
377
+ lines.push(` ${change.action.padEnd(width)} ${change.path} (${change.tool})`);
378
+ }
379
+ }
380
+ for (const note of plan.notes) {
381
+ lines.push('');
382
+ lines.push(`Note: ${note.message}`);
383
+ }
384
+ for (const failure of plan.failures) {
385
+ lines.push('');
386
+ lines.push(`Would fail: ${failure.tool} (${failure.error})`);
387
+ }
388
+ return lines.join('\n');
389
+ }
390
+ //# sourceMappingURL=update-plan.js.map
@@ -4,12 +4,19 @@
4
4
  * Refreshes OpenSpec skills and commands for configured tools.
5
5
  * Supports profile-aware updates, delivery changes, migration, and smart update detection.
6
6
  */
7
+ import { type FileView } from './file-view.js';
8
+ import { type UpdatePlan } from './update-plan.js';
7
9
  /**
8
10
  * Options for the update command.
9
11
  */
10
12
  export interface UpdateCommandOptions {
11
13
  /** Force update even when tools are up to date */
12
14
  force?: boolean;
15
+ /**
16
+ * Record what the update would change and write nothing. Set by `plan()`;
17
+ * callers use that instead of passing this directly.
18
+ */
19
+ dryRun?: boolean;
13
20
  }
14
21
  /**
15
22
  * Scans installed workflow artifacts (skills and managed commands) across all configured tools.
@@ -17,10 +24,24 @@ export interface UpdateCommandOptions {
17
24
  *
18
25
  * Wrapper around the shared migration module's scanInstalledWorkflows that accepts tool IDs.
19
26
  */
20
- export declare function scanInstalledWorkflows(projectPath: string, toolIds: string[]): string[];
27
+ export declare function scanInstalledWorkflows(projectPath: string, toolIds: string[], view?: FileView): string[];
21
28
  export declare class UpdateCommand {
22
29
  private readonly force;
30
+ private readonly dryRun;
31
+ private recorder;
32
+ private ops;
23
33
  constructor(options?: UpdateCommandOptions);
34
+ /**
35
+ * Reports what `execute` would add, change or delete, writing nothing.
36
+ *
37
+ * Same code path as the real update: the plan is recorded by the write seam
38
+ * the generation loop already goes through, so it cannot drift from it.
39
+ */
40
+ plan(projectPath: string): Promise<UpdatePlan>;
41
+ /** Prints progress on a real run, stays quiet while planning. */
42
+ private log;
43
+ /** A spinner on a real run, a no-op while planning. */
44
+ private startSpinner;
24
45
  /**
25
46
  * Refreshes OpenSpec skills and commands for all configured tools,
26
47
  * regenerating artifacts according to the effective profile and delivery mode.
@@ -28,6 +49,29 @@ export declare class UpdateCommand {
28
49
  * @param projectPath - Path to the project root containing the openspec directory
29
50
  */
30
51
  execute(projectPath: string): Promise<void>;
52
+ /**
53
+ * Records the shared-skills-root owner through the write seam, so a dry run
54
+ * lists the marker file a real update would write.
55
+ */
56
+ private writeSharedSkillTarget;
57
+ /**
58
+ * Where a real update throws for failed tools, it also stops. A dry run
59
+ * stops at the same point - so it plans nothing the real update would not
60
+ * reach - but returns instead of throwing: the failures are already in the
61
+ * plan, and the caller still gets every change planned before them.
62
+ *
63
+ * @returns whether the update stops here.
64
+ */
65
+ private stopIfUpdateFailed;
66
+ /** The tree as the real update would see it at this point. */
67
+ private get view();
68
+ /** The update's write seam, in the shape modules with their own writers take. */
69
+ private fileOps;
70
+ /**
71
+ * Plans the moves out of former tool roots that a non-interactive update
72
+ * makes, against the tree as planned so far.
73
+ */
74
+ private planLegacyToolMoves;
31
75
  private syncCopilotCloudFiles;
32
76
  /**
33
77
  * Tools whose SKILL.md bodies no longer match what this CLI generates.