@fission-ai/openspec 1.12.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 +3 -15
- 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 +170 -16
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +8 -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 +33 -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/onboarding-commands.d.ts +11 -1
- package/dist/core/onboarding-commands.js +26 -0
- 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 +164 -43
- 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 +37 -4
- package/dist/core/specs-apply.js +260 -18
- 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 +48 -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 +52 -15
- 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 +34 -0
- package/dist/core/update.js +109 -11
- 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 +24 -5
- 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
package/dist/core/specs-apply.js
CHANGED
|
@@ -22,7 +22,7 @@ function isLexicallyWithin(allowedDirectory, targetPath) {
|
|
|
22
22
|
!relative.startsWith(`..${path.sep}`) &&
|
|
23
23
|
!path.isAbsolute(relative)));
|
|
24
24
|
}
|
|
25
|
-
function resolveTrustedSpecPath(specsRoot, specPath) {
|
|
25
|
+
function resolveTrustedSpecPath(specsRoot, specPath, projectRoot) {
|
|
26
26
|
if (!isLexicallyWithin(specsRoot, specPath)) {
|
|
27
27
|
throw new Error(`Path is outside the allowed directory: ${specPath}`);
|
|
28
28
|
}
|
|
@@ -42,6 +42,14 @@ function resolveTrustedSpecPath(specsRoot, specPath) {
|
|
|
42
42
|
// Freeze their canonical location as the trust root so later swaps are
|
|
43
43
|
// rejected while a nested spec.md link still cannot escape.
|
|
44
44
|
const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath));
|
|
45
|
+
// An external capability link is deliberate and supported (see
|
|
46
|
+
// assertDiscoveredSpecPath), so it is not refused here. What was wrong is
|
|
47
|
+
// that the write was silent: the CLI reported the in-project path while
|
|
48
|
+
// writing somewhere else entirely, so a link swapped underneath a repo
|
|
49
|
+
// left nothing on screen to notice. Name the real destination instead.
|
|
50
|
+
if (projectRoot && !isLexicallyWithin(FileSystemUtils.canonicalizeExistingPath(projectRoot), root)) {
|
|
51
|
+
process.emitWarning(`Capability '${path.basename(path.dirname(specPath))}' links outside the project; writing to ${root}`, 'OpenSpecExternalSpecWrite');
|
|
52
|
+
}
|
|
45
53
|
const file = path.join(root, path.basename(specPath));
|
|
46
54
|
FileSystemUtils.assertPathWithin(root, file);
|
|
47
55
|
return { root, file };
|
|
@@ -69,7 +77,10 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
|
|
|
69
77
|
for (const { id, specFile } of discovered) {
|
|
70
78
|
const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
|
|
71
79
|
const source = resolveTrustedSpecPath(changeSpecsDir, specFile);
|
|
72
|
-
|
|
80
|
+
// Main specs always live at `<project root>/openspec/specs`, so the
|
|
81
|
+
// project root is the grandparent - a linked capability directory may not
|
|
82
|
+
// leave it.
|
|
83
|
+
const target = resolveTrustedSpecPath(mainSpecsDir, targetFile, path.dirname(path.dirname(mainSpecsDir)));
|
|
73
84
|
// Check if target exists
|
|
74
85
|
let exists = false;
|
|
75
86
|
try {
|
|
@@ -110,6 +121,29 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
110
121
|
// Parse deltas from the change spec file
|
|
111
122
|
const plan = parseDeltaSpec(changeContent);
|
|
112
123
|
const specName = update.id;
|
|
124
|
+
// A FROM:/TO: line that never formed a pair means the RENAMED section does not
|
|
125
|
+
// say what the author meant. Refuse rather than apply the pairing the reader
|
|
126
|
+
// happened to form: with interleaved lines that pairing renames a requirement
|
|
127
|
+
// the delta never named, under a name written for a different one.
|
|
128
|
+
if (plan.unpairedRenames.length > 0) {
|
|
129
|
+
const first = plan.unpairedRenames[0];
|
|
130
|
+
const missing = first.side === 'FROM' ? 'TO' : 'FROM';
|
|
131
|
+
throw new Error(`${specName} validation failed - RENAMED entry on line ${first.line} has no matching ${missing}: ` +
|
|
132
|
+
`for header "### Requirement: ${first.name}". ` +
|
|
133
|
+
`Write each rename as a FROM: line followed immediately by its TO: line.`);
|
|
134
|
+
}
|
|
135
|
+
// A well-formed requirement written outside every delta section is not
|
|
136
|
+
// applied. Say so here as well as in validate: archive is the last point at
|
|
137
|
+
// which the author can still notice, and the block reads exactly like one
|
|
138
|
+
// that would have applied.
|
|
139
|
+
for (const orphan of plan.orphanedRequirements) {
|
|
140
|
+
const where = orphan.section
|
|
141
|
+
? `under "## ${orphan.section}"`
|
|
142
|
+
: 'above the first "## " section';
|
|
143
|
+
warn(`${specName} - requirement "${orphan.name}" (line ${orphan.line}) is ${where}, ` +
|
|
144
|
+
`which is not a delta section, so it was not applied. ` +
|
|
145
|
+
`Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`);
|
|
146
|
+
}
|
|
113
147
|
// Pre-validate duplicates within sections
|
|
114
148
|
const addedNames = new Set();
|
|
115
149
|
for (const add of plan.added) {
|
|
@@ -287,6 +321,13 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
287
321
|
if (nameToBlock.has(to)) {
|
|
288
322
|
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
|
|
289
323
|
}
|
|
324
|
+
// A target that differs from another requirement only in case or interior
|
|
325
|
+
// whitespace would leave two copies of one requirement. The source itself
|
|
326
|
+
// is exempt, so a case-only rename of a requirement stays allowed.
|
|
327
|
+
const targetNearMiss = [...nameToBlock.keys()].find((k) => k !== from && foldRequirementName(k) === foldRequirementName(to));
|
|
328
|
+
if (targetNearMiss !== undefined) {
|
|
329
|
+
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - "### Requirement: ${nameToBlock.get(targetNearMiss).name}" already exists and differs only in case or spacing; choose a distinct name`);
|
|
330
|
+
}
|
|
290
331
|
const block = nameToBlock.get(from);
|
|
291
332
|
const newHeader = `### Requirement: ${to}`;
|
|
292
333
|
const rawLines = block.raw.split('\n');
|
|
@@ -369,6 +410,15 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
369
410
|
}
|
|
370
411
|
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
|
|
371
412
|
}
|
|
413
|
+
// A name that differs from an existing requirement only in case or
|
|
414
|
+
// interior whitespace is that requirement written again: adding it would
|
|
415
|
+
// leave two contradicting copies in the spec. Like the exact check above,
|
|
416
|
+
// this compares against the spec as it stands after the earlier operations,
|
|
417
|
+
// so a variant of a requirement this delta removed or renamed away is fine.
|
|
418
|
+
const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
|
|
419
|
+
if (nearMiss !== undefined) {
|
|
420
|
+
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - "### Requirement: ${nameToBlock.get(nearMiss).name}" already exists and differs only in case or spacing; use MODIFIED with that exact header to change it, or choose a distinct name`);
|
|
421
|
+
}
|
|
372
422
|
nameToBlock.set(key, add);
|
|
373
423
|
addedApplied++;
|
|
374
424
|
}
|
|
@@ -422,11 +472,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
422
472
|
// glued the heading to the Purpose paragraph and the first requirement, so
|
|
423
473
|
// every archive rewrote a well-formatted spec into that shape. Separate
|
|
424
474
|
// non-empty slices with one blank line instead.
|
|
425
|
-
const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
|
|
475
|
+
const rebuilt = collapseBlankRunsOutsideFences([parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
|
|
426
476
|
.filter((s) => s !== '')
|
|
427
|
-
.join('\n\n')
|
|
428
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
429
|
-
.trimEnd() + '\n';
|
|
477
|
+
.join('\n\n')).trimEnd() + '\n';
|
|
430
478
|
return {
|
|
431
479
|
rebuilt,
|
|
432
480
|
counts: {
|
|
@@ -473,6 +521,76 @@ function firstForeignTail(raw) {
|
|
|
473
521
|
}
|
|
474
522
|
return undefined;
|
|
475
523
|
}
|
|
524
|
+
/**
|
|
525
|
+
* The column a line's content starts at, tabs expanded to a four-column stop.
|
|
526
|
+
* `prefix` is the text that precedes the content: a line's indentation, or a
|
|
527
|
+
* list item's indentation together with its marker.
|
|
528
|
+
*/
|
|
529
|
+
function contentColumn(prefix) {
|
|
530
|
+
let column = 0;
|
|
531
|
+
for (const char of prefix)
|
|
532
|
+
column += char === '\t' ? 4 - (column % 4) : 1;
|
|
533
|
+
return column;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* A line that opens a block of its own: a blockquote, a thematic break, a list
|
|
537
|
+
* item, a table row, or raw HTML. CommonMark lets each of these interrupt a
|
|
538
|
+
* paragraph, so one written flush against a bullet starts something new rather
|
|
539
|
+
* than continuing it - and the audit has to name it rather than let it be
|
|
540
|
+
* deleted with the file. Headings interrupt too and are checked separately,
|
|
541
|
+
* since they are refused however they are indented.
|
|
542
|
+
*/
|
|
543
|
+
const INTERRUPTS_PARAGRAPH = /^ {0,3}(?:>|(?:[-*_][ \t]*){3,}$|(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)|[<|])/;
|
|
544
|
+
/**
|
|
545
|
+
* A list item, spelled the way CommonMark spells one, with its marker and the
|
|
546
|
+
* space after it captured so a caller can measure the item's content column.
|
|
547
|
+
*
|
|
548
|
+
* Every marker, and only those. `+` is a list marker like `-` and `*`: a spec
|
|
549
|
+
* bulleted that way validates like any other, and naming only two of the three
|
|
550
|
+
* made every one of its scenario bullets unaccounted content, so such a
|
|
551
|
+
* capability could not be retired at all.
|
|
552
|
+
*
|
|
553
|
+
* The nine-digit cap is the other half of "only those": CommonMark stops an
|
|
554
|
+
* ordered marker at nine digits, so `1234567890.` opens a paragraph, not a
|
|
555
|
+
* list. It changes no verdict here, because a line this pattern rejects is
|
|
556
|
+
* weighed by the same rules either way; it is here so the audit and
|
|
557
|
+
* INTERRUPTS_PARAGRAPH cannot disagree about what a marker is. A line one of
|
|
558
|
+
* them calls a bullet and the other does not is read as both at once, and that
|
|
559
|
+
* disagreement is what a shared definition removes.
|
|
560
|
+
*
|
|
561
|
+
* Content after the marker is not required, so an empty `- ` still reads as
|
|
562
|
+
* the bullet it is rather than falling through to the leftovers. The captured
|
|
563
|
+
* group is the indent plus the marker plus its trailing space, which is the
|
|
564
|
+
* item's content column.
|
|
565
|
+
*/
|
|
566
|
+
const LIST_ITEM = /^(\s*(?:[-*+]|\d{1,9}[.)])\s+)/;
|
|
567
|
+
/**
|
|
568
|
+
* Drop up to `columns` visual columns of leading whitespace, so a line inside a
|
|
569
|
+
* list item is classified by what it is *within* that item. A `## Retention`
|
|
570
|
+
* indented under `100. Step` is a heading; measured against the file's left
|
|
571
|
+
* margin instead, it reads as five spaces of nothing and was absorbed as
|
|
572
|
+
* continuation. A tab straddling the boundary is consumed whole, which can only
|
|
573
|
+
* make a line look more like a construct - the direction that refuses.
|
|
574
|
+
*/
|
|
575
|
+
function dropIndent(line, columns) {
|
|
576
|
+
let column = 0;
|
|
577
|
+
let index = 0;
|
|
578
|
+
while (index < line.length && column < columns) {
|
|
579
|
+
const char = line[index];
|
|
580
|
+
if (char === ' ')
|
|
581
|
+
column += 1;
|
|
582
|
+
else if (char === '\t')
|
|
583
|
+
column += 4 - (column % 4);
|
|
584
|
+
else
|
|
585
|
+
break;
|
|
586
|
+
index++;
|
|
587
|
+
}
|
|
588
|
+
return line.slice(index);
|
|
589
|
+
}
|
|
590
|
+
/** A heading in any form a spec can write one, ATX or raw HTML. */
|
|
591
|
+
function isHeadingLine(line) {
|
|
592
|
+
return /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line) || /^\s*<h[1-6]\b/i.test(line);
|
|
593
|
+
}
|
|
476
594
|
/**
|
|
477
595
|
* The non-blank lines of a spec that are not part of what a retirement is able
|
|
478
596
|
* to name: the title, the `## Purpose` section, the `## Requirements` header,
|
|
@@ -560,6 +678,18 @@ function contentTheMergeCannotName(parts) {
|
|
|
560
678
|
// operational note below the last scenario be deleted unmentioned.
|
|
561
679
|
let inScenarioBullets = false;
|
|
562
680
|
let bulletsSeen = false;
|
|
681
|
+
// The content column of the list item the previous line opened or
|
|
682
|
+
// continued, or null when the last line was not part of one. A line
|
|
683
|
+
// indented to that column continues the item it sits under (#1780) - a
|
|
684
|
+
// repository that wraps its prose at a column limit writes most scenario
|
|
685
|
+
// bullets over two lines, and counting the second line as loose content
|
|
686
|
+
// made every such capability unretirable. Reset by a blank line, so an
|
|
687
|
+
// indented note written below the scenarios is still the author's own.
|
|
688
|
+
let listContentIndent = null;
|
|
689
|
+
// Whether the bullet's paragraph is still open, so a line that does not
|
|
690
|
+
// indent can still be continuing it. Closed by anything that ends a
|
|
691
|
+
// paragraph: a blank line, a fence, a heading, or a block of its own.
|
|
692
|
+
let paragraphOpen = false;
|
|
563
693
|
for (let index = 0; index < lines.length; index++) {
|
|
564
694
|
const line = lines[index];
|
|
565
695
|
if (!line.trim()) {
|
|
@@ -567,29 +697,82 @@ function contentTheMergeCannotName(parts) {
|
|
|
567
697
|
// between a scenario header and its first bullet is not a boundary.
|
|
568
698
|
if (bulletsSeen)
|
|
569
699
|
inScenarioBullets = false;
|
|
700
|
+
listContentIndent = null;
|
|
701
|
+
paragraphOpen = false;
|
|
570
702
|
continue;
|
|
571
703
|
}
|
|
572
704
|
if (index === 0)
|
|
573
705
|
continue; // the `### Requirement:` header itself
|
|
706
|
+
const indent = contentColumn(/^[ \t]*/.exec(line)[0]);
|
|
707
|
+
// Indented to the item's content column: inside the item, whatever it
|
|
708
|
+
// holds - a nested list, a table, an indented quote.
|
|
709
|
+
const insideItem = listContentIndent !== null && indent >= listContentIndent;
|
|
710
|
+
// Every syntax test below reads the line as the item sees it. A wide
|
|
711
|
+
// marker (`100. `) pushes its content past the three columns Markdown
|
|
712
|
+
// constructs are allowed, so measuring from the file's left margin missed
|
|
713
|
+
// headings and block starts written inside such an item.
|
|
714
|
+
const withinItem = insideItem ? dropIndent(line, listContentIndent) : line;
|
|
715
|
+
// Not indented at all, but continuing the bullet's own paragraph inside a
|
|
716
|
+
// scenario's unbroken bullet run - how a hand-wrapped bullet is usually
|
|
717
|
+
// written. Absorbing it widens nothing: a sibling bullet in that same
|
|
718
|
+
// position is already read as the scenario's own, and a lazy line is part
|
|
719
|
+
// of the bullet above it where a sibling is merely next to it. Outside
|
|
720
|
+
// the run the indent is required, so a note bulleted below the scenarios
|
|
721
|
+
// and its own wrapped lines stay the author's.
|
|
722
|
+
const lazilyContinuesBullet = paragraphOpen && inScenarioBullets && !INTERRUPTS_PARAGRAPH.test(withinItem);
|
|
723
|
+
// A heading is a heading wherever it sits, so neither form absorbs one:
|
|
724
|
+
// `firstForeignTail` names the ATX spelling and the `before` pass names
|
|
725
|
+
// the raw HTML, and indenting a section under a bullet must not smuggle
|
|
726
|
+
// it past the audit.
|
|
727
|
+
const continuesListItem = (insideItem || lazilyContinuesBullet) && !isHeadingLine(withinItem);
|
|
574
728
|
// Fenced lines render as a code block inside the requirement, so they are
|
|
575
729
|
// its own content however they are spelled - a `### Requirement:` in an
|
|
576
730
|
// example is not a heading to any reader. Flagging them made a spec that
|
|
577
731
|
// merely documents a command unretirable.
|
|
578
|
-
if (mask[index])
|
|
732
|
+
if (mask[index]) {
|
|
733
|
+
// A fence that starts left of the item's content column has ended it,
|
|
734
|
+
// and a fence ends the paragraph wherever it sits - so what follows is
|
|
735
|
+
// not a lazy continuation of anything.
|
|
736
|
+
if (!insideItem)
|
|
737
|
+
listContentIndent = null;
|
|
738
|
+
paragraphOpen = false;
|
|
579
739
|
continue;
|
|
740
|
+
}
|
|
741
|
+
// Checked ahead of the continuation branch: a setext underline turns the
|
|
742
|
+
// line above it into a heading, and indenting the pair under a bullet
|
|
743
|
+
// must not absorb them any more than an indented `#` line is absorbed.
|
|
580
744
|
if (index > 1 &&
|
|
581
|
-
/^ {0,3}(?:=+|-+)\s*$/.test(
|
|
745
|
+
/^ {0,3}(?:=+|-+)\s*$/.test(withinItem) &&
|
|
582
746
|
lines[index - 1].trim()) {
|
|
583
747
|
leftovers.push(lines[index - 1].trim());
|
|
748
|
+
listContentIndent = null;
|
|
749
|
+
paragraphOpen = false;
|
|
584
750
|
continue;
|
|
585
751
|
}
|
|
752
|
+
// A continuation of the list item above: indented to its content column
|
|
753
|
+
// with no blank line between. Whatever the item is, this line is part of
|
|
754
|
+
// it - accounted for when the item was, and already reported when it was
|
|
755
|
+
// not, so nothing is deleted unmentioned either way.
|
|
756
|
+
if (continuesListItem) {
|
|
757
|
+
// An indented nested list or quote is still inside the item, but it
|
|
758
|
+
// ended the bullet's paragraph - so a later unindented line is not
|
|
759
|
+
// continuing that paragraph either.
|
|
760
|
+
paragraphOpen = !INTERRUPTS_PARAGRAPH.test(withinItem);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
// Any other line closes the item; a bullet opens the next one. The
|
|
764
|
+
// content column is the marker's own indent plus the marker itself, so a
|
|
765
|
+
// nested list and its own wrapped lines stay inside the item too.
|
|
766
|
+
const bullet = line.match(LIST_ITEM);
|
|
767
|
+
listContentIndent = bullet ? contentColumn(bullet[1]) : null;
|
|
768
|
+
paragraphOpen = bullet !== null;
|
|
586
769
|
if (/^ {0,3}####\s+Scenario:/i.test(line)) {
|
|
587
770
|
seenScenario = true;
|
|
588
771
|
inScenarioBullets = true;
|
|
589
772
|
bulletsSeen = false;
|
|
590
773
|
continue;
|
|
591
774
|
}
|
|
592
|
-
if (
|
|
775
|
+
if (bullet) {
|
|
593
776
|
if (inScenarioBullets) {
|
|
594
777
|
bulletsSeen = true;
|
|
595
778
|
continue;
|
|
@@ -609,6 +792,44 @@ function contentTheMergeCannotName(parts) {
|
|
|
609
792
|
}
|
|
610
793
|
return [...new Set(leftovers)];
|
|
611
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* Collapse runs of blank lines to a single blank line - everywhere except
|
|
797
|
+
* inside a fenced code block.
|
|
798
|
+
*
|
|
799
|
+
* The normalisation exists to tidy the seams between the slices this function
|
|
800
|
+
* rejoins. Applying it to the whole document also rewrote the inside of fenced
|
|
801
|
+
* code blocks, so a requirement documenting a sample with two consecutive blank
|
|
802
|
+
* lines had that sample silently edited on every archive. That matters for
|
|
803
|
+
* whitespace-significant content, and every other structural pass in this
|
|
804
|
+
* module is already fence-aware via `buildCodeFenceMask`.
|
|
805
|
+
*
|
|
806
|
+
* Only a truly empty line counts as blank, exactly as the `/\n{3,}/` it
|
|
807
|
+
* replaces did: a line of spaces was never collapsed and still is not.
|
|
808
|
+
*/
|
|
809
|
+
function collapseBlankRunsOutsideFences(content) {
|
|
810
|
+
const lines = content.split('\n');
|
|
811
|
+
const mask = buildCodeFenceMask(lines);
|
|
812
|
+
const kept = [];
|
|
813
|
+
let blankRun = 0;
|
|
814
|
+
for (let index = 0; index < lines.length; index++) {
|
|
815
|
+
const line = lines[index];
|
|
816
|
+
if (mask[index]) {
|
|
817
|
+
blankRun = 0;
|
|
818
|
+
kept.push(line);
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (line === '') {
|
|
822
|
+
blankRun++;
|
|
823
|
+
if (blankRun > 1)
|
|
824
|
+
continue;
|
|
825
|
+
kept.push(line);
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
blankRun = 0;
|
|
829
|
+
kept.push(line);
|
|
830
|
+
}
|
|
831
|
+
return kept.join('\n');
|
|
832
|
+
}
|
|
612
833
|
function normalizeBlockRaw(raw) {
|
|
613
834
|
return raw.replace(/\r\n?/g, '\n').trim();
|
|
614
835
|
}
|
|
@@ -840,15 +1061,36 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
|
|
|
840
1061
|
/** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
|
|
841
1062
|
function maskHtmlComments(content) {
|
|
842
1063
|
const blank = (text) => text.replace(/[^\n]/g, ' ');
|
|
843
|
-
//
|
|
844
|
-
|
|
845
|
-
//
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1064
|
+
// Linear scan: every character is visited once. A `/<!--[\s\S]*?--!?>/g`
|
|
1065
|
+
// replace re-scans to end of file from every `<!--`, which is quadratic on a
|
|
1066
|
+
// spec dense in comment openers.
|
|
1067
|
+
let out = '';
|
|
1068
|
+
let index = 0;
|
|
1069
|
+
for (;;) {
|
|
1070
|
+
const open = content.indexOf('<!--', index);
|
|
1071
|
+
if (open === -1)
|
|
1072
|
+
return out + content.slice(index);
|
|
1073
|
+
out += content.slice(index, open);
|
|
1074
|
+
// `--!>` is a comment terminator as well as `-->`.
|
|
1075
|
+
let close = -1;
|
|
1076
|
+
for (let i = open + 4; i < content.length; i++) {
|
|
1077
|
+
if (content.startsWith('-->', i)) {
|
|
1078
|
+
close = i + 3;
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1081
|
+
if (content.startsWith('--!>', i)) {
|
|
1082
|
+
close = i + 4;
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// A comment that is never closed runs to end of file, so everything after
|
|
1087
|
+
// it is commented out too. Without this an unterminated `<!--` above a
|
|
1088
|
+
// `## Purpose` left the commented-out header looking real (#1413).
|
|
1089
|
+
if (close === -1)
|
|
1090
|
+
return out + blank(content.slice(open));
|
|
1091
|
+
out += blank(content.slice(open, close));
|
|
1092
|
+
index = close;
|
|
1093
|
+
}
|
|
852
1094
|
}
|
|
853
1095
|
/**
|
|
854
1096
|
* Read the body of a `## Purpose` section, ignoring markdown that only appears
|
package/dist/core/store/git.d.ts
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds every read-only git probe. Without a timeout a wedged network mount, an
|
|
3
|
+
* fsmonitor daemon, or a credential/GPG prompt hangs the CLI forever; without a
|
|
4
|
+
* raised maxBuffer a very large dirty tree makes `git status --porcelain` throw
|
|
5
|
+
* ENOBUFS, which the probes below would otherwise report as "no git facts".
|
|
6
|
+
* A probe writes nothing, so a hard kill is safe.
|
|
7
|
+
*/
|
|
8
|
+
export declare const GIT_EXEC_OPTIONS: {
|
|
9
|
+
readonly encoding: "utf8";
|
|
10
|
+
readonly timeout: 15000;
|
|
11
|
+
readonly killSignal: "SIGKILL";
|
|
12
|
+
readonly maxBuffer: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Writes get their own bounds, and deliberately NOT SIGKILL: git traps SIGTERM
|
|
16
|
+
* to remove `.git/index.lock` on its way out, and a signal it cannot catch
|
|
17
|
+
* leaves that lock behind - every later git command in the user's store then
|
|
18
|
+
* fails with "Another git process seems to be running", including the
|
|
19
|
+
* best-effort unstage below. The timeout is also far longer, because a signed
|
|
20
|
+
* commit can legitimately sit waiting on pinentry or a hardware key.
|
|
21
|
+
*/
|
|
22
|
+
export declare const GIT_WRITE_EXEC_OPTIONS: {
|
|
23
|
+
readonly encoding: "utf8";
|
|
24
|
+
readonly timeout: 120000;
|
|
25
|
+
readonly maxBuffer: number;
|
|
26
|
+
};
|
|
1
27
|
export declare function isGitRepositoryAtRoot(storeRoot: string): Promise<boolean>;
|
|
2
28
|
export declare function initGitRepository(storeRoot: string): Promise<boolean>;
|
|
3
29
|
/**
|
|
@@ -11,6 +37,13 @@ export declare function assertGitCommitIdentity(probeCwd: string): Promise<void>
|
|
|
11
37
|
* Pathspecs may be files or directories.
|
|
12
38
|
*/
|
|
13
39
|
export declare function commitStoreFiles(storeRoot: string, id: string, pathspecs: string[]): Promise<boolean>;
|
|
40
|
+
/**
|
|
41
|
+
* A probe that hit a resource limit rather than an ordinary Git answer: the
|
|
42
|
+
* command was killed by the timeout above, or its output exceeded maxBuffer.
|
|
43
|
+
* Both produce the same `null` as "not a repository", so without this the CLI
|
|
44
|
+
* would quietly stop reporting facts it is capable of reporting.
|
|
45
|
+
*/
|
|
46
|
+
export declare function isProbeResourceFailure(error: unknown): boolean;
|
|
14
47
|
export declare function gitHasCommits(storeRoot: string): Promise<boolean | null>;
|
|
15
48
|
export declare function gitHasUncommittedChanges(storeRoot: string): Promise<boolean | null>;
|
|
16
49
|
export declare function gitHasRemote(storeRoot: string): Promise<boolean | null>;
|
package/dist/core/store/git.js
CHANGED
|
@@ -4,7 +4,40 @@ import * as path from 'node:path';
|
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { StoreError } from './errors.js';
|
|
6
6
|
const fs = nodeFs.promises;
|
|
7
|
-
const
|
|
7
|
+
const rawExecFileAsync = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* Bounds every read-only git probe. Without a timeout a wedged network mount, an
|
|
10
|
+
* fsmonitor daemon, or a credential/GPG prompt hangs the CLI forever; without a
|
|
11
|
+
* raised maxBuffer a very large dirty tree makes `git status --porcelain` throw
|
|
12
|
+
* ENOBUFS, which the probes below would otherwise report as "no git facts".
|
|
13
|
+
* A probe writes nothing, so a hard kill is safe.
|
|
14
|
+
*/
|
|
15
|
+
export const GIT_EXEC_OPTIONS = {
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
timeout: 15_000,
|
|
18
|
+
killSignal: 'SIGKILL',
|
|
19
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Writes get their own bounds, and deliberately NOT SIGKILL: git traps SIGTERM
|
|
23
|
+
* to remove `.git/index.lock` on its way out, and a signal it cannot catch
|
|
24
|
+
* leaves that lock behind - every later git command in the user's store then
|
|
25
|
+
* fails with "Another git process seems to be running", including the
|
|
26
|
+
* best-effort unstage below. The timeout is also far longer, because a signed
|
|
27
|
+
* commit can legitimately sit waiting on pinentry or a hardware key.
|
|
28
|
+
*/
|
|
29
|
+
export const GIT_WRITE_EXEC_OPTIONS = {
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
timeout: 120_000,
|
|
32
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
33
|
+
};
|
|
34
|
+
function execFileAsync(file, args, options = {}) {
|
|
35
|
+
return rawExecFileAsync(file, args, { ...GIT_EXEC_OPTIONS, ...options });
|
|
36
|
+
}
|
|
37
|
+
/** Same as execFileAsync, for commands that modify the user's repository. */
|
|
38
|
+
function execGitWrite(args, options = {}) {
|
|
39
|
+
return rawExecFileAsync('git', args, { ...GIT_WRITE_EXEC_OPTIONS, ...options });
|
|
40
|
+
}
|
|
8
41
|
/**
|
|
9
42
|
* Git mechanics for stores: repository detection, setup-time init and
|
|
10
43
|
* commit, and the read-only facts doctor reports. Nothing here clones, pulls,
|
|
@@ -31,7 +64,7 @@ export async function initGitRepository(storeRoot) {
|
|
|
31
64
|
return false;
|
|
32
65
|
}
|
|
33
66
|
try {
|
|
34
|
-
await
|
|
67
|
+
await execGitWrite(['init'], { cwd: storeRoot });
|
|
35
68
|
}
|
|
36
69
|
catch (error) {
|
|
37
70
|
throw new StoreError(`Failed to initialize Git repository: ${error instanceof Error ? error.message : String(error)}`, 'store_git_init_failed', {
|
|
@@ -74,13 +107,13 @@ export async function commitStoreFiles(storeRoot, id, pathspecs) {
|
|
|
74
107
|
return false;
|
|
75
108
|
}
|
|
76
109
|
try {
|
|
77
|
-
await
|
|
78
|
-
await
|
|
110
|
+
await execGitWrite(['add', '--', ...pathspecs], { cwd: storeRoot });
|
|
111
|
+
await execGitWrite(['commit', '-m', `Initialize OpenSpec store ${id}`, '--', ...pathspecs], { cwd: storeRoot });
|
|
79
112
|
}
|
|
80
113
|
catch (error) {
|
|
81
114
|
// Best-effort unstage so a failed commit (gpg signing, hooks) does not
|
|
82
115
|
// leave setup's files in the user's index after rollback deletes them.
|
|
83
|
-
await
|
|
116
|
+
await execGitWrite(['rm', '--cached', '-r', '-f', '-q', '--', ...pathspecs], {
|
|
84
117
|
cwd: storeRoot,
|
|
85
118
|
}).catch(() => undefined);
|
|
86
119
|
throw new StoreError(`Failed to create the initial store commit: ${error instanceof Error ? error.message : String(error)}`, 'store_git_commit_failed', {
|
|
@@ -90,12 +123,33 @@ export async function commitStoreFiles(storeRoot, id, pathspecs) {
|
|
|
90
123
|
}
|
|
91
124
|
return true;
|
|
92
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* A probe that hit a resource limit rather than an ordinary Git answer: the
|
|
128
|
+
* command was killed by the timeout above, or its output exceeded maxBuffer.
|
|
129
|
+
* Both produce the same `null` as "not a repository", so without this the CLI
|
|
130
|
+
* would quietly stop reporting facts it is capable of reporting.
|
|
131
|
+
*/
|
|
132
|
+
export function isProbeResourceFailure(error) {
|
|
133
|
+
if (typeof error !== 'object' || error === null)
|
|
134
|
+
return false;
|
|
135
|
+
const { code, killed, signal } = error;
|
|
136
|
+
return (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ||
|
|
137
|
+
code === 'ETIMEDOUT' ||
|
|
138
|
+
(killed === true && signal === 'SIGKILL'));
|
|
139
|
+
}
|
|
93
140
|
async function gitProbe(storeRoot, args) {
|
|
94
141
|
try {
|
|
95
142
|
const { stdout } = await execFileAsync('git', ['-C', storeRoot, ...args]);
|
|
96
143
|
return stdout;
|
|
97
144
|
}
|
|
98
|
-
catch {
|
|
145
|
+
catch (error) {
|
|
146
|
+
// "git is absent" and "this is not a repository" are expected answers and
|
|
147
|
+
// stay silent; a probe that timed out or overflowed its buffer is a
|
|
148
|
+
// degraded result the user should know about, since callers cannot tell
|
|
149
|
+
// the two apart from the null alone.
|
|
150
|
+
if (isProbeResourceFailure(error)) {
|
|
151
|
+
process.emitWarning(`git ${args.join(' ')} did not complete in ${storeRoot}; store Git facts are unavailable for this run.`, 'OpenSpecGitProbeWarning');
|
|
152
|
+
}
|
|
99
153
|
return null;
|
|
100
154
|
}
|
|
101
155
|
}
|
|
@@ -100,7 +100,7 @@ export declare function expandUserPath(inputPath: string): string;
|
|
|
100
100
|
* no-ops), and always honoring an explicit --init-git/--no-init-git.
|
|
101
101
|
*/
|
|
102
102
|
export declare function resolveSetupGitEnabled(prepared: PreparedStoreSetup, initGit?: boolean): boolean;
|
|
103
|
-
export declare function prepareStoreSetup(input: Pick<SetupStoreInput, 'id' | 'path' | 'allowInsideGitRepository' | 'remote'>): Promise<PreparedStoreSetup>;
|
|
103
|
+
export declare function prepareStoreSetup(input: Pick<SetupStoreInput, 'id' | 'path' | 'initGit' | 'allowInsideGitRepository' | 'remote'>): Promise<PreparedStoreSetup>;
|
|
104
104
|
export declare function setupPreparedStore(prepared: PreparedStoreSetup, input?: Pick<SetupStoreInput, 'initGit'>): Promise<StoreMutationResult>;
|
|
105
105
|
export declare function setupStore(input: SetupStoreInput): Promise<StoreMutationResult>;
|
|
106
106
|
export declare function registerExistingStore(input: RegisterExistingStoreInput): Promise<StoreMutationResult>;
|
|
@@ -8,7 +8,7 @@ import { classifyOpenSpecDir, storePointerProblem, } from '../project-config.js'
|
|
|
8
8
|
import { ANCHORED_OPENSPEC_DIRS, DIRECTORY_ANCHOR_FILE_NAME, OPENSPEC_ROOT_DIR, ensureOpenSpecRoot, inspectOpenSpecRoot, rollbackCreatedPaths, } from '../openspec-root.js';
|
|
9
9
|
import { STORE_METADATA_DIR_NAME, getStoreMetadataDir, getStoreMetadataPath, getStoreRegistryPath, listStoreRegistryEntries, readStoreRegistryState, readOptionalStoreMetadataState, resolveGitStoreBackendConfig, validateStoreId, writeStoreMetadataState, } from './foundation.js';
|
|
10
10
|
import { StoreError, makeStoreDiagnostic } from './errors.js';
|
|
11
|
-
import { assertGitCommitIdentity, commitStoreFiles, gitDirectoryHasTrackedFiles, gitHasCommits, gitHasRemote, gitHasUncommittedChanges, gitOriginUrl, initGitRepository, isGitRepositoryAtRoot, } from './git.js';
|
|
11
|
+
import { GIT_EXEC_OPTIONS, assertGitCommitIdentity, commitStoreFiles, gitDirectoryHasTrackedFiles, gitHasCommits, gitHasRemote, gitHasUncommittedChanges, gitOriginUrl, initGitRepository, isGitRepositoryAtRoot, } from './git.js';
|
|
12
12
|
import { getStoreRootForBackend, assertNoRegisteredStoreConflict, commitStoreRegistration, getRegisteredStore, listRegisteredStores, unregisterStoreRegistration, } from './registry.js';
|
|
13
13
|
const fs = nodeFs.promises;
|
|
14
14
|
const execFileAsync = promisify(execFile);
|
|
@@ -106,12 +106,7 @@ async function findContainingGitRepositoryRoot(storeRoot) {
|
|
|
106
106
|
: null;
|
|
107
107
|
};
|
|
108
108
|
try {
|
|
109
|
-
const { stdout } = await execFileAsync('git', [
|
|
110
|
-
'-C',
|
|
111
|
-
nearestParent,
|
|
112
|
-
'rev-parse',
|
|
113
|
-
'--show-toplevel',
|
|
114
|
-
]);
|
|
109
|
+
const { stdout } = await execFileAsync('git', ['-C', nearestParent, 'rev-parse', '--show-toplevel'], GIT_EXEC_OPTIONS);
|
|
115
110
|
return gitRootContainsStore(stdout.trim());
|
|
116
111
|
}
|
|
117
112
|
catch {
|
|
@@ -244,9 +239,10 @@ async function prepareSetupPlan(input) {
|
|
|
244
239
|
});
|
|
245
240
|
}
|
|
246
241
|
// Stores may be Git-backed, but creating one inside an implementation
|
|
247
|
-
// repo is almost always an accidental nested-repo setup.
|
|
242
|
+
// repo is almost always an accidental nested-repo setup. --no-init-git
|
|
243
|
+
// creates no repository, so there is nothing to nest.
|
|
248
244
|
await assertSetupPathIsNotNestedInGitRepo(storeRoot, {
|
|
249
|
-
allowInsideGitRepository: input.allowInsideGitRepository,
|
|
245
|
+
allowInsideGitRepository: input.allowInsideGitRepository || input.initGit === false,
|
|
250
246
|
});
|
|
251
247
|
let metadata = null;
|
|
252
248
|
let backend;
|
|
@@ -594,6 +590,29 @@ async function assertSafeToDeleteStoreRoot(storeRoot, id) {
|
|
|
594
590
|
}
|
|
595
591
|
return { exists: true };
|
|
596
592
|
}
|
|
593
|
+
/**
|
|
594
|
+
* Deleting a store root takes everything under it, including any other
|
|
595
|
+
* store registered inside it (a shared store vendored as a submodule, for
|
|
596
|
+
* example). `store remove <id>` never asked for that store to go.
|
|
597
|
+
*/
|
|
598
|
+
function assertNoRegisteredStoreInside(storeRoot, id, others) {
|
|
599
|
+
const root = normalizeRegistryPathForComparison(storeRoot);
|
|
600
|
+
const nested = others.filter((other) => {
|
|
601
|
+
const relative = path.relative(root, normalizeRegistryPathForComparison(other.storeRoot));
|
|
602
|
+
return (relative.length > 0 &&
|
|
603
|
+
relative !== '..' &&
|
|
604
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
605
|
+
!path.isAbsolute(relative));
|
|
606
|
+
});
|
|
607
|
+
if (nested.length === 0)
|
|
608
|
+
return;
|
|
609
|
+
const listed = nested.map((other) => `'${other.id}' (${other.storeRoot})`).join(', ');
|
|
610
|
+
const unregister = nested.map((other) => `openspec store unregister ${other.id}`).join(', then ');
|
|
611
|
+
throw new StoreError(`Store remove refuses to delete ${storeRoot}: it contains ${nested.length === 1 ? 'another registered store' : 'other registered stores'}: ${listed}.`, 'store_remove_contains_registered_store', {
|
|
612
|
+
target: 'store.root',
|
|
613
|
+
fix: `Unregister or remove ${nested.length === 1 ? 'that store' : 'those stores'} first (${unregister}), or run "openspec store unregister ${id}" to forget '${id}' without deleting files.`,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
597
616
|
export async function removeStore(target) {
|
|
598
617
|
const id = validateStoreId(target.id);
|
|
599
618
|
const diagnostics = [];
|
|
@@ -606,9 +625,12 @@ export async function removeStore(target) {
|
|
|
606
625
|
id,
|
|
607
626
|
expectedBackend: target.backend,
|
|
608
627
|
globalDataDir: target.globalDataDir,
|
|
609
|
-
beforeCommit: async (entry) => {
|
|
628
|
+
beforeCommit: async (entry, remaining) => {
|
|
610
629
|
const safeTarget = await assertSafeToDeleteStoreRoot(entry.storeRoot, id);
|
|
611
630
|
rootMissing = !safeTarget.exists;
|
|
631
|
+
if (safeTarget.exists) {
|
|
632
|
+
assertNoRegisteredStoreInside(entry.storeRoot, id, remaining);
|
|
633
|
+
}
|
|
612
634
|
},
|
|
613
635
|
});
|
|
614
636
|
if (rootMissing) {
|
|
@@ -15,7 +15,8 @@ export interface GetRegisteredStoreInput extends ResolveRegisteredStoreInput {
|
|
|
15
15
|
export interface UnregisterStoreInput extends StorePathOptions {
|
|
16
16
|
id: string;
|
|
17
17
|
expectedBackend?: StoreGitBackendConfig;
|
|
18
|
-
|
|
18
|
+
/** Runs under the registry lock, with the registrations that will remain. */
|
|
19
|
+
beforeCommit?: (entry: RegisteredStoreEntry, remaining: RegisteredStoreEntry[]) => Promise<void>;
|
|
19
20
|
}
|
|
20
21
|
export type ListRegisteredStoresOptions = StorePathOptions;
|
|
21
22
|
export interface RegisteredStoreEntry extends StoreRegistryEntry {
|
|
@@ -236,7 +236,11 @@ export async function unregisterStoreRegistration(input) {
|
|
|
236
236
|
...result.removed,
|
|
237
237
|
storeRoot: getStoreRootForBackend(result.removed.backend),
|
|
238
238
|
};
|
|
239
|
-
|
|
239
|
+
const remaining = listStoreRegistryEntries(result.next).map((entry) => ({
|
|
240
|
+
...entry,
|
|
241
|
+
storeRoot: getStoreRootForBackend(entry.backend),
|
|
242
|
+
}));
|
|
243
|
+
await input.beforeCommit?.(removedEntry, remaining);
|
|
240
244
|
removed = result.removed;
|
|
241
245
|
return result.next;
|
|
242
246
|
}, { globalDataDir: input.globalDataDir });
|