@fission-ai/openspec 1.14.2 → 1.15.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.
- package/README.md +13 -9
- package/dist/cli/index.js +47 -0
- package/dist/commands/change.js +2 -1
- package/dist/commands/feedback.js +33 -20
- package/dist/commands/install-standalone.d.ts +27 -0
- package/dist/commands/install-standalone.js +150 -0
- package/dist/commands/schema.js +10 -3
- package/dist/commands/spec.js +2 -10
- package/dist/commands/validate.js +11 -4
- package/dist/commands/workflow/instructions.js +15 -4
- package/dist/commands/workflow/new-change.js +12 -4
- package/dist/commands/workflow/status.js +5 -2
- package/dist/commands/workflow/templates.js +5 -2
- package/dist/core/artifact-graph/outputs.js +204 -29
- package/dist/core/artifact-graph/resolver.d.ts +6 -0
- package/dist/core/artifact-graph/resolver.js +11 -0
- package/dist/core/available-tools.d.ts +2 -1
- package/dist/core/available-tools.js +8 -28
- package/dist/core/completion-tip.d.ts +23 -0
- package/dist/core/completion-tip.js +4 -4
- package/dist/core/completions/command-registry.js +15 -0
- package/dist/core/completions/completion-provider.js +1 -1
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +1 -0
- package/dist/core/file-view.d.ts +13 -0
- package/dist/core/file-view.js +14 -0
- package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
- package/dist/core/github-copilot/cloud-agent.js +13 -9
- package/dist/core/global-config.d.ts +4 -0
- package/dist/core/global-config.js +5 -1
- package/dist/core/legacy-cleanup.d.ts +11 -1
- package/dist/core/legacy-cleanup.js +15 -18
- package/dist/core/migration.d.ts +27 -1
- package/dist/core/migration.js +77 -29
- package/dist/core/profile-sync-drift.d.ts +4 -3
- package/dist/core/profile-sync-drift.js +19 -16
- package/dist/core/shared/tool-detection.d.ts +12 -4
- package/dist/core/shared/tool-detection.js +29 -27
- package/dist/core/shared-skill-target.d.ts +12 -5
- package/dist/core/shared-skill-target.js +34 -23
- package/dist/core/specs-apply.js +23 -5
- package/dist/core/standalone-tip.d.ts +6 -0
- package/dist/core/standalone-tip.js +38 -0
- package/dist/core/templates/workflows/continue-change.js +18 -12
- package/dist/core/templates/workflows/explore.js +2 -2
- package/dist/core/templates/workflows/ff-change.js +8 -6
- package/dist/core/templates/workflows/propose.js +8 -6
- package/dist/core/templates/workflows/verify-change.js +4 -4
- package/dist/core/update-plan.d.ts +127 -0
- package/dist/core/update-plan.js +390 -0
- package/dist/core/update.d.ts +45 -1
- package/dist/core/update.js +279 -137
- package/dist/core/validation/validator.js +3 -3
- package/dist/core/version-check.d.ts +8 -1
- package/dist/core/version-check.js +48 -5
- package/dist/utils/executable-path.d.ts +5 -0
- package/dist/utils/executable-path.js +44 -0
- package/dist/utils/file-system.d.ts +1 -1
- package/dist/utils/file-system.js +1 -1
- package/dist/utils/interactive.d.ts +15 -0
- package/dist/utils/interactive.js +18 -0
- package/dist/utils/spec-discovery.d.ts +4 -4
- package/dist/utils/spec-discovery.js +7 -17
- package/dist/utils/spec-path-containment.d.ts +17 -0
- package/dist/utils/spec-path-containment.js +122 -0
- package/package.json +8 -4
|
@@ -9,6 +9,7 @@ import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractR
|
|
|
9
9
|
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
|
|
10
10
|
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
11
11
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
12
|
+
import { assertDiscoveredSpecFile } from '../../utils/spec-path-containment.js';
|
|
12
13
|
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
|
|
13
14
|
import { METADATA_FILENAME, formatUnknownChangeMetadataKeysMessage, readSkipSpecsMarker, readUnknownChangeMetadataKeys, resolveSchemaForChange, } from '../../utils/change-metadata.js';
|
|
14
15
|
import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
|
|
@@ -314,8 +315,7 @@ export class Validator {
|
|
|
314
315
|
// authoring time instead of days later at archive time (#1477).
|
|
315
316
|
if (options.mainSpecsDir && plan.modified.length > 0) {
|
|
316
317
|
const mainSpecFile = path.join(options.mainSpecsDir, ...specId.split('/'), 'spec.md');
|
|
317
|
-
|
|
318
|
-
issues.push(...(await this.findScenarioLossIssues(plan.modified, plan.renamed, mainSpecFile, entryPath, path.dirname(mainSpecFile))));
|
|
318
|
+
issues.push(...(await this.findScenarioLossIssues(plan.modified, plan.renamed, mainSpecFile, entryPath, options.mainSpecsDir)));
|
|
319
319
|
}
|
|
320
320
|
// Validate REMOVED (names only)
|
|
321
321
|
for (const name of plan.removed) {
|
|
@@ -606,7 +606,7 @@ export class Validator {
|
|
|
606
606
|
*/
|
|
607
607
|
async findScenarioLossIssues(modified, renamed, mainSpecFile, entryPath, mainSpecRoot) {
|
|
608
608
|
let mainContent;
|
|
609
|
-
|
|
609
|
+
assertDiscoveredSpecFile(mainSpecRoot, mainSpecFile);
|
|
610
610
|
try {
|
|
611
611
|
mainContent = await fs.readFile(mainSpecFile, 'utf-8');
|
|
612
612
|
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Called by the standalone binary's entry point. That copy has no package
|
|
3
|
+
* manager behind it, so a package-manager command is the wrong upgrade advice.
|
|
4
|
+
*/
|
|
5
|
+
export declare function markStandaloneBinary(executablePath: string): void;
|
|
6
|
+
/** The standalone binary's own path, or null when running from a package. */
|
|
7
|
+
export declare function getStandaloneBinaryPath(): string | null;
|
|
1
8
|
/**
|
|
2
9
|
* The registry to ask. A configured registry is honored only over TLS: a
|
|
3
10
|
* cleartext hop would aim the request at a link-local or internal address and
|
|
@@ -79,7 +86,7 @@ export declare function isNpmGlobalInstall(installDir: string | null, roots?: st
|
|
|
79
86
|
*/
|
|
80
87
|
export declare function isSourceCheckout(installDir: string | null): boolean;
|
|
81
88
|
export type PackageManager = 'npm' | 'pnpm' | 'bun' | 'yarn' | 'volta';
|
|
82
|
-
export type InstallScope = 'global' | 'project' | 'temporary' | 'source';
|
|
89
|
+
export type InstallScope = 'global' | 'project' | 'temporary' | 'source' | 'standalone';
|
|
83
90
|
export interface CliInstallInfo {
|
|
84
91
|
location: string | null;
|
|
85
92
|
packageManager: PackageManager | null;
|
|
@@ -3,11 +3,13 @@ import http from 'http';
|
|
|
3
3
|
import https from 'https';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { createRequire } from 'module';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
6
7
|
import chalk from 'chalk';
|
|
7
8
|
import { isCiEnvironment } from '../utils/ci.js';
|
|
8
9
|
import { isTelemetryOptedOutByEnv } from '../telemetry/opt-out.js';
|
|
9
10
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
10
11
|
import { getGlobalConfig, isGlobalConfigUnreadable } from './global-config.js';
|
|
12
|
+
import { findCopiesOnPath, samePath } from '../utils/executable-path.js';
|
|
11
13
|
const require = createRequire(import.meta.url);
|
|
12
14
|
const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
|
|
13
15
|
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
@@ -22,6 +24,34 @@ const MAX_REDIRECTS = 3;
|
|
|
22
24
|
* one could smuggle ANSI cursor controls and repaint the lines around it.
|
|
23
25
|
*/
|
|
24
26
|
const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
|
|
27
|
+
let standaloneBinaryPath = null;
|
|
28
|
+
/**
|
|
29
|
+
* Called by the standalone binary's entry point. That copy has no package
|
|
30
|
+
* manager behind it, so a package-manager command is the wrong upgrade advice.
|
|
31
|
+
*/
|
|
32
|
+
export function markStandaloneBinary(executablePath) {
|
|
33
|
+
standaloneBinaryPath = executablePath;
|
|
34
|
+
}
|
|
35
|
+
/** The standalone binary's own path, or null when running from a package. */
|
|
36
|
+
export function getStandaloneBinaryPath() {
|
|
37
|
+
return standaloneBinaryPath;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `openspec install` reinstalls the standalone binary where it already is,
|
|
41
|
+
* under its own name, so the new copy cannot end up shadowed by the old one.
|
|
42
|
+
* Spelled with the full path when `openspec` on PATH is some other copy.
|
|
43
|
+
* Null when this is not the binary.
|
|
44
|
+
*/
|
|
45
|
+
function standaloneUpgradeCommand() {
|
|
46
|
+
if (!standaloneBinaryPath)
|
|
47
|
+
return null;
|
|
48
|
+
const first = findCopiesOnPath()[0];
|
|
49
|
+
if (first && samePath(first, standaloneBinaryPath))
|
|
50
|
+
return 'openspec install';
|
|
51
|
+
return process.platform === 'win32'
|
|
52
|
+
? `& '${standaloneBinaryPath.replace(/'/g, "''")}' install`
|
|
53
|
+
: `'${standaloneBinaryPath.replace(/'/g, `'\\''`)}' install`;
|
|
54
|
+
}
|
|
25
55
|
/**
|
|
26
56
|
* The check is opt-out and must never get in the way: no network in CI or
|
|
27
57
|
* tests, an explicit escape hatch for anyone offline or air-gapped, and the
|
|
@@ -304,12 +334,14 @@ export async function getAvailableCliUpdate() {
|
|
|
304
334
|
* which copy is actually answering.
|
|
305
335
|
*/
|
|
306
336
|
export function getInstallDir() {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
catch {
|
|
337
|
+
// The binary is not a package install, so no package-manager logic applies.
|
|
338
|
+
if (standaloneBinaryPath)
|
|
311
339
|
return null;
|
|
312
|
-
|
|
340
|
+
// Resolved at runtime from this module's own (symlink-resolved) location.
|
|
341
|
+
// require.resolve would be resolved by the binary's bundler at build time,
|
|
342
|
+
// baking the build machine's path into it.
|
|
343
|
+
const packageJson = fileURLToPath(new URL('../../package.json', import.meta.url));
|
|
344
|
+
return fs.existsSync(packageJson) ? path.dirname(packageJson) : null;
|
|
313
345
|
}
|
|
314
346
|
/**
|
|
315
347
|
* True when the running CLI resolves from a `node_modules` belonging to the
|
|
@@ -508,6 +540,9 @@ function detectGlobalPackageManager(installDir) {
|
|
|
508
540
|
}
|
|
509
541
|
/** Describes the running copy without guessing when its owner is ambiguous. */
|
|
510
542
|
export function getCliInstallInfo(installDir = getInstallDir(), projectPath = '.') {
|
|
543
|
+
if (standaloneBinaryPath) {
|
|
544
|
+
return { location: path.dirname(standaloneBinaryPath), packageManager: null, scope: 'standalone' };
|
|
545
|
+
}
|
|
511
546
|
if (!installDir) {
|
|
512
547
|
return { location: null, packageManager: null, scope: null };
|
|
513
548
|
}
|
|
@@ -537,6 +572,8 @@ export function getCliInstallInfo(installDir = getInstallDir(), projectPath = '.
|
|
|
537
572
|
}
|
|
538
573
|
/** Returns a safe update command only for an install with known global ownership. */
|
|
539
574
|
export function getCliUpdateCommand(install) {
|
|
575
|
+
if (install.scope === 'standalone')
|
|
576
|
+
return standaloneUpgradeCommand();
|
|
540
577
|
if (install.scope !== 'global' || install.packageManager === null)
|
|
541
578
|
return null;
|
|
542
579
|
return GLOBAL_UPGRADE_COMMANDS[install.packageManager];
|
|
@@ -585,6 +622,12 @@ export function buildCliUpdateLines(latestVersion, installDir, projectPath, opti
|
|
|
585
622
|
*/
|
|
586
623
|
export function buildUpgradeCommandLines(installDir, projectPath) {
|
|
587
624
|
const lines = [];
|
|
625
|
+
const standalone = standaloneUpgradeCommand();
|
|
626
|
+
if (standalone) {
|
|
627
|
+
lines.push(` ${standalone}`);
|
|
628
|
+
lines.push(' Then run "openspec update" again to pick up new workflows.');
|
|
629
|
+
return lines;
|
|
630
|
+
}
|
|
588
631
|
if (isEphemeralRunnerInstall(installDir)) {
|
|
589
632
|
// That command *is* the update, so there is nothing to run afterwards.
|
|
590
633
|
lines.push(` npx ${PACKAGE_NAME}@latest update`);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Every `openspec` on PATH, in the order a shell would find them. */
|
|
2
|
+
export declare function findCopiesOnPath(pathValue?: string, platform?: NodeJS.Platform): string[];
|
|
3
|
+
/** Whether two paths name the same file once symlinks are resolved. */
|
|
4
|
+
export declare function samePath(a: string, b: string): boolean;
|
|
5
|
+
//# sourceMappingURL=executable-path.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Every `openspec` on PATH, in the order a shell would find them. */
|
|
4
|
+
export function findCopiesOnPath(pathValue = process.env.PATH ?? '', platform = process.platform) {
|
|
5
|
+
const names = platform === 'win32' ? ['openspec.exe', 'openspec.cmd'] : ['openspec'];
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
const copies = [];
|
|
8
|
+
for (const dir of pathValue.split(path.delimiter)) {
|
|
9
|
+
if (!dir)
|
|
10
|
+
continue;
|
|
11
|
+
for (const name of names) {
|
|
12
|
+
const candidate = path.join(dir, name);
|
|
13
|
+
try {
|
|
14
|
+
if (!fs.statSync(candidate).isFile())
|
|
15
|
+
continue;
|
|
16
|
+
if (platform !== 'win32')
|
|
17
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
18
|
+
const key = platform === 'win32' ? candidate.toLowerCase() : candidate;
|
|
19
|
+
if (!seen.has(key)) {
|
|
20
|
+
seen.add(key);
|
|
21
|
+
copies.push(candidate);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Not there, or not runnable: not a copy a shell would run.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return copies;
|
|
30
|
+
}
|
|
31
|
+
/** Whether two paths name the same file once symlinks are resolved. */
|
|
32
|
+
export function samePath(a, b) {
|
|
33
|
+
const real = (value) => {
|
|
34
|
+
try {
|
|
35
|
+
return fs.realpathSync(value);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return path.resolve(value);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const [x, y] = [real(a), real(b)];
|
|
42
|
+
return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=executable-path.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare class FileSystemUtils {
|
|
2
2
|
/**
|
|
3
3
|
* Converts a path to use forward slashes (POSIX style).
|
|
4
|
-
* Essential for cross-platform compatibility with glob libraries like
|
|
4
|
+
* Essential for cross-platform compatibility with glob libraries like tinyglobby.
|
|
5
5
|
*/
|
|
6
6
|
static toPosixPath(p: string): string;
|
|
7
7
|
/**
|
|
@@ -65,7 +65,7 @@ function findMarkerIndex(content, marker, fromIndex = 0) {
|
|
|
65
65
|
export class FileSystemUtils {
|
|
66
66
|
/**
|
|
67
67
|
* Converts a path to use forward slashes (POSIX style).
|
|
68
|
-
* Essential for cross-platform compatibility with glob libraries like
|
|
68
|
+
* Essential for cross-platform compatibility with glob libraries like tinyglobby.
|
|
69
69
|
*/
|
|
70
70
|
static toPosixPath(p) {
|
|
71
71
|
return p.replace(/\\/g, '/');
|
|
@@ -67,4 +67,19 @@ export declare function confirmPrompt(prompt: ConfirmPrompt, io?: {
|
|
|
67
67
|
input?: Readable;
|
|
68
68
|
output?: Writable;
|
|
69
69
|
}): Promise<boolean>;
|
|
70
|
+
/**
|
|
71
|
+
* Whether a spinner can safely render to `stream`.
|
|
72
|
+
*
|
|
73
|
+
* ora decides how many lines to erase with `ceil(width / stream.columns)`, so a
|
|
74
|
+
* TTY that reports zero columns makes that count `Infinity` and `clear()` never
|
|
75
|
+
* ends: the spinner emits cursor-up/clear-line escapes as fast as it can and the
|
|
76
|
+
* command never finishes (#2106). Zero-width TTYs are real — a pty inherited
|
|
77
|
+
* from a pre-commit runner such as lefthook, or `script -qec` — and nothing can
|
|
78
|
+
* be drawn in zero columns anyway, so refuse the spinner and let the caller fall
|
|
79
|
+
* back to its plain output.
|
|
80
|
+
*/
|
|
81
|
+
export declare function canRenderSpinner(stream?: {
|
|
82
|
+
isTTY?: boolean;
|
|
83
|
+
columns?: number;
|
|
84
|
+
}): boolean;
|
|
70
85
|
//# sourceMappingURL=interactive.d.ts.map
|
|
@@ -153,4 +153,22 @@ function readYesNo(prompt, input, output) {
|
|
|
153
153
|
});
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Whether a spinner can safely render to `stream`.
|
|
158
|
+
*
|
|
159
|
+
* ora decides how many lines to erase with `ceil(width / stream.columns)`, so a
|
|
160
|
+
* TTY that reports zero columns makes that count `Infinity` and `clear()` never
|
|
161
|
+
* ends: the spinner emits cursor-up/clear-line escapes as fast as it can and the
|
|
162
|
+
* command never finishes (#2106). Zero-width TTYs are real — a pty inherited
|
|
163
|
+
* from a pre-commit runner such as lefthook, or `script -qec` — and nothing can
|
|
164
|
+
* be drawn in zero columns anyway, so refuse the spinner and let the caller fall
|
|
165
|
+
* back to its plain output.
|
|
166
|
+
*/
|
|
167
|
+
export function canRenderSpinner(stream = process.stderr) {
|
|
168
|
+
// A non-TTY stream never enters the clear loop: ora disables itself and
|
|
169
|
+
// prints one plain line instead.
|
|
170
|
+
if (!stream.isTTY)
|
|
171
|
+
return true;
|
|
172
|
+
return (stream.columns ?? 0) > 0;
|
|
173
|
+
}
|
|
156
174
|
//# sourceMappingURL=interactive.js.map
|
|
@@ -10,10 +10,10 @@ export interface DiscoveredSpec {
|
|
|
10
10
|
* are found (#1353). A `spec.md` sitting directly in the root is ignored,
|
|
11
11
|
* matching the historical requirement that specs live in a capability folder.
|
|
12
12
|
* Dot-directories are skipped and symlinked directories are not followed.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
|
|
14
|
+
* graph's globs both count it as content, so dropping it here would silently
|
|
15
|
+
* lose the delta on archive. `assertSpecFilePathAllowed` decides how far such
|
|
16
|
+
* a link may point; a dangling link is skipped. Results are sorted by id.
|
|
17
17
|
*
|
|
18
18
|
* A missing root (ENOENT) yields an empty list, but any other read failure
|
|
19
19
|
* (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
|
|
@@ -1,27 +1,17 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { assertDiscoveredSpecFile } from './spec-path-containment.js';
|
|
4
4
|
import { parseDeltaSpec } from '../core/parsers/requirement-blocks.js';
|
|
5
|
-
function assertDiscoveredSpecPath(specsRoot, capabilityDir, specFile) {
|
|
6
|
-
try {
|
|
7
|
-
FileSystemUtils.assertPathWithin(specsRoot, specFile);
|
|
8
|
-
}
|
|
9
|
-
catch {
|
|
10
|
-
// Direct capability directories may intentionally be external monorepo
|
|
11
|
-
// links. In that case, confine the file to the capability itself.
|
|
12
|
-
FileSystemUtils.assertPathWithin(capabilityDir, specFile);
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
5
|
/**
|
|
16
6
|
* Recursively discover every `spec.md` under a specs root, so both the flat
|
|
17
7
|
* `specs/<id>/spec.md` layout and nested `specs/<area>/<id>/spec.md` layouts
|
|
18
8
|
* are found (#1353). A `spec.md` sitting directly in the root is ignored,
|
|
19
9
|
* matching the historical requirement that specs live in a capability folder.
|
|
20
10
|
* Dot-directories are skipped and symlinked directories are not followed.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
11
|
+
* A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
|
|
12
|
+
* graph's globs both count it as content, so dropping it here would silently
|
|
13
|
+
* lose the delta on archive. `assertSpecFilePathAllowed` decides how far such
|
|
14
|
+
* a link may point; a dangling link is skipped. Results are sorted by id.
|
|
25
15
|
*
|
|
26
16
|
* A missing root (ENOENT) yields an empty list, but any other read failure
|
|
27
17
|
* (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
|
|
@@ -49,13 +39,13 @@ export async function discoverSpecFiles(specsRoot) {
|
|
|
49
39
|
else if (entry.name === 'spec.md' && segments.length > 0) {
|
|
50
40
|
const specFile = path.join(dir, entry.name);
|
|
51
41
|
if (entry.isFile()) {
|
|
52
|
-
|
|
42
|
+
assertDiscoveredSpecFile(specsRoot, specFile);
|
|
53
43
|
results.push({ id: segments.join('/'), specFile });
|
|
54
44
|
}
|
|
55
45
|
else if (entry.isSymbolicLink()) {
|
|
56
46
|
try {
|
|
57
47
|
if ((await fs.stat(specFile)).isFile()) {
|
|
58
|
-
|
|
48
|
+
assertDiscoveredSpecFile(specsRoot, specFile);
|
|
59
49
|
results.push({ id: segments.join('/'), specFile });
|
|
60
50
|
}
|
|
61
51
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Containment for a `spec.md` under a trust root already established for it:
|
|
3
|
+
* inside the root, or a link out of the root that stays inside the project.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately does not re-derive a trust root from the file's own capability
|
|
6
|
+
* directory. Callers use this to recheck a path immediately before reading or
|
|
7
|
+
* writing it, and a capability directory swapped for a link since the path was
|
|
8
|
+
* resolved has to be refused rather than trusted afresh.
|
|
9
|
+
*/
|
|
10
|
+
export declare function assertSpecFileWithin(root: string, specFile: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Containment for a `spec.md` as it is discovered, where the capability
|
|
13
|
+
* directory itself may deliberately be a link into another repository and is
|
|
14
|
+
* then the file's own trust root.
|
|
15
|
+
*/
|
|
16
|
+
export declare function assertDiscoveredSpecFile(specsRoot: string, specFile: string): void;
|
|
17
|
+
//# sourceMappingURL=spec-path-containment.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as nodeFs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { OPENSPEC_DIR_NAME } from '../core/config.js';
|
|
4
|
+
import { FileSystemUtils } from './file-system.js';
|
|
5
|
+
/**
|
|
6
|
+
* The project a specs root belongs to, or null when it cannot be named.
|
|
7
|
+
*
|
|
8
|
+
* Every specs root OpenSpec reads is addressed through the project's
|
|
9
|
+
* `openspec` directory - `<project>/openspec/specs` for main specs and
|
|
10
|
+
* `<project>/openspec/changes/<change>/specs` (or `.../changes/archive/...`)
|
|
11
|
+
* for a change's deltas - so the project is the parent of that `openspec`
|
|
12
|
+
* directory.
|
|
13
|
+
*
|
|
14
|
+
* The directory below the match has to be `specs` or `changes`, which is what
|
|
15
|
+
* makes this a planning root rather than a directory that merely shares the
|
|
16
|
+
* name: a store kept at `~/openspec/my-store` would otherwise hand out `~` as
|
|
17
|
+
* its boundary. A specs root reached some other way (a store given by path)
|
|
18
|
+
* has no project to name, and containment stays at the specs root.
|
|
19
|
+
*/
|
|
20
|
+
function findProjectRoot(specsRoot) {
|
|
21
|
+
let current = path.resolve(specsRoot);
|
|
22
|
+
let below = null;
|
|
23
|
+
while (true) {
|
|
24
|
+
const parent = path.dirname(current);
|
|
25
|
+
if (parent === current) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (path.basename(current) === OPENSPEC_DIR_NAME &&
|
|
29
|
+
(below === 'specs' || below === 'changes')) {
|
|
30
|
+
return parent;
|
|
31
|
+
}
|
|
32
|
+
below = path.basename(current);
|
|
33
|
+
current = parent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Path-segment containment on the paths as written, without resolving links. */
|
|
37
|
+
function isLexicallyWithin(allowedDirectory, targetPath) {
|
|
38
|
+
const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
|
|
39
|
+
return (relative === '' ||
|
|
40
|
+
(relative !== '..' &&
|
|
41
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
42
|
+
!path.isAbsolute(relative)));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A project may keep a capability's spec file elsewhere - beside the product
|
|
46
|
+
* documentation it belongs to - and leave a relative symlink at
|
|
47
|
+
* `openspec/specs/<capability>/spec.md`, so OpenSpec and the docs read and
|
|
48
|
+
* write the same bytes. Accept such a link, and refuse one that leaves the
|
|
49
|
+
* project, so a link swapped underneath a repo cannot turn a spec read or an
|
|
50
|
+
* archive write into an arbitrary file access.
|
|
51
|
+
*
|
|
52
|
+
* `outsideError` is rethrown when the file is not a link at all, so a path
|
|
53
|
+
* that simply escapes its root keeps reporting that.
|
|
54
|
+
*/
|
|
55
|
+
function assertLinkedSpecFileInProject(root, specFile, outsideError) {
|
|
56
|
+
const projectRoot = findProjectRoot(root);
|
|
57
|
+
if (projectRoot === null) {
|
|
58
|
+
throw outsideError;
|
|
59
|
+
}
|
|
60
|
+
const resolvedFile = path.resolve(specFile);
|
|
61
|
+
let isLink = false;
|
|
62
|
+
try {
|
|
63
|
+
isLink = nodeFs.lstatSync(resolvedFile).isSymbolicLink();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
isLink = false;
|
|
67
|
+
}
|
|
68
|
+
if (!isLink) {
|
|
69
|
+
throw outsideError;
|
|
70
|
+
}
|
|
71
|
+
// Only where the link points is the project's choice. The directory holding
|
|
72
|
+
// it must still be reached from inside the root.
|
|
73
|
+
FileSystemUtils.assertPathWithin(root, path.dirname(resolvedFile));
|
|
74
|
+
// Canonicalizes through the link, so a target outside the project is refused
|
|
75
|
+
// and a dangling link - which cannot be proven contained - fails loudly.
|
|
76
|
+
FileSystemUtils.assertPathWithin(projectRoot, resolvedFile);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Containment for a `spec.md` under a trust root already established for it:
|
|
80
|
+
* inside the root, or a link out of the root that stays inside the project.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately does not re-derive a trust root from the file's own capability
|
|
83
|
+
* directory. Callers use this to recheck a path immediately before reading or
|
|
84
|
+
* writing it, and a capability directory swapped for a link since the path was
|
|
85
|
+
* resolved has to be refused rather than trusted afresh.
|
|
86
|
+
*/
|
|
87
|
+
export function assertSpecFileWithin(root, specFile) {
|
|
88
|
+
try {
|
|
89
|
+
FileSystemUtils.assertPathWithin(root, specFile);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
assertLinkedSpecFileInProject(root, specFile, error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Containment for a `spec.md` as it is discovered, where the capability
|
|
97
|
+
* directory itself may deliberately be a link into another repository and is
|
|
98
|
+
* then the file's own trust root.
|
|
99
|
+
*/
|
|
100
|
+
export function assertDiscoveredSpecFile(specsRoot, specFile) {
|
|
101
|
+
const capabilityDir = path.dirname(path.resolve(specFile));
|
|
102
|
+
try {
|
|
103
|
+
FileSystemUtils.assertPathWithin(specsRoot, specFile);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
// The capability directory is the file's own trust root only while it is
|
|
108
|
+
// addressed from inside the specs root: the link it may be is deliberate,
|
|
109
|
+
// a `..` out of the root is not.
|
|
110
|
+
if (isLexicallyWithin(specsRoot, capabilityDir)) {
|
|
111
|
+
try {
|
|
112
|
+
FileSystemUtils.assertPathWithin(capabilityDir, specFile);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Not a file of its own capability either; the link rule decides.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
assertLinkedSpecFileInProject(specsRoot, specFile, error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=spec-path-containment.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fission-ai/openspec",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"description": "AI-native system for spec-driven development",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openspec",
|
|
@@ -44,22 +44,26 @@
|
|
|
44
44
|
"@changesets/changelog-github": "^1.0.0",
|
|
45
45
|
"@changesets/cli": "^3.0.1",
|
|
46
46
|
"@types/node": "^20.19.43",
|
|
47
|
-
"@
|
|
47
|
+
"@types/picomatch": "^4.0.3",
|
|
48
|
+
"@vitest/ui": "^4.1.12",
|
|
48
49
|
"eslint": "^10.5.0",
|
|
49
50
|
"smol-toml": "^1.7.1",
|
|
50
51
|
"typescript": "^6.0.3",
|
|
51
52
|
"typescript-eslint": "^8.65.0",
|
|
52
|
-
"
|
|
53
|
+
"vite": "^7.3.7",
|
|
54
|
+
"vitest": "^4.1.12"
|
|
53
55
|
},
|
|
54
56
|
"dependencies": {
|
|
55
57
|
"@inquirer/core": "^12.0.0",
|
|
56
58
|
"@inquirer/prompts": "^8.5.2",
|
|
59
|
+
"brace-expansion": "^5.0.12",
|
|
57
60
|
"chalk": "^5.6.2",
|
|
58
61
|
"commander": "^14.0.0",
|
|
59
62
|
"cross-spawn": "7.0.6",
|
|
60
63
|
"diff": "^9.0.0",
|
|
61
|
-
"fast-glob": "^3.3.3",
|
|
62
64
|
"ora": "^9.4.1",
|
|
65
|
+
"picomatch": "^4.0.4",
|
|
66
|
+
"tinyglobby": "^0.2.17",
|
|
63
67
|
"yaml": "^2.8.3",
|
|
64
68
|
"zod": "^4.4.3"
|
|
65
69
|
},
|