@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.
- package/README.md +12 -10
- package/dist/cli/index.js +47 -0
- package/dist/commands/completion.js +4 -2
- 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/validate.js +33 -3
- 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/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/installers/zsh-installer.d.ts +9 -1
- package/dist/core/completions/installers/zsh-installer.js +64 -19
- 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/id.d.ts +8 -0
- package/dist/core/id.js +15 -0
- 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/parsers/code-fence.js +9 -1
- package/dist/core/parsers/requirement-blocks.d.ts +30 -0
- package/dist/core/parsers/requirement-blocks.js +22 -9
- package/dist/core/parsers/requirement-text.d.ts +14 -0
- package/dist/core/parsers/requirement-text.js +39 -0
- package/dist/core/parsers/spec-structure.js +1 -1
- package/dist/core/profile-sync-drift.d.ts +4 -3
- package/dist/core/profile-sync-drift.js +19 -16
- package/dist/core/project-config.d.ts +10 -0
- package/dist/core/project-config.js +26 -0
- package/dist/core/root-selection.d.ts +6 -0
- package/dist/core/root-selection.js +37 -0
- 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/standalone-tip.d.ts +6 -0
- package/dist/core/standalone-tip.js +38 -0
- package/dist/core/store/git.d.ts +22 -1
- package/dist/core/store/git.js +59 -4
- 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.d.ts +27 -2
- package/dist/core/validation/validator.js +58 -4
- 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/package.json +5 -4
- package/schemas/spec-driven/schema.yaml +1 -0
package/README.md
CHANGED
|
@@ -117,20 +117,20 @@ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the har
|
|
|
117
117
|
|
|
118
118
|
## Quick Start
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
Install OpenSpec globally:
|
|
120
|
+
Install OpenSpec. On macOS or Linux, in your terminal:
|
|
123
121
|
|
|
124
122
|
```bash
|
|
125
|
-
|
|
123
|
+
curl -fsSL https://openspec.dev/cli/install.sh | sh
|
|
126
124
|
```
|
|
127
125
|
|
|
128
|
-
|
|
126
|
+
On Windows, in PowerShell:
|
|
129
127
|
|
|
130
|
-
```
|
|
131
|
-
|
|
128
|
+
```powershell
|
|
129
|
+
irm https://openspec.dev/cli/install.ps1 | iex
|
|
132
130
|
```
|
|
133
131
|
|
|
132
|
+
Prefer a package manager? `npm install -g @fission-ai/openspec@latest` (needs Node.js 22.12.0 or higher) or the official [Homebrew formula](https://formulae.brew.sh/formula/openspec), `brew install openspec`.
|
|
133
|
+
|
|
134
134
|
Then navigate to your project directory and initialize:
|
|
135
135
|
|
|
136
136
|
```bash
|
|
@@ -152,7 +152,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`,
|
|
|
152
152
|
> [!NOTE]
|
|
153
153
|
> Not sure if your tool is supported? [View the full list](https://openspec.dev/docs/supported-tools) – we support 30+ tools and growing.
|
|
154
154
|
>
|
|
155
|
-
> Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](https://openspec.dev/docs/installation).
|
|
155
|
+
> Also works with npm, Homebrew, pnpm, yarn, bun, and Nix. [See installation options](https://openspec.dev/docs/installation).
|
|
156
156
|
|
|
157
157
|
## Docs
|
|
158
158
|
|
|
@@ -192,7 +192,9 @@ AI coding assistants are powerful but unpredictable when requirements live only
|
|
|
192
192
|
|
|
193
193
|
## Updating OpenSpec
|
|
194
194
|
|
|
195
|
-
**Upgrade the
|
|
195
|
+
**Upgrade the CLI**
|
|
196
|
+
|
|
197
|
+
With the standalone install, run `openspec install`. If you installed with npm:
|
|
196
198
|
|
|
197
199
|
```bash
|
|
198
200
|
npm install -g @fission-ai/openspec@latest
|
|
@@ -220,7 +222,7 @@ openspec update
|
|
|
220
222
|
|
|
221
223
|
## Contributing
|
|
222
224
|
|
|
223
|
-
|
|
225
|
+
Every change starts as an issue. We discuss it there, and if we agree to pursue it, a maintainer implements the change or invites you to build it with us.
|
|
224
226
|
|
|
225
227
|
→ **[CONTRIBUTING.md](CONTRIBUTING.md)**: the full process, from first issue to merged PR
|
|
226
228
|
|
package/dist/cli/index.js
CHANGED
|
@@ -277,8 +277,29 @@ program
|
|
|
277
277
|
.command('update [path]')
|
|
278
278
|
.description('Update OpenSpec instruction files')
|
|
279
279
|
.option('--force', 'Force update even when tools are up to date')
|
|
280
|
+
.option('--dry-run', 'Report what the update would change without writing anything')
|
|
281
|
+
.option('--json', 'With --dry-run, print the planned changes as JSON')
|
|
280
282
|
.action(async (targetPath = '.', options) => {
|
|
281
283
|
try {
|
|
284
|
+
if (options?.json && !options?.dryRun) {
|
|
285
|
+
console.error('--json is only available with --dry-run.');
|
|
286
|
+
process.exitCode = 1;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// A dry run reports the plan and nothing else: no network check, no
|
|
290
|
+
// upgrade prompt, no output that would break --json.
|
|
291
|
+
if (options?.dryRun) {
|
|
292
|
+
const [{ UpdateCommand }, { formatUpdatePlan }] = await Promise.all([
|
|
293
|
+
import('../core/update.js'),
|
|
294
|
+
import('../core/update-plan.js'),
|
|
295
|
+
]);
|
|
296
|
+
const plan = await new UpdateCommand({
|
|
297
|
+
force: options?.force,
|
|
298
|
+
dryRun: true,
|
|
299
|
+
}).plan(targetPath);
|
|
300
|
+
console.log(options?.json ? JSON.stringify(plan, null, 2) : formatUpdatePlan(plan));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
282
303
|
const [{ getInstallDir, isSourceCheckout, getAvailableCliUpdate, shouldOfferUpgrade, displayCliUpdateNote, offerCliUpgrade, rerunUpdateWithUpgradedCli, displayUpgradeCommand, }, { isInteractive }, { UpdateCommand },] = await Promise.all([
|
|
283
304
|
import('../core/version-check.js'),
|
|
284
305
|
import('../utils/interactive.js'),
|
|
@@ -333,6 +354,32 @@ program
|
|
|
333
354
|
else if (latestVersion) {
|
|
334
355
|
displayCliUpdateNote(latestVersion, targetPath);
|
|
335
356
|
}
|
|
357
|
+
// Once per machine, a package-manager user hears the standalone install
|
|
358
|
+
// exists. Offered, never pushed; deferred when stderr is not a terminal.
|
|
359
|
+
const { maybeShowStandaloneTip } = await import('../core/standalone-tip.js');
|
|
360
|
+
await maybeShowStandaloneTip({ silent: !process.stderr.isTTY });
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
// The --json dry run follows the agent contract: exactly one JSON
|
|
364
|
+
// document on stdout, even on failure.
|
|
365
|
+
// failWithError sets the exit code. Returning instead of process.exit()
|
|
366
|
+
// lets piped stdout drain - an exit can cut the JSON document short where
|
|
367
|
+
// pipes are asynchronous (macOS, Windows) - and keeps commander's
|
|
368
|
+
// postAction telemetry flush, as the upgrade paths above already do.
|
|
369
|
+
await failWithError(error, {
|
|
370
|
+
enabled: options?.dryRun && options?.json,
|
|
371
|
+
payload: { changes: [], notes: [], failures: [] },
|
|
372
|
+
fallbackCode: 'update_error',
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
program
|
|
377
|
+
.command('install [version]')
|
|
378
|
+
.description('Install the standalone openspec, which runs without Node.js (latest release unless [version] is given)')
|
|
379
|
+
.action(async (version) => {
|
|
380
|
+
try {
|
|
381
|
+
const { installStandalone } = await import('../commands/install-standalone.js');
|
|
382
|
+
await installStandalone({ version });
|
|
336
383
|
}
|
|
337
384
|
catch (error) {
|
|
338
385
|
await failWithError(error);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CompletionFactory } from '../core/completions/factory.js';
|
|
2
|
+
import { zshrcDisplayPath } from '../core/completions/installers/zsh-installer.js';
|
|
2
3
|
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
|
3
4
|
import { detectShell } from '../utils/shell-detection.js';
|
|
4
5
|
import { CompletionProvider } from '../core/completions/completion-provider.js';
|
|
@@ -113,7 +114,7 @@ export class CompletionCommand {
|
|
|
113
114
|
const configWasUpdated = result.zshrcConfigured || result.bashrcConfigured || result.profileConfigured;
|
|
114
115
|
if (configWasUpdated) {
|
|
115
116
|
const configPaths = {
|
|
116
|
-
zsh:
|
|
117
|
+
zsh: zshrcDisplayPath(),
|
|
117
118
|
bash: '~/.bashrc',
|
|
118
119
|
fish: '~/.config/fish/config.fish',
|
|
119
120
|
powershell: '$PROFILE',
|
|
@@ -174,7 +175,8 @@ export class CompletionCommand {
|
|
|
174
175
|
const { confirm } = await import('@inquirer/prompts');
|
|
175
176
|
// Get shell-specific config file path
|
|
176
177
|
const configPaths = {
|
|
177
|
-
|
|
178
|
+
// Uninstall also clears a block an older install left in ~/.zshrc.
|
|
179
|
+
zsh: zshrcDisplayPath() === '~/.zshrc' ? '~/.zshrc' : `${zshrcDisplayPath()} (and ~/.zshrc if an older install wrote there)`,
|
|
178
180
|
bash: '~/.bashrc',
|
|
179
181
|
fish: 'Fish configuration', // Fish doesn't modify profile, just removes script file
|
|
180
182
|
powershell: '$PROFILE',
|
|
@@ -4,6 +4,7 @@ import os from 'os';
|
|
|
4
4
|
const require = createRequire(import.meta.url);
|
|
5
5
|
const MAX_TITLE_LENGTH = 72;
|
|
6
6
|
const TITLE_PREFIX = 'Feedback: ';
|
|
7
|
+
const REPRO_PROMPT = '_Not provided — filed with `openspec feedback`. If this is a bug, please add the shortest steps that reproduce it from a fresh project._';
|
|
7
8
|
/**
|
|
8
9
|
* Check if gh CLI is installed and available in PATH
|
|
9
10
|
* Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS
|
|
@@ -97,24 +98,37 @@ function formatTitle(message) {
|
|
|
97
98
|
}
|
|
98
99
|
/**
|
|
99
100
|
* Format the full feedback body
|
|
101
|
+
*
|
|
102
|
+
* The headings mirror the bug form's labels (.github/ISSUE_TEMPLATE/bug_report.yml)
|
|
103
|
+
* so a CLI-filed issue reads like one filed through the form. `gh issue create
|
|
104
|
+
* --body` cannot fill an issue form, so the repro prompt is spelled out instead
|
|
105
|
+
* of being left silently empty.
|
|
100
106
|
*/
|
|
101
107
|
function formatBody(message, bodyText) {
|
|
102
|
-
const parts = ['
|
|
108
|
+
const parts = ['### What happened', '', message];
|
|
103
109
|
if (bodyText) {
|
|
104
|
-
parts.push('', '
|
|
110
|
+
parts.push('', '### Details', '', bodyText);
|
|
105
111
|
}
|
|
112
|
+
parts.push('', '### Minimal steps to reproduce', '', REPRO_PROMPT);
|
|
106
113
|
parts.push('', generateMetadata());
|
|
107
114
|
return parts.join('\n');
|
|
108
115
|
}
|
|
109
116
|
/**
|
|
110
117
|
* Generate a pre-filled GitHub issue URL for manual submission
|
|
118
|
+
*
|
|
119
|
+
* Opens the bug form itself. The query keys are the form's own field ids, which
|
|
120
|
+
* is how GitHub prefills an issue form; the fields it leaves empty are the
|
|
121
|
+
* required ones only the reporter can answer.
|
|
111
122
|
*/
|
|
112
|
-
function generateManualSubmissionUrl(title,
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
function generateManualSubmissionUrl(title, message, bodyText) {
|
|
124
|
+
const params = new URLSearchParams({
|
|
125
|
+
template: 'bug_report.yml',
|
|
126
|
+
title,
|
|
127
|
+
what_happened: bodyText ? `${message}\n\n${bodyText}` : message,
|
|
128
|
+
version: getVersion(),
|
|
129
|
+
environment: `${getPlatform()}, Node ${process.version}`,
|
|
130
|
+
});
|
|
131
|
+
return `https://github.com/Fission-AI/OpenSpec/issues/new?${params.toString()}`;
|
|
118
132
|
}
|
|
119
133
|
/**
|
|
120
134
|
* Display formatted feedback content for manual submission
|
|
@@ -145,7 +159,7 @@ function isMissingLabelError(error) {
|
|
|
145
159
|
* network, rate limit, ...), so show the same manual-submission path the
|
|
146
160
|
* missing-gh and unauthenticated flows get instead of discarding the text.
|
|
147
161
|
*/
|
|
148
|
-
function reportGhFailure(error, title, body) {
|
|
162
|
+
function reportGhFailure(error, title, body, url) {
|
|
149
163
|
// Display the error output from gh CLI
|
|
150
164
|
if (error.stderr) {
|
|
151
165
|
console.error(error.stderr.toString());
|
|
@@ -154,9 +168,8 @@ function reportGhFailure(error, title, body) {
|
|
|
154
168
|
console.error(error.message);
|
|
155
169
|
}
|
|
156
170
|
displayFormattedFeedback(title, body);
|
|
157
|
-
const manualUrl = generateManualSubmissionUrl(title, body);
|
|
158
171
|
console.log('Please submit your feedback manually:');
|
|
159
|
-
console.log(
|
|
172
|
+
console.log(url);
|
|
160
173
|
// Exit with the same code as gh CLI
|
|
161
174
|
process.exit(error.status ?? 1);
|
|
162
175
|
}
|
|
@@ -184,7 +197,7 @@ function createIssue(title, body, labels) {
|
|
|
184
197
|
/**
|
|
185
198
|
* Submit feedback via gh CLI
|
|
186
199
|
*/
|
|
187
|
-
function submitViaGhCli(title, body) {
|
|
200
|
+
function submitViaGhCli(title, body, url) {
|
|
188
201
|
let issueUrl;
|
|
189
202
|
let labelApplied = true;
|
|
190
203
|
try {
|
|
@@ -192,7 +205,7 @@ function submitViaGhCli(title, body) {
|
|
|
192
205
|
}
|
|
193
206
|
catch (error) {
|
|
194
207
|
if (!isMissingLabelError(error)) {
|
|
195
|
-
reportGhFailure(error, title, body);
|
|
208
|
+
reportGhFailure(error, title, body, url);
|
|
196
209
|
return;
|
|
197
210
|
}
|
|
198
211
|
// The repository does not define the 'feedback' label. Nothing was
|
|
@@ -202,7 +215,7 @@ function submitViaGhCli(title, body) {
|
|
|
202
215
|
labelApplied = false;
|
|
203
216
|
}
|
|
204
217
|
catch (retryError) {
|
|
205
|
-
reportGhFailure(retryError, title, body);
|
|
218
|
+
reportGhFailure(retryError, title, body, url);
|
|
206
219
|
return;
|
|
207
220
|
}
|
|
208
221
|
}
|
|
@@ -215,7 +228,7 @@ function submitViaGhCli(title, body) {
|
|
|
215
228
|
/**
|
|
216
229
|
* Handle fallback when gh CLI is not available or not authenticated
|
|
217
230
|
*/
|
|
218
|
-
function handleFallback(title, body, reason) {
|
|
231
|
+
function handleFallback(title, body, url, reason) {
|
|
219
232
|
if (reason === 'missing') {
|
|
220
233
|
console.log('⚠️ GitHub CLI not found. Manual submission required.');
|
|
221
234
|
}
|
|
@@ -223,9 +236,8 @@ function handleFallback(title, body, reason) {
|
|
|
223
236
|
console.log('⚠️ GitHub authentication required. Manual submission required.');
|
|
224
237
|
}
|
|
225
238
|
displayFormattedFeedback(title, body);
|
|
226
|
-
const manualUrl = generateManualSubmissionUrl(title, body);
|
|
227
239
|
console.log('Please submit your feedback manually:');
|
|
228
|
-
console.log(
|
|
240
|
+
console.log(url);
|
|
229
241
|
if (reason === 'unauthenticated') {
|
|
230
242
|
console.log('\nTo auto-submit in the future: gh auth login');
|
|
231
243
|
}
|
|
@@ -240,18 +252,19 @@ export class FeedbackCommand {
|
|
|
240
252
|
// Format title and body once for all code paths
|
|
241
253
|
const title = formatTitle(message);
|
|
242
254
|
const body = formatBody(message, options?.body);
|
|
255
|
+
const url = generateManualSubmissionUrl(title, message, options?.body);
|
|
243
256
|
// Check if gh CLI is installed
|
|
244
257
|
if (!isGhInstalled()) {
|
|
245
|
-
handleFallback(title, body, 'missing');
|
|
258
|
+
handleFallback(title, body, url, 'missing');
|
|
246
259
|
return;
|
|
247
260
|
}
|
|
248
261
|
// Check if gh CLI is authenticated
|
|
249
262
|
if (!isGhAuthenticated()) {
|
|
250
|
-
handleFallback(title, body, 'unauthenticated');
|
|
263
|
+
handleFallback(title, body, url, 'unauthenticated');
|
|
251
264
|
return;
|
|
252
265
|
}
|
|
253
266
|
// Submit via gh CLI
|
|
254
|
-
submitViaGhCli(title, body);
|
|
267
|
+
submitViaGhCli(title, body, url);
|
|
255
268
|
}
|
|
256
269
|
}
|
|
257
270
|
//# sourceMappingURL=feedback.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type CliInstallInfo } from '../core/version-check.js';
|
|
2
|
+
export interface InstallTarget {
|
|
3
|
+
dir: string;
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Where the installer will put the binary. Mirrors install.sh and install.ps1,
|
|
8
|
+
* and is passed to them explicitly so both sides agree. The standalone binary
|
|
9
|
+
* reinstalls itself where it is, under its own name.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveInstallTarget(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, standalonePath?: string | null): InstallTarget;
|
|
12
|
+
/** The command that removes a package-manager install, or null when there is nothing to remove. */
|
|
13
|
+
export declare function removeCommandFor(install: CliInstallInfo): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* Checks a download source given through the environment. Release files and
|
|
16
|
+
* SHA256SUMS come from the same place, so a remote plain-HTTP source could
|
|
17
|
+
* swap both: only https, or http to this machine. The host is parsed, not
|
|
18
|
+
* prefix-matched, and a user part (http://localhost:80@evil.example/) is
|
|
19
|
+
* refused.
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkSource(label: string, value: string): string;
|
|
22
|
+
/** Where the installer script comes from: OPENSPEC_DOWNLOAD_URL when set (mirrors, tests), else main. */
|
|
23
|
+
export declare function installerBase(env?: NodeJS.ProcessEnv): string;
|
|
24
|
+
export declare function installStandalone(options?: {
|
|
25
|
+
version?: string;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
//# sourceMappingURL=install-standalone.d.ts.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { findCopiesOnPath, samePath } from '../utils/executable-path.js';
|
|
6
|
+
import { getCliInstallInfo, getStandaloneBinaryPath, } from '../core/version-check.js';
|
|
7
|
+
/**
|
|
8
|
+
* `openspec install`: installs the standalone binary (no Node.js needed) by
|
|
9
|
+
* running the same release installer the docs give users, then says how to
|
|
10
|
+
* retire the package-manager copy. It never removes that copy itself: switching
|
|
11
|
+
* is the user's choice, and so is when.
|
|
12
|
+
*/
|
|
13
|
+
// The installers come from main, which always has them; they then pick the
|
|
14
|
+
// newest release whose files are all attached (see install.sh).
|
|
15
|
+
const INSTALLER_BASE = 'https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary';
|
|
16
|
+
const RELEASE_VERSION = /^v?\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?$/;
|
|
17
|
+
/**
|
|
18
|
+
* Where the installer will put the binary. Mirrors install.sh and install.ps1,
|
|
19
|
+
* and is passed to them explicitly so both sides agree. The standalone binary
|
|
20
|
+
* reinstalls itself where it is, under its own name.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveInstallTarget(env = process.env, platform = process.platform, standalonePath = getStandaloneBinaryPath()) {
|
|
23
|
+
const defaultName = platform === 'win32' ? 'openspec.exe' : 'openspec';
|
|
24
|
+
if (env.OPENSPEC_INSTALL_DIR) {
|
|
25
|
+
return { dir: path.resolve(env.OPENSPEC_INSTALL_DIR), name: env.OPENSPEC_INSTALL_NAME || defaultName };
|
|
26
|
+
}
|
|
27
|
+
if (standalonePath) {
|
|
28
|
+
return { dir: path.dirname(standalonePath), name: path.basename(standalonePath) };
|
|
29
|
+
}
|
|
30
|
+
if (platform === 'win32') {
|
|
31
|
+
const localAppData = env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
32
|
+
return { dir: path.join(localAppData, 'Programs', 'openspec'), name: defaultName };
|
|
33
|
+
}
|
|
34
|
+
return { dir: path.join(os.homedir(), '.local', 'bin'), name: defaultName };
|
|
35
|
+
}
|
|
36
|
+
/** The command that removes a package-manager install, or null when there is nothing to remove. */
|
|
37
|
+
export function removeCommandFor(install) {
|
|
38
|
+
if (install.scope !== 'global')
|
|
39
|
+
return null;
|
|
40
|
+
// The Homebrew formula keeps its own npm tree under Cellar; brew owns it.
|
|
41
|
+
if (install.location && /[\\/]Cellar[\\/]openspec[\\/]/i.test(install.location)) {
|
|
42
|
+
return 'brew uninstall openspec';
|
|
43
|
+
}
|
|
44
|
+
switch (install.packageManager) {
|
|
45
|
+
case 'npm':
|
|
46
|
+
return 'npm uninstall -g @fission-ai/openspec';
|
|
47
|
+
case 'pnpm':
|
|
48
|
+
return 'pnpm remove -g @fission-ai/openspec';
|
|
49
|
+
case 'yarn':
|
|
50
|
+
return 'yarn global remove @fission-ai/openspec';
|
|
51
|
+
case 'bun':
|
|
52
|
+
return 'bun remove -g @fission-ai/openspec';
|
|
53
|
+
case 'volta':
|
|
54
|
+
return 'volta uninstall @fission-ai/openspec';
|
|
55
|
+
default:
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Checks a download source given through the environment. Release files and
|
|
61
|
+
* SHA256SUMS come from the same place, so a remote plain-HTTP source could
|
|
62
|
+
* swap both: only https, or http to this machine. The host is parsed, not
|
|
63
|
+
* prefix-matched, and a user part (http://localhost:80@evil.example/) is
|
|
64
|
+
* refused.
|
|
65
|
+
*/
|
|
66
|
+
export function checkSource(label, value) {
|
|
67
|
+
let url;
|
|
68
|
+
try {
|
|
69
|
+
url = new URL(value);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new Error(`${label} is not a URL: ${value}`);
|
|
73
|
+
}
|
|
74
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
75
|
+
if (url.username || url.password || (url.protocol !== 'https:' && !(url.protocol === 'http:' && local))) {
|
|
76
|
+
throw new Error(`${label} must use https (or http to localhost): ${value}`);
|
|
77
|
+
}
|
|
78
|
+
return url.href.replace(/\/+$/, '');
|
|
79
|
+
}
|
|
80
|
+
/** Where the installer script comes from: OPENSPEC_DOWNLOAD_URL when set (mirrors, tests), else main. */
|
|
81
|
+
export function installerBase(env = process.env) {
|
|
82
|
+
if (env.OPENSPEC_RELEASES_URL)
|
|
83
|
+
checkSource('OPENSPEC_RELEASES_URL', env.OPENSPEC_RELEASES_URL);
|
|
84
|
+
return env.OPENSPEC_DOWNLOAD_URL ? checkSource('OPENSPEC_DOWNLOAD_URL', env.OPENSPEC_DOWNLOAD_URL) : INSTALLER_BASE;
|
|
85
|
+
}
|
|
86
|
+
// Fixed scripts: the installer URL reaches them as data (an argument, or an
|
|
87
|
+
// environment variable), never as part of the command text.
|
|
88
|
+
const POSIX_RUNNER = 'f=$(mktemp) || exit 1; { curl -fsSL "$1" -o "$f" 2>/dev/null || wget -q -O "$f" "$1"; } && sh "$f"; s=$?; rm -f "$f"; exit $s';
|
|
89
|
+
const WINDOWS_RUNNER = 'irm $env:OPENSPEC_INSTALLER_URL | iex';
|
|
90
|
+
/**
|
|
91
|
+
* Downloads and runs the installer with the same tools as the documented
|
|
92
|
+
* one-liners (curl or wget; PowerShell's irm), so proxies and certificates
|
|
93
|
+
* behave as they do for those. Saved to a file first on macOS and Linux:
|
|
94
|
+
* piped straight into sh, a failed download would look like a successful run.
|
|
95
|
+
*/
|
|
96
|
+
function runInstaller(scriptUrl, env) {
|
|
97
|
+
const windows = process.platform === 'win32';
|
|
98
|
+
const command = windows ? 'powershell.exe' : 'sh';
|
|
99
|
+
const args = windows
|
|
100
|
+
? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_RUNNER]
|
|
101
|
+
: ['-c', POSIX_RUNNER, 'openspec-install', scriptUrl];
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const child = spawn(command, args, {
|
|
104
|
+
stdio: 'inherit',
|
|
105
|
+
env: windows ? { ...env, OPENSPEC_INSTALLER_URL: scriptUrl } : env,
|
|
106
|
+
});
|
|
107
|
+
child.on('error', reject);
|
|
108
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export async function installStandalone(options = {}) {
|
|
112
|
+
const { version } = options;
|
|
113
|
+
if (version && version !== 'latest' && !RELEASE_VERSION.test(version)) {
|
|
114
|
+
throw new Error(`Not a release version: ${version}. Use one like 1.15.0, or leave it out for the latest.`);
|
|
115
|
+
}
|
|
116
|
+
const scriptUrl = `${installerBase()}/${process.platform === 'win32' ? 'install.ps1' : 'install.sh'}`;
|
|
117
|
+
const target = resolveInstallTarget();
|
|
118
|
+
const exitCode = await runInstaller(scriptUrl, {
|
|
119
|
+
...process.env,
|
|
120
|
+
OPENSPEC_INSTALL_DIR: target.dir,
|
|
121
|
+
OPENSPEC_INSTALL_NAME: target.name,
|
|
122
|
+
...(version && version !== 'latest' ? { OPENSPEC_VERSION: version } : {}),
|
|
123
|
+
});
|
|
124
|
+
if (exitCode !== 0) {
|
|
125
|
+
throw new Error(`The standalone install did not finish (installer: ${scriptUrl}).`);
|
|
126
|
+
}
|
|
127
|
+
const installedPath = path.join(target.dir, target.name);
|
|
128
|
+
const defaultName = process.platform === 'win32' ? 'openspec.exe' : 'openspec';
|
|
129
|
+
// A custom file name is never what `openspec` on PATH finds, so skip that check.
|
|
130
|
+
const first = target.name === defaultName ? findCopiesOnPath()[0] : undefined;
|
|
131
|
+
const runsStandalone = first !== undefined && samePath(first, installedPath);
|
|
132
|
+
// Run from a package manager, the old copy stays until the user removes it.
|
|
133
|
+
const remove = getStandaloneBinaryPath() ? null : removeCommandFor(getCliInstallInfo());
|
|
134
|
+
console.log();
|
|
135
|
+
if (runsStandalone) {
|
|
136
|
+
console.log('openspec now runs the standalone copy.');
|
|
137
|
+
}
|
|
138
|
+
else if (first) {
|
|
139
|
+
console.log(`${first} comes first on your PATH, so it still runs.`);
|
|
140
|
+
}
|
|
141
|
+
else if (target.name === defaultName) {
|
|
142
|
+
console.log('Open a new terminal so it picks up the new PATH.');
|
|
143
|
+
}
|
|
144
|
+
if (remove) {
|
|
145
|
+
console.log(runsStandalone ? 'To remove your package-manager copy:' : 'To switch, remove your package-manager copy:');
|
|
146
|
+
console.log(chalk.cyan(` ${remove}`));
|
|
147
|
+
}
|
|
148
|
+
console.log(chalk.dim("Check with 'openspec version'; it shows (standalone)."));
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=install-standalone.js.map
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import ora from 'ora';
|
|
5
4
|
import { stringify as stringifyYaml, parseDocument, isMap } from 'yaml';
|
|
6
5
|
import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
|
|
7
6
|
import { findApplyTracksWarning, parseSchema, SchemaValidationError, } from '../core/artifact-graph/schema.js';
|
|
@@ -602,7 +601,11 @@ export async function schemaValidateCommand(name, options) {
|
|
|
602
601
|
}
|
|
603
602
|
}
|
|
604
603
|
export async function schemaForkCommand(source, name, options) {
|
|
605
|
-
|
|
604
|
+
let spinner = null;
|
|
605
|
+
if (!options?.json) {
|
|
606
|
+
const { default: ora } = await import('ora');
|
|
607
|
+
spinner = ora();
|
|
608
|
+
}
|
|
606
609
|
try {
|
|
607
610
|
const projectRoot = process.cwd();
|
|
608
611
|
const destinationName = name || `${source}-custom`;
|
|
@@ -829,7 +832,11 @@ export async function schemaForkCommand(source, name, options) {
|
|
|
829
832
|
}
|
|
830
833
|
}
|
|
831
834
|
export async function schemaInitCommand(name, options) {
|
|
832
|
-
|
|
835
|
+
let spinner = null;
|
|
836
|
+
if (!options?.json) {
|
|
837
|
+
const { default: ora } = await import('ora');
|
|
838
|
+
spinner = ora();
|
|
839
|
+
}
|
|
833
840
|
try {
|
|
834
841
|
const projectRoot = process.cwd();
|
|
835
842
|
// Validate name
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import ora from 'ora';
|
|
2
1
|
import path from 'path';
|
|
3
2
|
import { describeNestedChange, findNestedChangesIn, NESTED_CHANGE_ISSUE_MARKER, } from '../utils/nested-change.js';
|
|
4
3
|
import { Validator } from '../core/validation/validator.js';
|
|
@@ -12,6 +11,7 @@ import { promises as fs } from 'fs';
|
|
|
12
11
|
import { getTaskProgressDetailForChange } from '../utils/task-progress.js';
|
|
13
12
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
14
13
|
import { folderStyleNameProblem } from '../core/id.js';
|
|
14
|
+
import { readConfigParseError } from '../core/project-config.js';
|
|
15
15
|
/** Findings are a distinct report, not a partial full-v1 items collection. */
|
|
16
16
|
export function projectValidationFindings(full, scope) {
|
|
17
17
|
const itemFindings = full.items.filter(item => item.issues.length > 0);
|
|
@@ -67,6 +67,28 @@ export class ValidateCommand {
|
|
|
67
67
|
if (!root) {
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
|
+
// Every other command ignores an unreadable or unparseable config with a
|
|
71
|
+
// warning, so validate is where CI has to catch it (#1892).
|
|
72
|
+
const configError = readConfigParseError(root.path);
|
|
73
|
+
if (configError) {
|
|
74
|
+
const unreadable = configError.kind === 'read';
|
|
75
|
+
const message = unreadable
|
|
76
|
+
? `Could not read ${configError.filePath} (${configError.message}).`
|
|
77
|
+
: `Could not parse ${configError.filePath} (${configError.message}).`;
|
|
78
|
+
const fix = unreadable
|
|
79
|
+
? `Make ${configError.filePath} a readable file, or remove it.`
|
|
80
|
+
: `Fix the YAML syntax in ${configError.filePath}.`;
|
|
81
|
+
const code = unreadable ? 'unreadable_config' : 'unparseable_config';
|
|
82
|
+
if (options.json) {
|
|
83
|
+
console.log(JSON.stringify({ status: [{ severity: 'error', code, message, fix }] }, null, 2));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
console.error(`Error: ${message}`);
|
|
87
|
+
console.error(`Fix: ${fix}`);
|
|
88
|
+
}
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
70
92
|
const interactive = isInteractive(options);
|
|
71
93
|
// Archived-task linting is its own scope: it checks task completion of
|
|
72
94
|
// already-archived changes, not delta specs (whose operations are already
|
|
@@ -356,7 +378,11 @@ export class ValidateCommand {
|
|
|
356
378
|
}
|
|
357
379
|
}
|
|
358
380
|
async runBulkValidation(root, scope, opts) {
|
|
359
|
-
|
|
381
|
+
let spinner;
|
|
382
|
+
if (!opts.json && isInteractive(opts) && canRenderSpinner()) {
|
|
383
|
+
const { default: ora } = await import('ora');
|
|
384
|
+
spinner = ora('Validating...').start();
|
|
385
|
+
}
|
|
360
386
|
const [changeIds, specIds] = await Promise.all([
|
|
361
387
|
scope.changes ? this.listChangeIds(root) : Promise.resolve([]),
|
|
362
388
|
scope.specs ? getSpecIds(root.path) : Promise.resolve([]),
|
|
@@ -527,7 +553,11 @@ export class ValidateCommand {
|
|
|
527
553
|
// List first (may throw on a real archive-read failure), then start the
|
|
528
554
|
// spinner so a thrown error never leaves a spinner spinning.
|
|
529
555
|
const ids = await this.listArchivedChangeIds(root);
|
|
530
|
-
|
|
556
|
+
let spinner;
|
|
557
|
+
if (!opts.json && isInteractive(opts) && canRenderSpinner()) {
|
|
558
|
+
const { default: ora } = await import('ora');
|
|
559
|
+
spinner = ora('Validating archived changes...').start();
|
|
560
|
+
}
|
|
531
561
|
// The archive is append-only and can hold thousands of changes; a single
|
|
532
562
|
// run resolves them all under one constant projectRoot (root.path), so
|
|
533
563
|
// memoize the schema→glob lookup to avoid re-parsing the same schema.yaml
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Generates enriched instructions for creating artifacts or applying tasks.
|
|
5
5
|
* Includes both artifact instructions and apply instructions.
|
|
6
6
|
*/
|
|
7
|
-
import ora from 'ora';
|
|
8
7
|
import path from 'path';
|
|
9
8
|
import * as fs from 'fs';
|
|
10
9
|
import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
|
|
@@ -50,7 +49,11 @@ export async function instructionsCommand(artifactId, options) {
|
|
|
50
49
|
if (!root) {
|
|
51
50
|
return;
|
|
52
51
|
}
|
|
53
|
-
|
|
52
|
+
let spinner;
|
|
53
|
+
if (!options.json) {
|
|
54
|
+
const { default: ora } = await import('ora');
|
|
55
|
+
spinner = ora('Generating instructions...').start();
|
|
56
|
+
}
|
|
54
57
|
try {
|
|
55
58
|
const planningHome = toPlanningHome(root);
|
|
56
59
|
const projectRoot = root.path;
|
|
@@ -534,7 +537,11 @@ export async function applyInstructionsCommand(options) {
|
|
|
534
537
|
if (!root) {
|
|
535
538
|
return;
|
|
536
539
|
}
|
|
537
|
-
|
|
540
|
+
let spinner;
|
|
541
|
+
if (!options.json) {
|
|
542
|
+
const { default: ora } = await import('ora');
|
|
543
|
+
spinner = ora('Generating apply instructions...').start();
|
|
544
|
+
}
|
|
538
545
|
try {
|
|
539
546
|
const planningHome = toPlanningHome(root);
|
|
540
547
|
const projectRoot = root.path;
|
|
@@ -639,7 +646,11 @@ export async function archiveInstructionsCommand(options) {
|
|
|
639
646
|
if (!root) {
|
|
640
647
|
return;
|
|
641
648
|
}
|
|
642
|
-
|
|
649
|
+
let spinner;
|
|
650
|
+
if (!options.json) {
|
|
651
|
+
const { default: ora } = await import('ora');
|
|
652
|
+
spinner = ora('Loading archive inputs...').start();
|
|
653
|
+
}
|
|
643
654
|
try {
|
|
644
655
|
const changeName = await validateChangeExists(options.change, root.path, root.changesDir, { newChangeHint: withStoreFlag(root, 'openspec new change <name>') });
|
|
645
656
|
const projectConfig = readProjectConfig(root.path);
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* this command.
|
|
8
8
|
*/
|
|
9
9
|
import chalk from 'chalk';
|
|
10
|
-
import ora from 'ora';
|
|
11
10
|
import path from 'path';
|
|
12
11
|
import { createChange, validateChangeName } from '../../utils/change-utils.js';
|
|
13
12
|
import { formatChangeLocation } from '../../core/planning-home.js';
|
|
14
13
|
import { resolveRootForCommand, RootSelectionError, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../../core/root-selection.js';
|
|
14
|
+
import { readProjectConfig } from '../../core/project-config.js';
|
|
15
15
|
import { printJson, statusFromError, validateSchemaExists } from './shared.js';
|
|
16
16
|
// -----------------------------------------------------------------------------
|
|
17
17
|
// Command Implementation
|
|
@@ -53,7 +53,11 @@ function printImplicitRootNotice(root) {
|
|
|
53
53
|
console.log(chalk.dim('Run `openspec init` to finish setting this project up, or delete that directory if you meant a different project.'));
|
|
54
54
|
}
|
|
55
55
|
export async function newChangeCommand(name, options) {
|
|
56
|
-
|
|
56
|
+
let spinner;
|
|
57
|
+
if (!options.json) {
|
|
58
|
+
const { default: ora } = await import('ora');
|
|
59
|
+
spinner = ora();
|
|
60
|
+
}
|
|
57
61
|
try {
|
|
58
62
|
if (!name) {
|
|
59
63
|
throw new Error('Missing required argument <name>');
|
|
@@ -75,12 +79,16 @@ export async function newChangeCommand(name, options) {
|
|
|
75
79
|
if (options.schema) {
|
|
76
80
|
validateSchemaExists(options.schema, projectRoot);
|
|
77
81
|
}
|
|
78
|
-
|
|
82
|
+
// Resolve the schema once (--schema, then config, then the root default)
|
|
83
|
+
// and hand it to createChange, so the progress line names the schema the
|
|
84
|
+
// change gets and config warnings print only once. An empty --schema is
|
|
85
|
+
// absent, matching createChange's `if (options.schema)`.
|
|
86
|
+
const resolvedSchema = options.schema || (readProjectConfig(projectRoot)?.schema ?? root.defaultSchema);
|
|
79
87
|
if (spinner) {
|
|
80
88
|
spinner.start(`Creating change '${name}' with schema '${resolvedSchema}'...`);
|
|
81
89
|
}
|
|
82
90
|
const result = await createChange(projectRoot, name, {
|
|
83
|
-
schema:
|
|
91
|
+
schema: resolvedSchema,
|
|
84
92
|
defaultSchema: root.defaultSchema,
|
|
85
93
|
changesDir: root.changesDir,
|
|
86
94
|
metadata: {
|