@fission-ai/openspec 1.13.0 → 1.13.1
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 +1 -1
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +33 -14
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +17 -1
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +13 -3
- package/dist/core/legacy-cleanup.d.ts +4 -0
- package/dist/core/legacy-cleanup.js +180 -12
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +44 -3
- package/dist/core/parsers/requirement-blocks.js +90 -15
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/specs-apply.js +82 -11
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +50 -15
- package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
- package/dist/core/templates/workflows/continue-change.js +14 -2
- package/dist/core/templates/workflows/explore.js +32 -14
- package/dist/core/templates/workflows/ff-change.js +19 -3
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +67 -37
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +22 -5
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +52 -25
- package/dist/core/templates/workflows/verify-change.js +12 -3
- package/dist/core/update.d.ts +12 -0
- package/dist/core/update.js +58 -3
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +154 -40
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +4 -4
- package/schemas/spec-driven/schema.yaml +10 -3
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
|
@@ -6,13 +6,14 @@ import { ChangeParser } from '../parsers/change-parser.js';
|
|
|
6
6
|
import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
|
|
7
7
|
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
|
|
8
8
|
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, findMissingCurrentScenarios, } from '../parsers/requirement-blocks.js';
|
|
9
|
-
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, } from '../parsers/requirement-text.js';
|
|
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 { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
|
|
12
|
+
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
|
|
13
13
|
import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
|
|
14
14
|
import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
|
|
15
15
|
import { findTaskNumberingIssues } from './task-numbering.js';
|
|
16
|
+
import { findMissingTaskCheckboxIssues } from './task-checkboxes.js';
|
|
16
17
|
import { findPurposePlaceholderIssue } from './purpose-placeholder.js';
|
|
17
18
|
import { getPackageSchemasDir, getSchemaDir } from '../artifact-graph/index.js';
|
|
18
19
|
export class Validator {
|
|
@@ -174,6 +175,34 @@ export class Validator {
|
|
|
174
175
|
: `Header "### ${stray.header}" in ${stray.section} is not a "### Requirement:" header and is ignored by validation. Use "### Requirement: ${stray.header}" if it should be validated as a requirement.`,
|
|
175
176
|
});
|
|
176
177
|
}
|
|
178
|
+
// A FROM:/TO: line that never formed a pair. Reported here so the author
|
|
179
|
+
// learns at authoring time, rather than having archive either skip the
|
|
180
|
+
// rename or apply it to a requirement they never named.
|
|
181
|
+
for (const unpaired of plan.unpairedRenames) {
|
|
182
|
+
const missing = unpaired.side === 'FROM' ? 'TO' : 'FROM';
|
|
183
|
+
issues.push({
|
|
184
|
+
level: 'ERROR',
|
|
185
|
+
path: entryPath,
|
|
186
|
+
line: unpaired.line,
|
|
187
|
+
message: `RENAMED ${unpaired.side}: "${unpaired.name}" has no matching ${missing}: line. Write each rename as a FROM: line followed immediately by its TO: line.`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
// A well-formed requirement written outside every delta section. The
|
|
191
|
+
// reader only looks inside the four delta sections, so this block is
|
|
192
|
+
// ignored - reported as a WARNING rather than an ERROR because a
|
|
193
|
+
// handful of pre-format archived changes still carry this shape, and
|
|
194
|
+
// the fix is to move the block, not to reject the change outright.
|
|
195
|
+
for (const orphan of plan.orphanedRequirements) {
|
|
196
|
+
const where = orphan.section
|
|
197
|
+
? `under "## ${orphan.section}"`
|
|
198
|
+
: 'above the first "## " section';
|
|
199
|
+
issues.push({
|
|
200
|
+
level: 'WARNING',
|
|
201
|
+
path: entryPath,
|
|
202
|
+
line: orphan.line,
|
|
203
|
+
message: `Requirement "${orphan.name}" is ${where}, which is not a delta section, so it is ignored. Move it under "## ADDED Requirements", "## MODIFIED Requirements", "## REMOVED Requirements", or "## RENAMED Requirements".`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
177
206
|
const sectionNames = [];
|
|
178
207
|
if (plan.sectionPresence.added)
|
|
179
208
|
sectionNames.push('## ADDED Requirements');
|
|
@@ -225,7 +254,7 @@ export class Validator {
|
|
|
225
254
|
}
|
|
226
255
|
const scenarioCount = this.countScenarios(block.raw);
|
|
227
256
|
if (scenarioCount < 1) {
|
|
228
|
-
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario` });
|
|
257
|
+
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
|
|
229
258
|
}
|
|
230
259
|
}
|
|
231
260
|
// Validate MODIFIED
|
|
@@ -257,7 +286,7 @@ export class Validator {
|
|
|
257
286
|
}
|
|
258
287
|
const scenarioCount = this.countScenarios(block.raw);
|
|
259
288
|
if (scenarioCount < 1) {
|
|
260
|
-
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario` });
|
|
289
|
+
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
|
|
261
290
|
}
|
|
262
291
|
}
|
|
263
292
|
// Run archive's scenario-loss check here too, so the change fails at
|
|
@@ -358,6 +387,17 @@ export class Validator {
|
|
|
358
387
|
throw error;
|
|
359
388
|
}
|
|
360
389
|
}
|
|
390
|
+
// The same drop happens to delta sections in any other file the merge
|
|
391
|
+
// path does not read (specs/<capability>.md, a note beside spec.md),
|
|
392
|
+
// while the artifact graph's specs/**/*.md glob counts it as written.
|
|
393
|
+
const unreadDeltaFiles = await findUnreadDeltaFiles(specsDir);
|
|
394
|
+
for (const file of unreadDeltaFiles) {
|
|
395
|
+
issues.push({
|
|
396
|
+
level: 'ERROR',
|
|
397
|
+
path: file.path,
|
|
398
|
+
message: `Delta spec found at specs/${file.path}. Delta specs must be a spec.md inside a capability folder — this file is ignored when the change is applied or archived. Move its requirements into specs/${file.expected}.`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
361
401
|
for (const { path: specPath, sections } of emptySectionSpecs) {
|
|
362
402
|
issues.push({
|
|
363
403
|
level: 'ERROR',
|
|
@@ -397,10 +437,10 @@ export class Validator {
|
|
|
397
437
|
if (skipSpecs && specsDirHasFiles) {
|
|
398
438
|
issues.push({ level: 'ERROR', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT });
|
|
399
439
|
}
|
|
400
|
-
// The root-level
|
|
401
|
-
// deltas found" on top would contradict
|
|
402
|
-
// the
|
|
403
|
-
if (totalDeltas === 0 && !hasRootLevelSpec) {
|
|
440
|
+
// The root-level and unread-file errors already name the file and the fix;
|
|
441
|
+
// adding "No deltas found" on top would contradict them, since the deltas
|
|
442
|
+
// are sitting in the files just reported.
|
|
443
|
+
if (totalDeltas === 0 && !hasRootLevelSpec && unreadDeltaFiles.length === 0) {
|
|
404
444
|
if (skipSpecs && !specsDirHasFiles) {
|
|
405
445
|
issues.push({ level: 'INFO', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_ACCEPTED });
|
|
406
446
|
}
|
|
@@ -409,56 +449,120 @@ export class Validator {
|
|
|
409
449
|
}
|
|
410
450
|
}
|
|
411
451
|
if (options.projectRoot) {
|
|
412
|
-
issues.push(...await this.
|
|
452
|
+
issues.push(...await this.collectTaskFileIssues(changeDir, options.projectRoot));
|
|
413
453
|
}
|
|
414
454
|
return this.createReport(issues);
|
|
415
455
|
}
|
|
416
|
-
|
|
456
|
+
/**
|
|
457
|
+
* Lints the change's task files.
|
|
458
|
+
*
|
|
459
|
+
* Two checks with deliberately different reach. Checkbox formatting is read
|
|
460
|
+
* from whatever the change's own schema declares as its tracked task output,
|
|
461
|
+
* because every schema's progress, apply and archive behavior is computed by
|
|
462
|
+
* counting checkboxes in exactly those files. Numbering stays scoped to the
|
|
463
|
+
* built-in `spec-driven` schema, whose template is the one that numbers tasks
|
|
464
|
+
* in the first place.
|
|
465
|
+
*
|
|
466
|
+
* The checkbox check never falls back to a bare top-level `tasks.md`: a file
|
|
467
|
+
* no artifact declares is not a tracked task list, and warning about its
|
|
468
|
+
* formatting would be a guess about a file the tool does not read.
|
|
469
|
+
*/
|
|
470
|
+
async collectTaskFileIssues(changeDir, projectRoot) {
|
|
471
|
+
let trackedFiles;
|
|
417
472
|
try {
|
|
418
|
-
|
|
419
|
-
const schemaDir = getSchemaDir(schemaName, projectRoot);
|
|
420
|
-
const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven');
|
|
421
|
-
if (schemaName !== 'spec-driven' ||
|
|
422
|
-
schemaDir === null ||
|
|
423
|
-
FileSystemUtils.canonicalizeExistingPath(schemaDir) !==
|
|
424
|
-
FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir)) {
|
|
425
|
-
return [];
|
|
426
|
-
}
|
|
473
|
+
trackedFiles = resolveTaskFilesForChange(changeDir, projectRoot);
|
|
427
474
|
}
|
|
428
475
|
catch {
|
|
429
476
|
return [];
|
|
430
477
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
478
|
+
const files = trackedFiles.length > 0 ? trackedFiles : [path.join(changeDir, 'tasks.md')];
|
|
479
|
+
const { documents, unreadable } = await this.readTaskDocuments(changeDir, files);
|
|
480
|
+
const toWarning = (issue) => ({
|
|
481
|
+
level: 'WARNING',
|
|
482
|
+
path: issue.path,
|
|
483
|
+
line: issue.line,
|
|
484
|
+
message: issue.message,
|
|
485
|
+
});
|
|
486
|
+
const issues = [];
|
|
487
|
+
// "No file here holds a checkbox" is a claim about the whole tracked set, so
|
|
488
|
+
// a file that exists but could not be read withdraws it: the checkboxes may
|
|
489
|
+
// be in exactly that file. `validate --archived` is the surface that reports
|
|
490
|
+
// an unreadable task file loudly (#205); this one must not guess from it.
|
|
491
|
+
if (trackedFiles.length > 0 && unreadable === 0) {
|
|
492
|
+
issues.push(...findMissingTaskCheckboxIssues(documents).map(toWarning));
|
|
437
493
|
}
|
|
438
|
-
if (
|
|
439
|
-
|
|
494
|
+
if (this.usesBuiltInSpecDrivenSchema(changeDir, projectRoot)) {
|
|
495
|
+
issues.push(...findTaskNumberingIssues(documents).map(toWarning));
|
|
440
496
|
}
|
|
497
|
+
return issues;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Reads task files into change-relative documents, counting the ones that
|
|
501
|
+
* exist but could not be read. A file that is simply absent is not counted:
|
|
502
|
+
* the resolver only returns files it found, so the remaining read failures
|
|
503
|
+
* are permissions and I/O, and a check that reasons over the whole set needs
|
|
504
|
+
* to know its evidence was incomplete.
|
|
505
|
+
*/
|
|
506
|
+
async readTaskDocuments(changeDir, files) {
|
|
441
507
|
const documents = [];
|
|
442
|
-
|
|
508
|
+
let unreadable = 0;
|
|
509
|
+
for (const file of files) {
|
|
443
510
|
let content;
|
|
444
511
|
try {
|
|
445
|
-
content = await fs.readFile(
|
|
512
|
+
content = await fs.readFile(file, 'utf-8');
|
|
446
513
|
}
|
|
447
|
-
catch {
|
|
514
|
+
catch (error) {
|
|
515
|
+
if (error?.code !== 'ENOENT')
|
|
516
|
+
unreadable++;
|
|
448
517
|
continue;
|
|
449
518
|
}
|
|
450
|
-
documents.push({
|
|
451
|
-
path: FileSystemUtils.toPosixPath(path.relative(changeDir, taskFile)),
|
|
452
|
-
content,
|
|
453
|
-
});
|
|
519
|
+
documents.push({ path: this.taskDocumentPath(changeDir, file), content });
|
|
454
520
|
}
|
|
455
521
|
documents.sort((left, right) => left.path.localeCompare(right.path));
|
|
456
|
-
return
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
522
|
+
return { documents, unreadable };
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Names a task file relative to its change, POSIX-separated.
|
|
526
|
+
*
|
|
527
|
+
* Both sides are canonicalized first. `resolveArtifactOutputs` hands back real
|
|
528
|
+
* paths, while `changeDir` carries whatever spelling the caller resolved, and
|
|
529
|
+
* the two can differ without being apart: on Windows a short 8.3 alias
|
|
530
|
+
* (`RUNNER~1`) against its expanded form turned `tasks.md` into a
|
|
531
|
+
* `../../../..`-prefixed absolute path in the report, and a symlinked project
|
|
532
|
+
* directory does the same elsewhere. Canonicalizing recovers the real
|
|
533
|
+
* relationship. The Windows CI job is the regression guard — the mismatch
|
|
534
|
+
* cannot be staged on POSIX, where the spawned CLI's `process.cwd()` is
|
|
535
|
+
* already physical.
|
|
536
|
+
*
|
|
537
|
+
* A path that still escapes would be a resolver bug rather than a spelling
|
|
538
|
+
* difference, but the report must never leak an absolute filesystem path, so
|
|
539
|
+
* the file name stands in.
|
|
540
|
+
*/
|
|
541
|
+
taskDocumentPath(changeDir, file) {
|
|
542
|
+
const relative = path.relative(FileSystemUtils.canonicalizeExistingPath(changeDir), FileSystemUtils.canonicalizeExistingPath(file));
|
|
543
|
+
const escapes = relative === '' || relative.startsWith('..') || path.isAbsolute(relative);
|
|
544
|
+
return escapes ? path.basename(file) : FileSystemUtils.toPosixPath(relative);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* True when the change resolves to the package's own `spec-driven` schema. A
|
|
548
|
+
* project schema that merely reuses the name is not it, so checks written
|
|
549
|
+
* against the built-in template never fire on someone else's task format.
|
|
550
|
+
*/
|
|
551
|
+
usesBuiltInSpecDrivenSchema(changeDir, projectRoot) {
|
|
552
|
+
try {
|
|
553
|
+
const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot).replace(/\.ya?ml$/, '');
|
|
554
|
+
if (schemaName !== 'spec-driven')
|
|
555
|
+
return false;
|
|
556
|
+
const schemaDir = getSchemaDir(schemaName, projectRoot);
|
|
557
|
+
if (schemaDir === null)
|
|
558
|
+
return false;
|
|
559
|
+
const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven');
|
|
560
|
+
return (FileSystemUtils.canonicalizeExistingPath(schemaDir) ===
|
|
561
|
+
FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir));
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
462
566
|
}
|
|
463
567
|
/**
|
|
464
568
|
* Report MODIFIED requirements whose block omits a scenario the main spec
|
|
@@ -797,6 +901,16 @@ export class Validator {
|
|
|
797
901
|
// example is not a real scenario. Drop the header line (index 0).
|
|
798
902
|
return countScenariosShared(blockRaw.split('\n').slice(1));
|
|
799
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* Why a requirement with a visible scenario header still has no scenario:
|
|
906
|
+
* the header has no body, and the spec path archive validates against does
|
|
907
|
+
* not count it. Empty when the block has no bare scenario header.
|
|
908
|
+
*/
|
|
909
|
+
emptyScenarioHint(blockRaw) {
|
|
910
|
+
if (countEmptyScenarios(blockRaw.split('\n').slice(1)) === 0)
|
|
911
|
+
return '';
|
|
912
|
+
return ' (a scenario header with no body under it does not count; add its steps, e.g. "- **WHEN** ..." and "- **THEN** ...")';
|
|
913
|
+
}
|
|
800
914
|
formatSectionList(sections) {
|
|
801
915
|
if (sections.length === 0)
|
|
802
916
|
return '';
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The registry to ask
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* The registry to ask. A configured registry is honored only over TLS: a
|
|
3
|
+
* cleartext hop would aim the request at a link-local or internal address and
|
|
4
|
+
* let anyone on the path choose the "newer version" answer.
|
|
5
|
+
*
|
|
6
|
+
* A rejected registry disables the check rather than falling back to public
|
|
7
|
+
* npm. Falling back would send a request an organization on an internal mirror
|
|
8
|
+
* deliberately avoided, and would then report a version resolved against a
|
|
9
|
+
* registry the eventual `npm install -g` does not use.
|
|
8
10
|
*/
|
|
9
|
-
export declare function registryUrl(): string;
|
|
11
|
+
export declare function registryUrl(): string | null;
|
|
10
12
|
/**
|
|
11
13
|
* Compares two semver-ish versions. Returns 1 when a > b, -1 when a < b, 0
|
|
12
14
|
* otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
|
|
@@ -92,6 +94,11 @@ export declare function buildUpgradeCommandLines(installDir: string | null, proj
|
|
|
92
94
|
* that may not be the one on PATH, a project dependency belongs to that
|
|
93
95
|
* project's package manager, an npx/dlx cache has nothing to upgrade, and a
|
|
94
96
|
* source checkout is not an install at all.
|
|
97
|
+
*
|
|
98
|
+
* A non-default registry is also disqualifying. `npm install -g` resolves
|
|
99
|
+
* against whatever `npm_config_registry` says, which npm may have read from a
|
|
100
|
+
* cloned repository's .npmrc — so such a registry may still inform the check,
|
|
101
|
+
* but must never be the thing that offers to install a global package.
|
|
95
102
|
*/
|
|
96
103
|
export declare function canSelfUpgrade(installDir: string | null, projectPath: string): boolean;
|
|
97
104
|
/**
|
|
@@ -5,7 +5,8 @@ import path from 'path';
|
|
|
5
5
|
import { createRequire } from 'module';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { isCiEnvironment } from '../utils/ci.js';
|
|
8
|
-
import {
|
|
8
|
+
import { isTelemetryOptedOutByEnv } from '../telemetry/opt-out.js';
|
|
9
|
+
import { getGlobalConfig, isGlobalConfigUnreadable } from './global-config.js';
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
|
|
11
12
|
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
@@ -29,9 +30,7 @@ const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\
|
|
|
29
30
|
function isCheckEnabled() {
|
|
30
31
|
if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined)
|
|
31
32
|
return false;
|
|
32
|
-
if (
|
|
33
|
-
return false;
|
|
34
|
-
if (process.env.OPENSPEC_TELEMETRY === '0')
|
|
33
|
+
if (isTelemetryOptedOutByEnv())
|
|
35
34
|
return false;
|
|
36
35
|
if (isCiEnvironment())
|
|
37
36
|
return false;
|
|
@@ -40,21 +39,53 @@ function isCheckEnabled() {
|
|
|
40
39
|
// Same config opt-out as telemetry (env remains the hard override above).
|
|
41
40
|
if (getGlobalConfig().telemetry?.enabled === false)
|
|
42
41
|
return false;
|
|
42
|
+
// A config that cannot be parsed may be hiding that opt-out.
|
|
43
|
+
if (isGlobalConfigUnreadable())
|
|
44
|
+
return false;
|
|
43
45
|
return true;
|
|
44
46
|
}
|
|
45
47
|
/**
|
|
46
|
-
* The registry
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* request is a flow worth avoiding for a convenience this small, and a project
|
|
50
|
-
* file would travel with a cloned repository. Anyone on a private mirror can
|
|
51
|
-
* export `npm_config_registry`, or turn the check off entirely.
|
|
48
|
+
* The configured registry, if npm exported one. Not trustworthy: npm exports
|
|
49
|
+
* every config source it read, including a `registry=` line in a
|
|
50
|
+
* repository-local .npmrc that travels with a clone.
|
|
52
51
|
*/
|
|
53
|
-
|
|
52
|
+
function configuredRegistry() {
|
|
54
53
|
const configured = process.env.npm_config_registry?.trim();
|
|
55
|
-
|
|
54
|
+
return configured ? configured : undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The registry to ask. A configured registry is honored only over TLS: a
|
|
58
|
+
* cleartext hop would aim the request at a link-local or internal address and
|
|
59
|
+
* let anyone on the path choose the "newer version" answer.
|
|
60
|
+
*
|
|
61
|
+
* A rejected registry disables the check rather than falling back to public
|
|
62
|
+
* npm. Falling back would send a request an organization on an internal mirror
|
|
63
|
+
* deliberately avoided, and would then report a version resolved against a
|
|
64
|
+
* registry the eventual `npm install -g` does not use.
|
|
65
|
+
*/
|
|
66
|
+
export function registryUrl() {
|
|
67
|
+
const configured = configuredRegistry();
|
|
68
|
+
if (configured && !/^https:\/\//i.test(configured))
|
|
69
|
+
return null;
|
|
70
|
+
const base = configured ?? DEFAULT_REGISTRY;
|
|
56
71
|
return `${base.replace(/\/+$/, '')}/${PACKAGE_NAME}/latest`;
|
|
57
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* True when the check is talking to the public npm registry. Read from the raw
|
|
75
|
+
* env var, not from registryUrl(), so a registry that was rejected above still
|
|
76
|
+
* disqualifies a self-upgrade instead of looking like the default.
|
|
77
|
+
*/
|
|
78
|
+
function isDefaultRegistry() {
|
|
79
|
+
const configured = configuredRegistry();
|
|
80
|
+
if (!configured)
|
|
81
|
+
return true;
|
|
82
|
+
try {
|
|
83
|
+
return new URL(configured).origin === new URL(DEFAULT_REGISTRY).origin;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
58
89
|
/**
|
|
59
90
|
* Compares two prerelease tags per SemVer: dot-separated identifiers compared
|
|
60
91
|
* one by one, numeric identifiers numerically (so beta.10 > beta.2), numeric
|
|
@@ -141,7 +172,12 @@ function fetchLatestVersion() {
|
|
|
141
172
|
};
|
|
142
173
|
let url;
|
|
143
174
|
try {
|
|
144
|
-
|
|
175
|
+
const resolved = registryUrl();
|
|
176
|
+
if (resolved === null) {
|
|
177
|
+
resolve(null);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
url = new URL(resolved);
|
|
145
181
|
}
|
|
146
182
|
catch {
|
|
147
183
|
resolve(null);
|
|
@@ -169,10 +205,13 @@ function fetchLatestVersion() {
|
|
|
169
205
|
redirectsLeft -= 1;
|
|
170
206
|
try {
|
|
171
207
|
const next = new URL(location, target);
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
208
|
+
// Cross-host is allowed - that is the whole point of following a
|
|
209
|
+
// redirect for a mirror or corporate front-end - but never off
|
|
210
|
+
// TLS: a MITM on a cleartext hop would control the "newer
|
|
211
|
+
// version" answer, and the reply cannot install anything on its
|
|
212
|
+
// own (canSelfUpgrade refuses a non-default registry, and the
|
|
213
|
+
// version is re-validated against a strict pattern).
|
|
214
|
+
if (next.protocol === 'https:') {
|
|
176
215
|
send(next);
|
|
177
216
|
return;
|
|
178
217
|
}
|
|
@@ -492,10 +531,17 @@ function loadSpawn() {
|
|
|
492
531
|
* that may not be the one on PATH, a project dependency belongs to that
|
|
493
532
|
* project's package manager, an npx/dlx cache has nothing to upgrade, and a
|
|
494
533
|
* source checkout is not an install at all.
|
|
534
|
+
*
|
|
535
|
+
* A non-default registry is also disqualifying. `npm install -g` resolves
|
|
536
|
+
* against whatever `npm_config_registry` says, which npm may have read from a
|
|
537
|
+
* cloned repository's .npmrc — so such a registry may still inform the check,
|
|
538
|
+
* but must never be the thing that offers to install a global package.
|
|
495
539
|
*/
|
|
496
540
|
export function canSelfUpgrade(installDir, projectPath) {
|
|
497
541
|
if (!installDir)
|
|
498
542
|
return false;
|
|
543
|
+
if (!isDefaultRegistry())
|
|
544
|
+
return false;
|
|
499
545
|
if (isEphemeralRunnerInstall(installDir))
|
|
500
546
|
return false;
|
|
501
547
|
// Both anchors matter: `openspec update ../other` from a project that owns
|
package/dist/core/worksets.js
CHANGED
|
@@ -175,7 +175,7 @@ export function worksetNotFoundError(name, state) {
|
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
177
|
export function withWorkset(state, workset) {
|
|
178
|
-
if (state.worksets
|
|
178
|
+
if (Object.prototype.hasOwnProperty.call(state.worksets, workset.name)) {
|
|
179
179
|
throw new StoreError(`Workset '${workset.name}' already exists.`, 'workset_exists', {
|
|
180
180
|
target: 'workset.name',
|
|
181
181
|
fix: `Choose another name, or remove it first: openspec workset remove ${workset.name}`,
|
|
@@ -193,7 +193,7 @@ export function withWorkset(state, workset) {
|
|
|
193
193
|
};
|
|
194
194
|
}
|
|
195
195
|
export function withoutWorkset(state, name) {
|
|
196
|
-
if (state.worksets
|
|
196
|
+
if (!Object.prototype.hasOwnProperty.call(state.worksets, name)) {
|
|
197
197
|
throw worksetNotFoundError(name, state);
|
|
198
198
|
}
|
|
199
199
|
const remaining = { ...state.worksets };
|
|
@@ -226,8 +226,10 @@ export function listWorksets(state) {
|
|
|
226
226
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
227
227
|
}
|
|
228
228
|
export function getWorkset(state, name) {
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
if (!Object.prototype.hasOwnProperty.call(state.worksets, name)) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
return toWorkset(name, state.worksets[name]);
|
|
231
233
|
}
|
|
232
234
|
/**
|
|
233
235
|
* The generated .code-workspace content: members in saved order with
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
interface Choice {
|
|
2
2
|
name: string;
|
|
3
3
|
value: string;
|
|
4
|
+
/**
|
|
5
|
+
* Extra terms the search box matches, for choices users look for by a word
|
|
6
|
+
* the name does not spell (see #653). Never rendered.
|
|
7
|
+
*/
|
|
8
|
+
searchAliases?: string[];
|
|
4
9
|
description?: string;
|
|
5
10
|
configured?: boolean;
|
|
6
11
|
detected?: boolean;
|
|
@@ -12,6 +17,8 @@ interface Config {
|
|
|
12
17
|
choices: Choice[];
|
|
13
18
|
pageSize?: number;
|
|
14
19
|
validate?: (selected: string[]) => boolean | string;
|
|
20
|
+
/** Shown when a search matches nothing, so the list is not a dead end. */
|
|
21
|
+
emptyHint?: string;
|
|
15
22
|
}
|
|
16
23
|
/**
|
|
17
24
|
* A searchable multi-select prompt with visible search box,
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
/**
|
|
3
|
+
* True when every character is printable, so the text can go in the search box.
|
|
4
|
+
* `\u007f` is DEL, which sorts above the printable range.
|
|
5
|
+
*/
|
|
6
|
+
function isPrintable(text) {
|
|
7
|
+
return text.length > 0 && [...text].every((char) => char >= ' ' && char !== '\u007f');
|
|
8
|
+
}
|
|
2
9
|
/**
|
|
3
10
|
* Create the searchable multi-select prompt.
|
|
4
11
|
* Uses dynamic import to prevent pre-commit hook hangs (see #367).
|
|
@@ -6,7 +13,7 @@ import chalk from 'chalk';
|
|
|
6
13
|
async function createSearchableMultiSelect() {
|
|
7
14
|
const { createPrompt, useState, useKeypress, useMemo, usePrefix, isEnterKey, isBackspaceKey, isUpKey, isDownKey, } = await import('@inquirer/core');
|
|
8
15
|
return createPrompt((config, done) => {
|
|
9
|
-
const { message, choices, pageSize = 15, validate } = config;
|
|
16
|
+
const { message, choices, pageSize = 15, validate, emptyHint } = config;
|
|
10
17
|
const [searchText, setSearchText] = useState('');
|
|
11
18
|
const [selectedValues, setSelectedValues] = useState(() => choices.filter(c => c.preSelected).map(c => c.value));
|
|
12
19
|
const [cursor, setCursor] = useState(0);
|
|
@@ -17,9 +24,10 @@ async function createSearchableMultiSelect() {
|
|
|
17
24
|
const filteredChoices = useMemo(() => {
|
|
18
25
|
if (!searchText.trim())
|
|
19
26
|
return choices;
|
|
20
|
-
const term = searchText.toLowerCase();
|
|
27
|
+
const term = searchText.trim().toLowerCase();
|
|
21
28
|
return choices.filter((c) => c.name.toLowerCase().includes(term) ||
|
|
22
|
-
c.value.toLowerCase().includes(term)
|
|
29
|
+
c.value.toLowerCase().includes(term) ||
|
|
30
|
+
(c.searchAliases ?? []).some((alias) => alias.toLowerCase().includes(term)));
|
|
23
31
|
}, [searchText, choices]);
|
|
24
32
|
const selectedSet = useMemo(() => new Set(selectedValues), [selectedValues]);
|
|
25
33
|
const choiceMap = useMemo(() => new Map(choices.map((c) => [c.value, c])), [choices]);
|
|
@@ -72,9 +80,28 @@ async function createSearchableMultiSelect() {
|
|
|
72
80
|
setCursor(Math.min(filteredChoices.length - 1, cursor + 1));
|
|
73
81
|
return;
|
|
74
82
|
}
|
|
75
|
-
// Character input - handle printable characters
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
// Character input - handle printable characters.
|
|
84
|
+
// readline reports punctuation (`.`, `-`, `/`) only in `sequence`, leaving
|
|
85
|
+
// `name` undefined, so keying off `name` alone silently dropped every
|
|
86
|
+
// non-alphanumeric character the user typed.
|
|
87
|
+
// `@inquirer/core` types only `name` and `ctrl`; readline emits more.
|
|
88
|
+
const event = key;
|
|
89
|
+
if (event.ctrl || event.meta)
|
|
90
|
+
return;
|
|
91
|
+
// Requiring every character to be printable drops escape sequences
|
|
92
|
+
// (arrows, function keys), tab, escape and delete. readline splits a
|
|
93
|
+
// paste into one key per character, so a pasted space still toggles.
|
|
94
|
+
const typed = typeof event.sequence === 'string' && isPrintable(event.sequence)
|
|
95
|
+
? event.sequence
|
|
96
|
+
: // Only the sequence may be multi-character: readline `name`s such as
|
|
97
|
+
// 'tab' and 'escape' are printable strings but not typed input.
|
|
98
|
+
typeof event.name === 'string' &&
|
|
99
|
+
event.name.length === 1 &&
|
|
100
|
+
isPrintable(event.name)
|
|
101
|
+
? event.name
|
|
102
|
+
: undefined;
|
|
103
|
+
if (typed) {
|
|
104
|
+
setSearchText(searchText + typed);
|
|
78
105
|
setCursor(0);
|
|
79
106
|
}
|
|
80
107
|
});
|
|
@@ -102,6 +129,8 @@ async function createSearchableMultiSelect() {
|
|
|
102
129
|
// List
|
|
103
130
|
if (filteredChoices.length === 0) {
|
|
104
131
|
lines.push(chalk.yellow(' No matches'));
|
|
132
|
+
if (emptyHint)
|
|
133
|
+
lines.push(chalk.dim(` ${emptyHint}`));
|
|
105
134
|
}
|
|
106
135
|
else {
|
|
107
136
|
// Calculate pagination
|
package/dist/telemetry/config.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { promises as fs } from 'fs';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import os from 'os';
|
|
8
|
-
import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, } from '../core/global-config.js';
|
|
8
|
+
import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, isConfigRootObject, unreadableGlobalConfigMessage, } from '../core/global-config.js';
|
|
9
9
|
// Constants
|
|
10
10
|
export const CONFIG_DIR_NAME = GLOBAL_CONFIG_DIR_NAME;
|
|
11
11
|
export const CONFIG_FILE_NAME = GLOBAL_CONFIG_FILE_NAME;
|
|
@@ -18,7 +18,14 @@ function getLegacyConfigPath() {
|
|
|
18
18
|
async function readConfigFile(configPath) {
|
|
19
19
|
try {
|
|
20
20
|
const content = await fs.readFile(configPath, 'utf-8');
|
|
21
|
-
|
|
21
|
+
const parsed = JSON.parse(content);
|
|
22
|
+
// Valid JSON that is not an object carries no settings to merge into, and
|
|
23
|
+
// spreading it would replace the file (a string even spreads to numeric
|
|
24
|
+
// character keys). Same predicate the rest of the CLI refuses to save over.
|
|
25
|
+
if (!isConfigRootObject(parsed)) {
|
|
26
|
+
return { status: 'invalid', config: {} };
|
|
27
|
+
}
|
|
28
|
+
return { status: 'ok', config: parsed };
|
|
22
29
|
}
|
|
23
30
|
catch (error) {
|
|
24
31
|
if (error.code === 'ENOENT') {
|
|
@@ -108,6 +115,11 @@ export async function readConfig() {
|
|
|
108
115
|
*/
|
|
109
116
|
export async function writeConfig(updates) {
|
|
110
117
|
const configPath = getConfigPath();
|
|
118
|
+
// Never write over a file that did not parse: the merge below would start
|
|
119
|
+
// from an empty object and replace every setting in it with these updates.
|
|
120
|
+
if ((await readConfigFile(configPath)).status === 'invalid') {
|
|
121
|
+
throw new Error(unreadableGlobalConfigMessage(configPath));
|
|
122
|
+
}
|
|
111
123
|
// Read existing config and merge
|
|
112
124
|
const existing = await readConfig();
|
|
113
125
|
const merged = { ...existing, ...updates };
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Check if telemetry is enabled.
|
|
3
3
|
*
|
|
4
4
|
* Precedence (first match wins):
|
|
5
|
-
* 1. OPENSPEC_TELEMETRY
|
|
6
|
-
* 2. DO_NOT_TRACK
|
|
5
|
+
* 1. OPENSPEC_TELEMETRY set to anything but an on-value (1/true/yes/on) → disabled
|
|
6
|
+
* 2. DO_NOT_TRACK set to anything but an off-value (0/false/no/off) → disabled
|
|
7
7
|
* 3. CI set to a truthy/on value → disabled (same rule as version-check)
|
|
8
8
|
* 4. global config telemetry.enabled === false → disabled
|
|
9
|
-
* 5.
|
|
9
|
+
* 5. global config file exists but cannot be parsed → disabled
|
|
10
|
+
* 6. otherwise enabled (unset config means on; opt-out model)
|
|
10
11
|
*
|
|
11
12
|
* Kept synchronous so call sites need not become async. Reads config via
|
|
12
13
|
* sync getGlobalConfig() rather than async getTelemetryConfig().
|