@gordon.gan/specflow 1.1.1 → 1.2.0-beta.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 +34 -8
- package/dist/cli/commands/change-archive.js +4 -4
- package/dist/cli/commands/change-new.js +5 -5
- package/dist/cli/commands/change-phase.js +4 -4
- package/dist/cli/commands/change-status.js +4 -4
- package/dist/cli/commands/context.d.ts +18 -0
- package/dist/cli/commands/context.js +125 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +20 -6
- package/dist/cli/commands/instructions.d.ts +4 -1
- package/dist/cli/commands/instructions.js +62 -9
- package/dist/cli/commands/show.d.ts +22 -0
- package/dist/cli/commands/show.js +92 -0
- package/dist/cli/commands/store.d.ts +16 -0
- package/dist/cli/commands/store.js +221 -0
- package/dist/cli/commands/validate.d.ts +16 -0
- package/dist/cli/commands/validate.js +34 -4
- package/dist/cli/commands/workset.d.ts +12 -0
- package/dist/cli/commands/workset.js +235 -0
- package/dist/cli/index.js +8 -0
- package/dist/cli/shared/store-option.d.ts +11 -0
- package/dist/cli/shared/store-option.js +40 -0
- package/dist/core/artifact-graph/instruction-loader.d.ts +23 -0
- package/dist/core/artifact-graph/instruction-loader.js +6 -0
- package/dist/core/artifact-graph/types.d.ts +2 -2
- package/dist/core/artifact-language.d.ts +7 -0
- package/dist/core/artifact-language.js +33 -0
- package/dist/core/context-assembly.d.ts +9 -0
- package/dist/core/context-assembly.js +68 -0
- package/dist/core/diagnostics.d.ts +11 -0
- package/dist/core/diagnostics.js +18 -0
- package/dist/core/file-state.d.ts +23 -0
- package/dist/core/file-state.js +101 -0
- package/dist/core/global-config.d.ts +26 -0
- package/dist/core/global-config.js +77 -0
- package/dist/core/opener-launch.d.ts +3 -0
- package/dist/core/opener-launch.js +20 -0
- package/dist/core/openers.d.ts +23 -0
- package/dist/core/openers.js +20 -0
- package/dist/core/project-config.d.ts +16 -0
- package/dist/core/project-config.js +104 -0
- package/dist/core/reference-index.d.ts +8 -0
- package/dist/core/reference-index.js +80 -0
- package/dist/core/references.d.ts +17 -0
- package/dist/core/references.js +51 -0
- package/dist/core/relationship-health.d.ts +22 -0
- package/dist/core/relationship-health.js +68 -0
- package/dist/core/root-selection.d.ts +26 -0
- package/dist/core/root-selection.js +197 -0
- package/dist/core/store/errors.d.ts +2 -0
- package/dist/core/store/errors.js +1 -0
- package/dist/core/store/foundation.d.ts +141 -0
- package/dist/core/store/foundation.js +79 -0
- package/dist/core/store/health.d.ts +13 -0
- package/dist/core/store/health.js +117 -0
- package/dist/core/store/operations.d.ts +56 -0
- package/dist/core/store/operations.js +268 -0
- package/dist/core/store/registry.d.ts +15 -0
- package/dist/core/store/registry.js +128 -0
- package/dist/core/working-set.d.ts +30 -0
- package/dist/core/working-set.js +26 -0
- package/dist/core/worksets.d.ts +71 -0
- package/dist/core/worksets.js +134 -0
- package/dist/integrations/shared/skill-renderer.d.ts +6 -0
- package/dist/integrations/shared/skill-renderer.js +22 -0
- package/package.json +1 -1
- package/prompts/shared/artifact-language.md +30 -0
- package/skills/specflow-apply/SKILL.md +12 -27
- package/skills/specflow-explore/SKILL.md +6 -0
- package/skills/specflow-propose/SKILL.md +6 -0
- package/skills/specflow-refine/SKILL.md +6 -0
- package/skills/specflow-snap/SKILL.md +6 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Diagnostic } from '../diagnostics.js';
|
|
3
|
+
export declare const KEBAB_CASE_STORE_ID: RegExp;
|
|
4
|
+
export declare const StoreIdentitySchema: z.ZodObject<{
|
|
5
|
+
version: z.ZodLiteral<1>;
|
|
6
|
+
id: z.ZodString;
|
|
7
|
+
remote: z.ZodOptional<z.ZodString>;
|
|
8
|
+
}, "strict", z.ZodTypeAny, {
|
|
9
|
+
version: 1;
|
|
10
|
+
id: string;
|
|
11
|
+
remote?: string | undefined;
|
|
12
|
+
}, {
|
|
13
|
+
version: 1;
|
|
14
|
+
id: string;
|
|
15
|
+
remote?: string | undefined;
|
|
16
|
+
}>;
|
|
17
|
+
export type StoreIdentity = z.infer<typeof StoreIdentitySchema>;
|
|
18
|
+
export declare const StoreProvenanceSchema: z.ZodEnum<["managed", "external"]>;
|
|
19
|
+
export declare const StoreBackendSchema: z.ZodObject<{
|
|
20
|
+
type: z.ZodLiteral<"git">;
|
|
21
|
+
local_path: z.ZodString;
|
|
22
|
+
remote: z.ZodOptional<z.ZodString>;
|
|
23
|
+
branch: z.ZodOptional<z.ZodString>;
|
|
24
|
+
}, "strict", z.ZodTypeAny, {
|
|
25
|
+
type: "git";
|
|
26
|
+
local_path: string;
|
|
27
|
+
remote?: string | undefined;
|
|
28
|
+
branch?: string | undefined;
|
|
29
|
+
}, {
|
|
30
|
+
type: "git";
|
|
31
|
+
local_path: string;
|
|
32
|
+
remote?: string | undefined;
|
|
33
|
+
branch?: string | undefined;
|
|
34
|
+
}>;
|
|
35
|
+
export declare const RegistryEntrySchema: z.ZodObject<{
|
|
36
|
+
provenance: z.ZodEnum<["managed", "external"]>;
|
|
37
|
+
backend: z.ZodObject<{
|
|
38
|
+
type: z.ZodLiteral<"git">;
|
|
39
|
+
local_path: z.ZodString;
|
|
40
|
+
remote: z.ZodOptional<z.ZodString>;
|
|
41
|
+
branch: z.ZodOptional<z.ZodString>;
|
|
42
|
+
}, "strict", z.ZodTypeAny, {
|
|
43
|
+
type: "git";
|
|
44
|
+
local_path: string;
|
|
45
|
+
remote?: string | undefined;
|
|
46
|
+
branch?: string | undefined;
|
|
47
|
+
}, {
|
|
48
|
+
type: "git";
|
|
49
|
+
local_path: string;
|
|
50
|
+
remote?: string | undefined;
|
|
51
|
+
branch?: string | undefined;
|
|
52
|
+
}>;
|
|
53
|
+
}, "strict", z.ZodTypeAny, {
|
|
54
|
+
provenance: "managed" | "external";
|
|
55
|
+
backend: {
|
|
56
|
+
type: "git";
|
|
57
|
+
local_path: string;
|
|
58
|
+
remote?: string | undefined;
|
|
59
|
+
branch?: string | undefined;
|
|
60
|
+
};
|
|
61
|
+
}, {
|
|
62
|
+
provenance: "managed" | "external";
|
|
63
|
+
backend: {
|
|
64
|
+
type: "git";
|
|
65
|
+
local_path: string;
|
|
66
|
+
remote?: string | undefined;
|
|
67
|
+
branch?: string | undefined;
|
|
68
|
+
};
|
|
69
|
+
}>;
|
|
70
|
+
export declare const StoreRegistrySchema: z.ZodObject<{
|
|
71
|
+
version: z.ZodLiteral<1>;
|
|
72
|
+
stores: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
73
|
+
provenance: z.ZodEnum<["managed", "external"]>;
|
|
74
|
+
backend: z.ZodObject<{
|
|
75
|
+
type: z.ZodLiteral<"git">;
|
|
76
|
+
local_path: z.ZodString;
|
|
77
|
+
remote: z.ZodOptional<z.ZodString>;
|
|
78
|
+
branch: z.ZodOptional<z.ZodString>;
|
|
79
|
+
}, "strict", z.ZodTypeAny, {
|
|
80
|
+
type: "git";
|
|
81
|
+
local_path: string;
|
|
82
|
+
remote?: string | undefined;
|
|
83
|
+
branch?: string | undefined;
|
|
84
|
+
}, {
|
|
85
|
+
type: "git";
|
|
86
|
+
local_path: string;
|
|
87
|
+
remote?: string | undefined;
|
|
88
|
+
branch?: string | undefined;
|
|
89
|
+
}>;
|
|
90
|
+
}, "strict", z.ZodTypeAny, {
|
|
91
|
+
provenance: "managed" | "external";
|
|
92
|
+
backend: {
|
|
93
|
+
type: "git";
|
|
94
|
+
local_path: string;
|
|
95
|
+
remote?: string | undefined;
|
|
96
|
+
branch?: string | undefined;
|
|
97
|
+
};
|
|
98
|
+
}, {
|
|
99
|
+
provenance: "managed" | "external";
|
|
100
|
+
backend: {
|
|
101
|
+
type: "git";
|
|
102
|
+
local_path: string;
|
|
103
|
+
remote?: string | undefined;
|
|
104
|
+
branch?: string | undefined;
|
|
105
|
+
};
|
|
106
|
+
}>>;
|
|
107
|
+
}, "strict", z.ZodTypeAny, {
|
|
108
|
+
stores: Record<string, {
|
|
109
|
+
provenance: "managed" | "external";
|
|
110
|
+
backend: {
|
|
111
|
+
type: "git";
|
|
112
|
+
local_path: string;
|
|
113
|
+
remote?: string | undefined;
|
|
114
|
+
branch?: string | undefined;
|
|
115
|
+
};
|
|
116
|
+
}>;
|
|
117
|
+
version: 1;
|
|
118
|
+
}, {
|
|
119
|
+
stores: Record<string, {
|
|
120
|
+
provenance: "managed" | "external";
|
|
121
|
+
backend: {
|
|
122
|
+
type: "git";
|
|
123
|
+
local_path: string;
|
|
124
|
+
remote?: string | undefined;
|
|
125
|
+
branch?: string | undefined;
|
|
126
|
+
};
|
|
127
|
+
}>;
|
|
128
|
+
version: 1;
|
|
129
|
+
}>;
|
|
130
|
+
export type StoreRegistry = z.infer<typeof StoreRegistrySchema>;
|
|
131
|
+
export type RegistryEntry = z.infer<typeof RegistryEntrySchema>;
|
|
132
|
+
export declare class StoreError extends Error {
|
|
133
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
134
|
+
constructor(message: string, diagnostics: Diagnostic | readonly Diagnostic[]);
|
|
135
|
+
}
|
|
136
|
+
export declare function validateStoreId(id: string): void;
|
|
137
|
+
export declare function canonicalizePath(targetPath: string): string;
|
|
138
|
+
export declare function pathsReferToSameCheckout(leftPath: string, rightPath: string, platform?: NodeJS.Platform): boolean;
|
|
139
|
+
export declare const STORE_IDENTITY_RELATIVE_PATH: string;
|
|
140
|
+
export declare function getStoreIdentityPath(root: string): string;
|
|
141
|
+
export declare function emptyRegistry(): StoreRegistry;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
export const KEBAB_CASE_STORE_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
5
|
+
export const StoreIdentitySchema = z
|
|
6
|
+
.object({
|
|
7
|
+
version: z.literal(1),
|
|
8
|
+
id: z.string().regex(KEBAB_CASE_STORE_ID),
|
|
9
|
+
remote: z.string().url().optional(),
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
export const StoreProvenanceSchema = z.enum(['managed', 'external']);
|
|
13
|
+
export const StoreBackendSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
type: z.literal('git'),
|
|
16
|
+
local_path: z.string().min(1),
|
|
17
|
+
remote: z.string().optional(),
|
|
18
|
+
branch: z.string().optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
export const RegistryEntrySchema = z
|
|
22
|
+
.object({
|
|
23
|
+
provenance: StoreProvenanceSchema,
|
|
24
|
+
backend: StoreBackendSchema,
|
|
25
|
+
})
|
|
26
|
+
.strict();
|
|
27
|
+
export const StoreRegistrySchema = z
|
|
28
|
+
.object({
|
|
29
|
+
version: z.literal(1),
|
|
30
|
+
stores: z.record(RegistryEntrySchema),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
export class StoreError extends Error {
|
|
34
|
+
diagnostics;
|
|
35
|
+
constructor(message, diagnostics) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'StoreError';
|
|
38
|
+
this.diagnostics = Array.isArray(diagnostics) ? diagnostics : [diagnostics];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function validateStoreId(id) {
|
|
42
|
+
if (!KEBAB_CASE_STORE_ID.test(id)) {
|
|
43
|
+
throw new StoreError(`Invalid store ID '${id}'.`, {
|
|
44
|
+
severity: 'error',
|
|
45
|
+
code: 'invalid_store_id',
|
|
46
|
+
message: `Store ID '${id}' must be kebab-case.`,
|
|
47
|
+
target: 'store.id',
|
|
48
|
+
fix: 'Use lowercase letters, numbers, and single hyphens only.',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function canonicalizePath(targetPath) {
|
|
53
|
+
return fs.realpathSync.native ? fs.realpathSync.native(targetPath) : fs.realpathSync(targetPath);
|
|
54
|
+
}
|
|
55
|
+
export function pathsReferToSameCheckout(leftPath, rightPath, platform = process.platform) {
|
|
56
|
+
const left = canonicalizePath(leftPath);
|
|
57
|
+
const right = canonicalizePath(rightPath);
|
|
58
|
+
if (platform === 'win32') {
|
|
59
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
if (left === right) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const leftStat = fs.statSync(left);
|
|
66
|
+
const rightStat = fs.statSync(right);
|
|
67
|
+
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export const STORE_IDENTITY_RELATIVE_PATH = path.join('.specflow-store', 'store.yaml');
|
|
74
|
+
export function getStoreIdentityPath(root) {
|
|
75
|
+
return path.join(root, STORE_IDENTITY_RELATIVE_PATH);
|
|
76
|
+
}
|
|
77
|
+
export function emptyRegistry() {
|
|
78
|
+
return { version: 1, stores: {} };
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Diagnostic } from '../diagnostics.js';
|
|
2
|
+
import { type RegistryEntry, type StoreRegistry } from './foundation.js';
|
|
3
|
+
export declare function inspectStoreEntry(storeId: string, entry: RegistryEntry): Promise<readonly Diagnostic[]>;
|
|
4
|
+
export declare function inspectAllStoresHealth(registry: StoreRegistry): Promise<Array<{
|
|
5
|
+
id: string;
|
|
6
|
+
root: string;
|
|
7
|
+
provenance: string;
|
|
8
|
+
diagnostics: Diagnostic[];
|
|
9
|
+
}>>;
|
|
10
|
+
export declare function flattenStoreDoctorDiagnostics(stores: Array<{
|
|
11
|
+
id: string;
|
|
12
|
+
diagnostics: Diagnostic[];
|
|
13
|
+
}>): Diagnostic[];
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import { sortDiagnostics } from '../diagnostics.js';
|
|
7
|
+
import { getStoreIdentityPath, StoreIdentitySchema, } from './foundation.js';
|
|
8
|
+
import { hasPlanningContent } from '../root-selection.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
async function pathExists(targetPath) {
|
|
11
|
+
try {
|
|
12
|
+
await fs.stat(targetPath);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function readIdentity(root) {
|
|
20
|
+
const identityPath = getStoreIdentityPath(root);
|
|
21
|
+
try {
|
|
22
|
+
const raw = yaml.load(await fs.readFile(identityPath, 'utf-8'));
|
|
23
|
+
const parsed = StoreIdentitySchema.safeParse(raw);
|
|
24
|
+
return parsed.success ? parsed.data : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function readGitOrigin(checkoutPath) {
|
|
31
|
+
try {
|
|
32
|
+
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
|
|
33
|
+
cwd: checkoutPath,
|
|
34
|
+
});
|
|
35
|
+
const trimmed = stdout.trim();
|
|
36
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function inspectStoreEntry(storeId, entry) {
|
|
43
|
+
const diagnostics = [];
|
|
44
|
+
const checkoutPath = entry.backend.local_path;
|
|
45
|
+
if (!(await pathExists(checkoutPath))) {
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
severity: 'error',
|
|
48
|
+
code: 'store_checkout_missing',
|
|
49
|
+
message: `Registered checkout for '${storeId}' does not exist at ${checkoutPath}.`,
|
|
50
|
+
target: `stores.${storeId}`,
|
|
51
|
+
fix: `Relink with specflow store register --id ${storeId} <path> or specflow store unregister ${storeId}.`,
|
|
52
|
+
});
|
|
53
|
+
return diagnostics;
|
|
54
|
+
}
|
|
55
|
+
const identity = await readIdentity(checkoutPath);
|
|
56
|
+
if (!identity) {
|
|
57
|
+
diagnostics.push({
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
code: 'store_identity_missing',
|
|
60
|
+
message: `Store '${storeId}' checkout has no committed identity.`,
|
|
61
|
+
target: `stores.${storeId}`,
|
|
62
|
+
fix: `Run specflow store register --id ${storeId} ${checkoutPath} --yes to adopt identity.`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else if (identity.id !== storeId) {
|
|
66
|
+
diagnostics.push({
|
|
67
|
+
severity: 'error',
|
|
68
|
+
code: 'store_identity_mismatch',
|
|
69
|
+
message: `Store '${storeId}' registry ID does not match committed identity '${identity.id}'.`,
|
|
70
|
+
target: `stores.${storeId}`,
|
|
71
|
+
fix: 'Unregister and re-register with the matching store ID.',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const configPath = path.join(checkoutPath, 'specflow', 'config.yaml');
|
|
75
|
+
if (!(await pathExists(configPath))) {
|
|
76
|
+
diagnostics.push({
|
|
77
|
+
severity: 'warning',
|
|
78
|
+
code: 'store_unhealthy_root',
|
|
79
|
+
message: `Store '${storeId}' checkout is missing specflow/config.yaml.`,
|
|
80
|
+
target: `stores.${storeId}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else if (!(await hasPlanningContent(checkoutPath))) {
|
|
84
|
+
diagnostics.push({
|
|
85
|
+
severity: 'info',
|
|
86
|
+
code: 'store_empty_planning',
|
|
87
|
+
message: `Store '${storeId}' has no baseline specs or active changes yet.`,
|
|
88
|
+
target: `stores.${storeId}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const canonicalRemote = identity?.remote ?? entry.backend.remote;
|
|
92
|
+
if (canonicalRemote) {
|
|
93
|
+
const observedOrigin = await readGitOrigin(checkoutPath);
|
|
94
|
+
if (observedOrigin && observedOrigin !== canonicalRemote) {
|
|
95
|
+
diagnostics.push({
|
|
96
|
+
severity: 'info',
|
|
97
|
+
code: 'store_remote_divergence',
|
|
98
|
+
message: `Store '${storeId}' canonical remote differs from Git origin.`,
|
|
99
|
+
target: `stores.${storeId}`,
|
|
100
|
+
fix: 'Update the committed identity remote or reconcile the checkout manually.',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return diagnostics;
|
|
105
|
+
}
|
|
106
|
+
export async function inspectAllStoresHealth(registry) {
|
|
107
|
+
const results = await Promise.all(Object.entries(registry.stores).map(async ([id, entry]) => ({
|
|
108
|
+
id,
|
|
109
|
+
root: entry.backend.local_path,
|
|
110
|
+
provenance: entry.provenance,
|
|
111
|
+
diagnostics: [...(await inspectStoreEntry(id, entry))],
|
|
112
|
+
})));
|
|
113
|
+
return results;
|
|
114
|
+
}
|
|
115
|
+
export function flattenStoreDoctorDiagnostics(stores) {
|
|
116
|
+
return sortDiagnostics(stores.flatMap((store) => store.diagnostics));
|
|
117
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Diagnostic } from '../diagnostics.js';
|
|
2
|
+
import { type StoreIdentity } from './foundation.js';
|
|
3
|
+
export interface StoreSetupInput {
|
|
4
|
+
id: string;
|
|
5
|
+
targetPath: string;
|
|
6
|
+
dataDir: string;
|
|
7
|
+
remote?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface StoreSetupResult {
|
|
10
|
+
readonly root: string;
|
|
11
|
+
readonly identity: StoreIdentity;
|
|
12
|
+
readonly createdArtifacts: readonly string[];
|
|
13
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
14
|
+
}
|
|
15
|
+
export declare function prepareStoreSetup(input: StoreSetupInput): Promise<StoreSetupResult>;
|
|
16
|
+
export interface RemoveManagedStoreInput {
|
|
17
|
+
id: string;
|
|
18
|
+
dataDir: string;
|
|
19
|
+
deleteCheckout?: (checkoutPath: string) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface RemoveManagedStoreResult {
|
|
22
|
+
readonly registryRemoved: boolean;
|
|
23
|
+
readonly checkoutDeleted: boolean;
|
|
24
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
25
|
+
}
|
|
26
|
+
export declare function removeManagedStore(input: RemoveManagedStoreInput): Promise<RemoveManagedStoreResult>;
|
|
27
|
+
export declare function listRegisteredStores(dataDir: string): Promise<{
|
|
28
|
+
id: string;
|
|
29
|
+
root: string;
|
|
30
|
+
provenance: "managed" | "external";
|
|
31
|
+
}[]>;
|
|
32
|
+
export interface StoreRegisterInput {
|
|
33
|
+
id: string;
|
|
34
|
+
localPath: string;
|
|
35
|
+
dataDir: string;
|
|
36
|
+
confirmIdentity?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface StoreRegisterResult {
|
|
39
|
+
readonly store: {
|
|
40
|
+
id: string;
|
|
41
|
+
root: string;
|
|
42
|
+
};
|
|
43
|
+
readonly alreadyRegistered: boolean;
|
|
44
|
+
readonly createdArtifacts: readonly string[];
|
|
45
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
46
|
+
}
|
|
47
|
+
export declare function prepareStoreRegistration(input: StoreRegisterInput): Promise<StoreRegisterResult>;
|
|
48
|
+
export interface UnregisterStoreInput {
|
|
49
|
+
id: string;
|
|
50
|
+
dataDir: string;
|
|
51
|
+
}
|
|
52
|
+
export interface UnregisterStoreResult {
|
|
53
|
+
readonly removed: boolean;
|
|
54
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
55
|
+
}
|
|
56
|
+
export declare function unregisterStore(input: UnregisterStoreInput): Promise<UnregisterStoreResult>;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { getStoreIdentityPath, StoreError, StoreIdentitySchema, validateStoreId, } from './foundation.js';
|
|
5
|
+
import { registerStoreEntry, unregisterStoreEntry, readRegistry } from './registry.js';
|
|
6
|
+
import { getStoreRegistryPath } from '../global-config.js';
|
|
7
|
+
import { ensureDir } from '../../utils/file-system.js';
|
|
8
|
+
const SPECFLOW_DIR = 'specflow';
|
|
9
|
+
async function pathExists(targetPath) {
|
|
10
|
+
try {
|
|
11
|
+
await fs.stat(targetPath);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function writeStoreIdentity(root, identity) {
|
|
19
|
+
const identityPath = getStoreIdentityPath(root);
|
|
20
|
+
await ensureDir(path.dirname(identityPath));
|
|
21
|
+
await fs.writeFile(identityPath, yaml.dump(identity, { lineWidth: -1 }), 'utf-8');
|
|
22
|
+
return identityPath;
|
|
23
|
+
}
|
|
24
|
+
async function createPlanningRoot(root) {
|
|
25
|
+
const created = [];
|
|
26
|
+
const specflowDir = path.join(root, SPECFLOW_DIR);
|
|
27
|
+
await ensureDir(path.join(specflowDir, 'changes'));
|
|
28
|
+
await ensureDir(path.join(specflowDir, 'specs'));
|
|
29
|
+
const configPath = path.join(specflowDir, 'config.yaml');
|
|
30
|
+
if (!(await pathExists(configPath))) {
|
|
31
|
+
await fs.writeFile(configPath, 'schema: specflow\n', 'utf-8');
|
|
32
|
+
created.push(configPath);
|
|
33
|
+
}
|
|
34
|
+
created.push(specflowDir);
|
|
35
|
+
return created;
|
|
36
|
+
}
|
|
37
|
+
async function rollbackLedger(ledger) {
|
|
38
|
+
for (const artifact of [...ledger].reverse()) {
|
|
39
|
+
await fs.rm(artifact, { recursive: true, force: true }).catch(() => undefined);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function prepareStoreSetup(input) {
|
|
43
|
+
validateStoreId(input.id);
|
|
44
|
+
const targetPath = path.resolve(input.targetPath);
|
|
45
|
+
if (await pathExists(targetPath)) {
|
|
46
|
+
throw new StoreError('Store setup target already exists.', {
|
|
47
|
+
severity: 'error',
|
|
48
|
+
code: 'store_target_exists',
|
|
49
|
+
message: 'Store setup accepts only a path that does not exist.',
|
|
50
|
+
target: 'store.path',
|
|
51
|
+
fix: 'Choose a missing path or adopt an existing directory with store register.',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const ledger = [];
|
|
55
|
+
const identity = {
|
|
56
|
+
version: 1,
|
|
57
|
+
id: input.id,
|
|
58
|
+
...(input.remote ? { remote: input.remote } : {}),
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
await fs.mkdir(targetPath, { recursive: false });
|
|
62
|
+
ledger.push(targetPath);
|
|
63
|
+
const created = await createPlanningRoot(targetPath);
|
|
64
|
+
ledger.push(...created);
|
|
65
|
+
const identityPath = await writeStoreIdentity(targetPath, identity);
|
|
66
|
+
ledger.push(identityPath);
|
|
67
|
+
const registryPath = getStoreRegistryPath(input.dataDir);
|
|
68
|
+
await registerStoreEntry(registryPath, {
|
|
69
|
+
id: input.id,
|
|
70
|
+
localPath: targetPath,
|
|
71
|
+
provenance: 'managed',
|
|
72
|
+
remote: input.remote,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
root: targetPath,
|
|
76
|
+
identity,
|
|
77
|
+
createdArtifacts: ledger,
|
|
78
|
+
diagnostics: [],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
await rollbackLedger(ledger);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function removeManagedStore(input) {
|
|
87
|
+
const registryPath = getStoreRegistryPath(input.dataDir);
|
|
88
|
+
const registry = await readRegistry(registryPath);
|
|
89
|
+
const entry = registry.stores[input.id];
|
|
90
|
+
if (!entry) {
|
|
91
|
+
throw new StoreError(`Store '${input.id}' is not registered.`, {
|
|
92
|
+
severity: 'error',
|
|
93
|
+
code: 'store_not_registered',
|
|
94
|
+
message: `Store '${input.id}' is not registered.`,
|
|
95
|
+
target: `stores.${input.id}`,
|
|
96
|
+
fix: 'Run specflow store list to inspect registered stores.',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (entry.provenance !== 'managed') {
|
|
100
|
+
throw new StoreError('Refusing to delete an external checkout.', {
|
|
101
|
+
severity: 'error',
|
|
102
|
+
code: 'store_external_delete_forbidden',
|
|
103
|
+
message: 'External checkouts cannot be deleted by SpecFlow.',
|
|
104
|
+
target: `stores.${input.id}`,
|
|
105
|
+
fix: 'Unregister the store instead of removing it.',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const checkoutPath = entry.backend.local_path;
|
|
109
|
+
const identityPath = getStoreIdentityPath(checkoutPath);
|
|
110
|
+
let identityMatches = false;
|
|
111
|
+
try {
|
|
112
|
+
const raw = yaml.load(await fs.readFile(identityPath, 'utf-8'));
|
|
113
|
+
const parsed = StoreIdentitySchema.safeParse(raw);
|
|
114
|
+
identityMatches = parsed.success && parsed.data.id === input.id;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
identityMatches = false;
|
|
118
|
+
}
|
|
119
|
+
if (!identityMatches) {
|
|
120
|
+
throw new StoreError('Refusing to delete checkout with missing or mismatched identity.', {
|
|
121
|
+
severity: 'error',
|
|
122
|
+
code: 'store_identity_mismatch',
|
|
123
|
+
message: 'Checkout identity does not match the requested store ID.',
|
|
124
|
+
target: `stores.${input.id}`,
|
|
125
|
+
fix: 'Unregister the store instead of removing it.',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
await unregisterStoreEntry(registryPath, input.id);
|
|
129
|
+
const deleteCheckout = input.deleteCheckout ??
|
|
130
|
+
(async (target) => {
|
|
131
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
await deleteCheckout(checkoutPath);
|
|
135
|
+
return { registryRemoved: true, checkoutDeleted: true, diagnostics: [] };
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return {
|
|
139
|
+
registryRemoved: true,
|
|
140
|
+
checkoutDeleted: false,
|
|
141
|
+
diagnostics: [
|
|
142
|
+
{
|
|
143
|
+
severity: 'error',
|
|
144
|
+
code: 'store_delete_failed',
|
|
145
|
+
message: `Managed store was unregistered but checkout deletion failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
146
|
+
target: `stores.${input.id}`,
|
|
147
|
+
fix: `Manually remove ${checkoutPath} or re-register the store if you still need it.`,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export async function listRegisteredStores(dataDir) {
|
|
154
|
+
const registry = await readRegistry(getStoreRegistryPath(dataDir));
|
|
155
|
+
return Object.entries(registry.stores).map(([id, entry]) => ({
|
|
156
|
+
id,
|
|
157
|
+
root: entry.backend.local_path,
|
|
158
|
+
provenance: entry.provenance,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
async function readStoreIdentity(root) {
|
|
162
|
+
const identityPath = getStoreIdentityPath(root);
|
|
163
|
+
try {
|
|
164
|
+
const raw = yaml.load(await fs.readFile(identityPath, 'utf-8'));
|
|
165
|
+
const parsed = StoreIdentitySchema.safeParse(raw);
|
|
166
|
+
return parsed.success ? parsed.data : null;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function isHealthySpecflowRoot(root) {
|
|
173
|
+
const configPath = path.join(root, 'specflow', 'config.yaml');
|
|
174
|
+
return pathExists(configPath);
|
|
175
|
+
}
|
|
176
|
+
export async function prepareStoreRegistration(input) {
|
|
177
|
+
validateStoreId(input.id);
|
|
178
|
+
const targetPath = path.resolve(input.localPath);
|
|
179
|
+
if (!(await pathExists(targetPath))) {
|
|
180
|
+
throw new StoreError('Registration path does not exist.', {
|
|
181
|
+
severity: 'error',
|
|
182
|
+
code: 'store_path_missing',
|
|
183
|
+
message: `Path '${targetPath}' does not exist.`,
|
|
184
|
+
target: 'store.path',
|
|
185
|
+
fix: 'Provide an existing checkout path.',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const stat = await fs.stat(targetPath);
|
|
189
|
+
if (!stat.isDirectory()) {
|
|
190
|
+
throw new StoreError('Registration path must be a directory.', {
|
|
191
|
+
severity: 'error',
|
|
192
|
+
code: 'store_path_not_directory',
|
|
193
|
+
message: 'Store registration requires a directory checkout.',
|
|
194
|
+
target: 'store.path',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (!(await isHealthySpecflowRoot(targetPath))) {
|
|
198
|
+
throw new StoreError('Registration path is not a healthy SpecFlow root.', {
|
|
199
|
+
severity: 'error',
|
|
200
|
+
code: 'store_unhealthy_root',
|
|
201
|
+
message: 'Expected specflow/config.yaml under the checkout.',
|
|
202
|
+
target: 'store.path',
|
|
203
|
+
fix: 'Run specflow init in the checkout or choose a valid planning root.',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const existingIdentity = await readStoreIdentity(targetPath);
|
|
207
|
+
if (existingIdentity && existingIdentity.id !== input.id) {
|
|
208
|
+
throw new StoreError('Committed store identity conflicts with requested ID.', {
|
|
209
|
+
severity: 'error',
|
|
210
|
+
code: 'store_identity_conflict',
|
|
211
|
+
message: `Checkout identity is '${existingIdentity.id}' but registration requested '${input.id}'.`,
|
|
212
|
+
target: 'store.id',
|
|
213
|
+
fix: `Register as '${existingIdentity.id}' or choose a different checkout.`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const createdArtifacts = [];
|
|
217
|
+
let identityToWrite = existingIdentity;
|
|
218
|
+
if (!existingIdentity) {
|
|
219
|
+
if (!input.confirmIdentity) {
|
|
220
|
+
throw new StoreError('Identity adoption requires explicit confirmation.', {
|
|
221
|
+
severity: 'error',
|
|
222
|
+
code: 'store_identity_confirmation_required',
|
|
223
|
+
message: 'Non-interactive registration must pass --yes to create store identity.',
|
|
224
|
+
target: 'store.id',
|
|
225
|
+
fix: `Re-run with --yes to create .specflow-store/store.yaml for '${input.id}'.`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
identityToWrite = { version: 1, id: input.id };
|
|
229
|
+
const identityPath = await writeStoreIdentity(targetPath, identityToWrite);
|
|
230
|
+
createdArtifacts.push(identityPath);
|
|
231
|
+
}
|
|
232
|
+
const registryPath = getStoreRegistryPath(input.dataDir);
|
|
233
|
+
try {
|
|
234
|
+
const { alreadyRegistered } = await registerStoreEntry(registryPath, {
|
|
235
|
+
id: input.id,
|
|
236
|
+
localPath: targetPath,
|
|
237
|
+
provenance: 'external',
|
|
238
|
+
remote: identityToWrite?.remote,
|
|
239
|
+
});
|
|
240
|
+
return {
|
|
241
|
+
store: { id: input.id, root: targetPath },
|
|
242
|
+
alreadyRegistered,
|
|
243
|
+
createdArtifacts,
|
|
244
|
+
diagnostics: [],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
for (const artifact of [...createdArtifacts].reverse()) {
|
|
249
|
+
await fs.rm(artifact, { force: true }).catch(() => undefined);
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export async function unregisterStore(input) {
|
|
255
|
+
const registryPath = getStoreRegistryPath(input.dataDir);
|
|
256
|
+
const registry = await readRegistry(registryPath);
|
|
257
|
+
if (!registry.stores[input.id]) {
|
|
258
|
+
throw new StoreError(`Store '${input.id}' is not registered.`, {
|
|
259
|
+
severity: 'error',
|
|
260
|
+
code: 'store_not_registered',
|
|
261
|
+
message: `Store '${input.id}' is not registered.`,
|
|
262
|
+
target: `stores.${input.id}`,
|
|
263
|
+
fix: 'Run specflow store list to inspect registered stores.',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
await unregisterStoreEntry(registryPath, input.id);
|
|
267
|
+
return { removed: true, diagnostics: [] };
|
|
268
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type StoreRegistry } from './foundation.js';
|
|
2
|
+
export declare function readRegistry(registryPath: string): Promise<StoreRegistry>;
|
|
3
|
+
export declare function updateRegistry(registryPath: string, updater: (state: StoreRegistry) => StoreRegistry): Promise<StoreRegistry>;
|
|
4
|
+
export declare function findPathConflict(registry: StoreRegistry, targetPath: string, storeId: string, platform?: NodeJS.Platform): string | null;
|
|
5
|
+
export declare function registerStoreEntry(registryPath: string, input: {
|
|
6
|
+
id: string;
|
|
7
|
+
localPath: string;
|
|
8
|
+
provenance: 'managed' | 'external';
|
|
9
|
+
remote?: string;
|
|
10
|
+
branch?: string;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
registry: StoreRegistry;
|
|
13
|
+
alreadyRegistered: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function unregisterStoreEntry(registryPath: string, storeId: string): Promise<StoreRegistry>;
|