@brainervirus/workit-core 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +6 -1
- package/src/core/auto-approval.ts +34 -14
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +886 -172
- package/src/core/external-action.ts +214 -63
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +56 -52
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +7 -1
- package/src/core/task-engine.ts +47 -2
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workspaces.ts +548 -46
- package/src/core.ts +1 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
package/src/core/cutover.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
+
closeSync,
|
|
3
4
|
cpSync,
|
|
4
5
|
existsSync,
|
|
6
|
+
lstatSync,
|
|
5
7
|
mkdirSync,
|
|
8
|
+
opendirSync,
|
|
9
|
+
openSync,
|
|
6
10
|
readFileSync,
|
|
11
|
+
readSync,
|
|
7
12
|
readdirSync,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
writeFileSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
constants as fsConstants,
|
|
11
15
|
} from "node:fs";
|
|
12
16
|
import os from "node:os";
|
|
13
17
|
import path from "node:path";
|
|
@@ -27,6 +31,8 @@ import {
|
|
|
27
31
|
mergeOpenCodeConfig,
|
|
28
32
|
} from "./registration";
|
|
29
33
|
import { WORKIT_METHOD_SKILLS, WORKIT_SKILL_ALIASES } from "./skill-manifests";
|
|
34
|
+
import { writeFileAtomic, writeFileExclusive } from "./safe-write";
|
|
35
|
+
import { LEGACY_CURSOR_ASSET_DIGESTS } from "./legacy-ownership";
|
|
30
36
|
|
|
31
37
|
export type CutoverHost = "opencode" | "cursor" | "codex" | "pi";
|
|
32
38
|
|
|
@@ -36,9 +42,37 @@ export type SessionObservation = {
|
|
|
36
42
|
state: "stopped" | "active" | "unknown";
|
|
37
43
|
};
|
|
38
44
|
|
|
45
|
+
export type CutoverInventoryEntry = {
|
|
46
|
+
path: string;
|
|
47
|
+
kind: "file" | "directory" | "symlink" | "other";
|
|
48
|
+
category:
|
|
49
|
+
| "configuration"
|
|
50
|
+
| "recovery"
|
|
51
|
+
| "project-state"
|
|
52
|
+
| "project-history"
|
|
53
|
+
| "host-configuration"
|
|
54
|
+
| "integration";
|
|
55
|
+
disposition: "preserve" | "convert" | "unknown";
|
|
56
|
+
bytes: number;
|
|
57
|
+
digest: Digest | null;
|
|
58
|
+
ownershipEvidence: string | null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type CutoverInventory = {
|
|
62
|
+
complete: boolean;
|
|
63
|
+
entries: CutoverInventoryEntry[];
|
|
64
|
+
files: number;
|
|
65
|
+
bytes: number;
|
|
66
|
+
bytesByCategory: Partial<Record<CutoverInventoryEntry["category"], number>>;
|
|
67
|
+
issues: string[];
|
|
68
|
+
revision: Digest;
|
|
69
|
+
};
|
|
70
|
+
|
|
39
71
|
export type CutoverPlan = {
|
|
40
72
|
id: Id;
|
|
73
|
+
archiveDir: string | null;
|
|
41
74
|
managedFiles: { path: string; beforeDigest: Digest | null; afterDigest: Digest }[];
|
|
75
|
+
inventory: CutoverInventory;
|
|
42
76
|
sessions: SessionObservation[];
|
|
43
77
|
unresolved: { key: string; reason: string }[];
|
|
44
78
|
conversion: ConversionPreview;
|
|
@@ -48,6 +82,7 @@ export type CutoverPlan = {
|
|
|
48
82
|
|
|
49
83
|
export type CutoverReceipt = {
|
|
50
84
|
backupId: Id;
|
|
85
|
+
archiveDir: string;
|
|
51
86
|
planId: Id;
|
|
52
87
|
generation: "legacy" | "v1";
|
|
53
88
|
hosts: CutoverHost[];
|
|
@@ -56,6 +91,23 @@ export type CutoverReceipt = {
|
|
|
56
91
|
notes: string[];
|
|
57
92
|
};
|
|
58
93
|
|
|
94
|
+
type CutoverJournal = {
|
|
95
|
+
schemaVersion: 1;
|
|
96
|
+
backupId: Id;
|
|
97
|
+
archiveDir: string;
|
|
98
|
+
planId: Id;
|
|
99
|
+
hosts: CutoverHost[];
|
|
100
|
+
resolutions: Record<string, string>;
|
|
101
|
+
conversionNeeded: boolean;
|
|
102
|
+
phase: "applying" | "interrupted" | "partial" | "complete";
|
|
103
|
+
completedSteps: string[];
|
|
104
|
+
appliedHosts: CutoverHost[];
|
|
105
|
+
currentStep: string | null;
|
|
106
|
+
error: string | null;
|
|
107
|
+
startedAt: string;
|
|
108
|
+
notes: string[];
|
|
109
|
+
};
|
|
110
|
+
|
|
59
111
|
export type CutoverDecision = {
|
|
60
112
|
approve: true;
|
|
61
113
|
hosts: CutoverHost[];
|
|
@@ -64,21 +116,30 @@ export type CutoverDecision = {
|
|
|
64
116
|
|
|
65
117
|
export type RollbackPreview = {
|
|
66
118
|
backupId: Id;
|
|
67
|
-
restorable: {
|
|
68
|
-
|
|
119
|
+
restorable: {
|
|
120
|
+
path: string;
|
|
121
|
+
currentDigest: Digest;
|
|
122
|
+
installedDigest: Digest;
|
|
123
|
+
restore: "backup" | "remove";
|
|
124
|
+
}[];
|
|
125
|
+
conflicts: { path: string; currentDigest: Digest | null; installedDigest: Digest }[];
|
|
69
126
|
preserved: string[];
|
|
127
|
+
issues: string[];
|
|
70
128
|
};
|
|
71
129
|
|
|
72
130
|
export type CutoverPaths = {
|
|
73
131
|
home?: string;
|
|
74
132
|
configDir?: string;
|
|
75
133
|
stateDir?: string;
|
|
134
|
+
archiveDir?: string;
|
|
76
135
|
dev?: string | null;
|
|
77
136
|
workspace?: string;
|
|
78
137
|
opencodeConfig?: string;
|
|
79
138
|
cursorSettings?: string;
|
|
80
139
|
cursorMcp?: string;
|
|
81
140
|
cursorPluginDir?: string;
|
|
141
|
+
piConfig?: string;
|
|
142
|
+
piSettings?: string;
|
|
82
143
|
sessions?: SessionObservation[];
|
|
83
144
|
env?: NodeJS.ProcessEnv;
|
|
84
145
|
};
|
|
@@ -89,6 +150,8 @@ export type GenerationState = {
|
|
|
89
150
|
};
|
|
90
151
|
|
|
91
152
|
const GENERATION_FILE = "generation.json";
|
|
153
|
+
const CUTOVER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
154
|
+
const validCutoverId = (value: string): boolean => CUTOVER_ID.test(value);
|
|
92
155
|
|
|
93
156
|
const resolvePaths = (options: CutoverPaths = {}) => {
|
|
94
157
|
const home = options.home ?? options.env?.HOME ?? os.homedir();
|
|
@@ -100,6 +163,7 @@ const resolvePaths = (options: CutoverPaths = {}) => {
|
|
|
100
163
|
options.stateDir ??
|
|
101
164
|
options.env?.WORKFLOW_TOOLKIT_STATE ??
|
|
102
165
|
path.join(home, ".local", "state", "workit");
|
|
166
|
+
const piAgentDir = options.env?.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent");
|
|
103
167
|
return {
|
|
104
168
|
home,
|
|
105
169
|
configDir,
|
|
@@ -112,13 +176,79 @@ const resolvePaths = (options: CutoverPaths = {}) => {
|
|
|
112
176
|
cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
|
|
113
177
|
cursorPluginDir:
|
|
114
178
|
options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
|
|
179
|
+
piConfig: options.piConfig ?? path.join(home, ".pi", "config.json"),
|
|
180
|
+
piSettings: options.piSettings ?? path.join(piAgentDir, "settings.json"),
|
|
115
181
|
sessions: options.sessions ?? [],
|
|
116
182
|
};
|
|
117
183
|
};
|
|
118
184
|
|
|
185
|
+
const MAX_CUTOVER_INVENTORY_ENTRIES = 50_000;
|
|
186
|
+
const MAX_CUTOVER_INVENTORY_DEPTH = 64;
|
|
187
|
+
|
|
188
|
+
const isMissing = (error: unknown): boolean =>
|
|
189
|
+
typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
190
|
+
|
|
191
|
+
const lstatOrNull = (file: string) => {
|
|
192
|
+
try {
|
|
193
|
+
return lstatSync(file);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (isMissing(error)) return null;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const noSymlinkAncestor = (file: string): boolean => {
|
|
201
|
+
let current = path.resolve(file);
|
|
202
|
+
for (;;) {
|
|
203
|
+
const stat = lstatOrNull(current);
|
|
204
|
+
if (stat?.isSymbolicLink()) return false;
|
|
205
|
+
const parent = path.dirname(current);
|
|
206
|
+
if (parent === current) return true;
|
|
207
|
+
current = parent;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const readDirNamesBounded = (
|
|
212
|
+
dir: string,
|
|
213
|
+
limit: number,
|
|
214
|
+
): { names: string[]; truncated: boolean } => {
|
|
215
|
+
const handle = opendirSync(dir);
|
|
216
|
+
const names: string[] = [];
|
|
217
|
+
try {
|
|
218
|
+
for (;;) {
|
|
219
|
+
const entry = handle.readSync();
|
|
220
|
+
if (!entry) return { names: names.sort(), truncated: false };
|
|
221
|
+
if (names.length === limit) return { names: names.sort(), truncated: true };
|
|
222
|
+
names.push(entry.name);
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
handle.closeSync();
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
119
229
|
export const digestFile = (file: string): Digest | null => {
|
|
120
|
-
if (!
|
|
121
|
-
|
|
230
|
+
if (!noSymlinkAncestor(path.dirname(file))) return null;
|
|
231
|
+
const stat = lstatOrNull(file);
|
|
232
|
+
if (!stat?.isFile() || stat.isSymbolicLink()) return null;
|
|
233
|
+
let fd: number | undefined;
|
|
234
|
+
try {
|
|
235
|
+
fd = openSync(file, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
236
|
+
const hash = createHash("sha256");
|
|
237
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
238
|
+
let offset = 0;
|
|
239
|
+
for (;;) {
|
|
240
|
+
const read = readSync(fd, chunk, 0, chunk.length, offset);
|
|
241
|
+
if (read === 0) break;
|
|
242
|
+
hash.update(chunk.subarray(0, read));
|
|
243
|
+
offset += read;
|
|
244
|
+
}
|
|
245
|
+
return hash.digest("hex");
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (isMissing(error)) return null;
|
|
248
|
+
throw error;
|
|
249
|
+
} finally {
|
|
250
|
+
if (fd !== undefined) closeSync(fd);
|
|
251
|
+
}
|
|
122
252
|
};
|
|
123
253
|
|
|
124
254
|
export const readGenerationState = (configDir: string): GenerationState => {
|
|
@@ -134,8 +264,7 @@ export const readGenerationState = (configDir: string): GenerationState => {
|
|
|
134
264
|
};
|
|
135
265
|
|
|
136
266
|
export const writeGenerationState = (configDir: string, state: GenerationState): void => {
|
|
137
|
-
|
|
138
|
-
writeFileSync(path.join(configDir, GENERATION_FILE), JSON.stringify(state, null, 2) + "\n");
|
|
267
|
+
writeFileAtomic(path.join(configDir, GENERATION_FILE), JSON.stringify(state, null, 2) + "\n");
|
|
139
268
|
};
|
|
140
269
|
|
|
141
270
|
const listSkillNames = (root: string): string[] => {
|
|
@@ -168,22 +297,13 @@ export const classifyHostGeneration = (
|
|
|
168
297
|
try {
|
|
169
298
|
const cfg = JSON.parse(readFileSync(paths.opencodeConfig, "utf8"));
|
|
170
299
|
const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : cfg.plugin ? [cfg.plugin] : [];
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
);
|
|
175
|
-
const hasWorkit = plugins.some((p: unknown) => isWorkitPlugin(p));
|
|
176
|
-
const devAssets =
|
|
177
|
-
paths.dev &&
|
|
178
|
-
existsSync(path.join(paths.dev, "packages/workit-opencode/assets/commands/wk-init.md"));
|
|
179
|
-
const legacy = hasLegacyPin || Boolean(devAssets);
|
|
180
|
-
const v1 =
|
|
181
|
-
hasWorkit &&
|
|
182
|
-
paths.dev &&
|
|
183
|
-
existsSync(path.join(paths.dev, "packages/workit-opencode/dist/plugin.js"));
|
|
300
|
+
const workitPins = plugins.filter((p: unknown) => isWorkitPlugin(p));
|
|
301
|
+
const canonicalV1Pin = `file://${paths.dev}/packages/workit-opencode/dist/plugin.js`;
|
|
302
|
+
const v1 = workitPins.some((p: unknown) => String(p) === canonicalV1Pin);
|
|
303
|
+
const legacy = workitPins.some((p: unknown) => String(p) !== canonicalV1Pin);
|
|
184
304
|
if (legacy && v1) return "mixed";
|
|
185
305
|
if (v1) return "v1";
|
|
186
|
-
if (legacy
|
|
306
|
+
if (legacy) return "legacy";
|
|
187
307
|
return "none";
|
|
188
308
|
} catch {
|
|
189
309
|
return "none";
|
|
@@ -196,14 +316,29 @@ export const classifyHostGeneration = (
|
|
|
196
316
|
return existsSync(path.join(plugin, ".codex-plugin")) ? "v1" : "legacy";
|
|
197
317
|
}
|
|
198
318
|
if (host === "pi") {
|
|
199
|
-
const
|
|
200
|
-
|
|
319
|
+
const extension = paths.dev && path.resolve(paths.dev, "packages/workit-pi/dist/workit.js");
|
|
320
|
+
const packageDir = paths.dev && path.resolve(paths.dev, "packages/workit-pi");
|
|
321
|
+
if (!existsSync(paths.piConfig) && !existsSync(paths.piSettings)) return "none";
|
|
201
322
|
try {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
323
|
+
const config = existsSync(paths.piConfig)
|
|
324
|
+
? JSON.parse(readFileSync(paths.piConfig, "utf8"))
|
|
325
|
+
: {};
|
|
326
|
+
const settings = existsSync(paths.piSettings)
|
|
327
|
+
? JSON.parse(readFileSync(paths.piSettings, "utf8"))
|
|
328
|
+
: {};
|
|
329
|
+
const ext = config?.extensions ?? config?.pi?.extensions ?? [];
|
|
330
|
+
const packages = settings?.packages ?? [];
|
|
331
|
+
const has =
|
|
332
|
+
(typeof extension === "string" &&
|
|
333
|
+
Array.isArray(ext) &&
|
|
334
|
+
ext.some(
|
|
335
|
+
(entry: unknown) => typeof entry === "string" && path.resolve(entry) === extension,
|
|
336
|
+
)) ||
|
|
337
|
+
(typeof packageDir === "string" &&
|
|
338
|
+
Array.isArray(packages) &&
|
|
339
|
+
packages.some(
|
|
340
|
+
(entry: unknown) => typeof entry === "string" && path.resolve(entry) === packageDir,
|
|
341
|
+
));
|
|
207
342
|
return has ? "v1" : "none";
|
|
208
343
|
} catch {
|
|
209
344
|
return "none";
|
|
@@ -264,45 +399,431 @@ const managedCutoverFiles = (paths: ReturnType<typeof resolvePaths>): string[] =
|
|
|
264
399
|
paths.opencodeConfig,
|
|
265
400
|
paths.cursorSettings,
|
|
266
401
|
paths.cursorMcp,
|
|
402
|
+
paths.piConfig,
|
|
403
|
+
paths.piSettings,
|
|
267
404
|
path.join(paths.cursorPluginDir, "hooks", "hooks-cursor.json"),
|
|
268
405
|
];
|
|
269
406
|
|
|
407
|
+
type CutoverScope = {
|
|
408
|
+
path: string;
|
|
409
|
+
category: CutoverInventoryEntry["category"];
|
|
410
|
+
disposition: CutoverInventoryEntry["disposition"];
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const exactRegistrationEvidence = (
|
|
414
|
+
file: string,
|
|
415
|
+
paths: ReturnType<typeof resolvePaths>,
|
|
416
|
+
): string | null => {
|
|
417
|
+
try {
|
|
418
|
+
if (!noSymlinkAncestor(path.dirname(file))) return null;
|
|
419
|
+
const fileStat = lstatOrNull(file);
|
|
420
|
+
if (!fileStat?.isFile() || fileStat.isSymbolicLink()) return null;
|
|
421
|
+
if (file === paths.opencodeConfig) {
|
|
422
|
+
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
423
|
+
const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : cfg.plugin ? [cfg.plugin] : [];
|
|
424
|
+
const workit = plugins.filter((plugin: unknown) => isWorkitPlugin(plugin));
|
|
425
|
+
return workit.length > 0 ? "exact Workit plugin entry in OpenCode config" : null;
|
|
426
|
+
}
|
|
427
|
+
if (file === paths.cursorSettings) {
|
|
428
|
+
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
429
|
+
const enabled = cfg?.enabled_plugins;
|
|
430
|
+
const pluginEnabled =
|
|
431
|
+
enabled &&
|
|
432
|
+
typeof enabled === "object" &&
|
|
433
|
+
Object.entries(enabled).some(([name, value]) => isWorkitPlugin(name) && value === true);
|
|
434
|
+
const dirs = Array.isArray(cfg?.plugin_dirs) ? cfg.plugin_dirs : [];
|
|
435
|
+
const dirRegistered = dirs.some(
|
|
436
|
+
(dir: unknown) => typeof dir === "string" && path.resolve(dir) === paths.cursorPluginDir,
|
|
437
|
+
);
|
|
438
|
+
return pluginEnabled || dirRegistered
|
|
439
|
+
? "exact Workit plugin key or directory in Cursor settings"
|
|
440
|
+
: null;
|
|
441
|
+
}
|
|
442
|
+
if (file === paths.cursorMcp) {
|
|
443
|
+
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
444
|
+
const servers = cfg?.mcpServers;
|
|
445
|
+
const workit =
|
|
446
|
+
servers &&
|
|
447
|
+
typeof servers === "object" &&
|
|
448
|
+
Object.entries(servers).some(([name, value]) => {
|
|
449
|
+
if (isWorkitPlugin(name)) return true;
|
|
450
|
+
const server = value as { command?: unknown; args?: unknown } | null;
|
|
451
|
+
return (
|
|
452
|
+
server?.command === "npx" &&
|
|
453
|
+
Array.isArray(server.args) &&
|
|
454
|
+
server.args.includes(`--package=${CURSOR_RUNTIME_PACKAGE}`)
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
return workit ? "exact Workit MCP server entry in Cursor config" : null;
|
|
458
|
+
}
|
|
459
|
+
if (file === paths.piConfig) {
|
|
460
|
+
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
461
|
+
const ext = paths.dev && path.join(paths.dev, "packages/workit-pi/dist/workit.js");
|
|
462
|
+
const skills = paths.dev && path.join(paths.dev, "packages/workit-pi/skills");
|
|
463
|
+
const matches = (value: unknown, target: string | null): boolean =>
|
|
464
|
+
target !== null &&
|
|
465
|
+
typeof value === "string" &&
|
|
466
|
+
path.resolve(value) === path.resolve(target);
|
|
467
|
+
const extensions = cfg?.extensions ?? cfg?.pi?.extensions;
|
|
468
|
+
const skillPaths = cfg?.skills ?? cfg?.pi?.skills;
|
|
469
|
+
if (
|
|
470
|
+
(Array.isArray(extensions) && extensions.some((entry: unknown) => matches(entry, ext))) ||
|
|
471
|
+
(Array.isArray(skillPaths) && skillPaths.some((entry: unknown) => matches(entry, skills)))
|
|
472
|
+
) {
|
|
473
|
+
return "exact Workit extension or skills path in Pi config";
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (file === paths.piSettings) {
|
|
477
|
+
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
478
|
+
const packageDir = paths.dev && path.join(paths.dev, "packages/workit-pi");
|
|
479
|
+
const matches = (entry: unknown): boolean =>
|
|
480
|
+
typeof packageDir === "string" &&
|
|
481
|
+
typeof entry === "string" &&
|
|
482
|
+
path.resolve(entry) === path.resolve(packageDir);
|
|
483
|
+
if (Array.isArray(cfg?.packages) && cfg.packages.some(matches)) {
|
|
484
|
+
return "exact Workit package path in Pi settings";
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
return null;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const appendExactPath = (value: unknown, target: string): unknown[] => {
|
|
494
|
+
const entries = Array.isArray(value) ? value : value === undefined ? [] : [value];
|
|
495
|
+
return [
|
|
496
|
+
...entries.filter(
|
|
497
|
+
(entry) => typeof entry !== "string" || path.resolve(entry) !== path.resolve(target),
|
|
498
|
+
),
|
|
499
|
+
target,
|
|
500
|
+
];
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const mergePiConfig = (current: Record<string, unknown>, dev: string): Record<string, unknown> => {
|
|
504
|
+
const nestedPi =
|
|
505
|
+
current.pi && typeof current.pi === "object" && !Array.isArray(current.pi)
|
|
506
|
+
? (current.pi as Record<string, unknown>)
|
|
507
|
+
: null;
|
|
508
|
+
const target = nestedPi ?? current;
|
|
509
|
+
const extension = path.join(dev, "packages/workit-pi/dist/workit.js");
|
|
510
|
+
const skills = path.join(dev, "packages/workit-pi/skills");
|
|
511
|
+
return nestedPi
|
|
512
|
+
? {
|
|
513
|
+
...current,
|
|
514
|
+
pi: {
|
|
515
|
+
...nestedPi,
|
|
516
|
+
extensions: appendExactPath(nestedPi.extensions, extension),
|
|
517
|
+
skills: appendExactPath(nestedPi.skills, skills),
|
|
518
|
+
},
|
|
519
|
+
}
|
|
520
|
+
: {
|
|
521
|
+
...target,
|
|
522
|
+
extensions: appendExactPath(target.extensions, extension),
|
|
523
|
+
skills: appendExactPath(target.skills, skills),
|
|
524
|
+
};
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const mergePiSettings = (
|
|
528
|
+
current: Record<string, unknown>,
|
|
529
|
+
dev: string,
|
|
530
|
+
): Record<string, unknown> => ({
|
|
531
|
+
...current,
|
|
532
|
+
packages: appendExactPath(current.packages, path.join(dev, "packages/workit-pi")),
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const legacyFlowInventoryTargets = (workspace: string, issues: Set<string>): string[] => {
|
|
536
|
+
const docs = path.join(workspace, "docs");
|
|
537
|
+
let docsStat: ReturnType<typeof lstatSync> | null;
|
|
538
|
+
try {
|
|
539
|
+
if (!noSymlinkAncestor(path.dirname(docs))) {
|
|
540
|
+
issues.add(`symlinked parent is outside the inventoried scope: ${path.dirname(docs)}`);
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
543
|
+
docsStat = lstatOrNull(docs);
|
|
544
|
+
} catch (error) {
|
|
545
|
+
issues.add(`cannot inspect project history at ${docs}: ${String(error)}`);
|
|
546
|
+
return [];
|
|
547
|
+
}
|
|
548
|
+
if (!docsStat) return [];
|
|
549
|
+
if (docsStat.isSymbolicLink()) return [docs];
|
|
550
|
+
if (!docsStat.isDirectory()) return [];
|
|
551
|
+
|
|
552
|
+
const targets: string[] = [];
|
|
553
|
+
try {
|
|
554
|
+
const { names: slugs, truncated } = readDirNamesBounded(docs, MAX_CUTOVER_INVENTORY_ENTRIES);
|
|
555
|
+
if (truncated) {
|
|
556
|
+
issues.add(`project history inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} entries`);
|
|
557
|
+
}
|
|
558
|
+
for (const slug of slugs) {
|
|
559
|
+
const slugPath = path.join(docs, slug);
|
|
560
|
+
const slugStat = lstatOrNull(slugPath);
|
|
561
|
+
if (!slugStat) continue;
|
|
562
|
+
if (slugStat.isSymbolicLink()) {
|
|
563
|
+
targets.push(slugPath);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (!slugStat.isDirectory()) continue;
|
|
567
|
+
const sddPath = path.join(slugPath, "sdd");
|
|
568
|
+
const sddStat = lstatOrNull(sddPath);
|
|
569
|
+
if (!sddStat) continue;
|
|
570
|
+
if (sddStat.isSymbolicLink()) {
|
|
571
|
+
targets.push(sddPath);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (!sddStat.isDirectory()) continue;
|
|
575
|
+
const flow = path.join(sddPath, "flow.json");
|
|
576
|
+
if (lstatOrNull(flow)) targets.push(flow);
|
|
577
|
+
if (targets.length >= MAX_CUTOVER_INVENTORY_ENTRIES) {
|
|
578
|
+
issues.add(`project history inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} entries`);
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
} catch (error) {
|
|
583
|
+
issues.add(`cannot enumerate project history at ${docs}: ${String(error)}`);
|
|
584
|
+
}
|
|
585
|
+
return targets;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const cutoverInventoryScopes = (
|
|
589
|
+
paths: ReturnType<typeof resolvePaths>,
|
|
590
|
+
issues: Set<string>,
|
|
591
|
+
): CutoverScope[] => [
|
|
592
|
+
{ path: paths.configDir, category: "configuration", disposition: "preserve" },
|
|
593
|
+
{ path: paths.stateDir, category: "recovery", disposition: "preserve" },
|
|
594
|
+
{
|
|
595
|
+
path: path.join(paths.workspace, ".workit"),
|
|
596
|
+
category: "project-state",
|
|
597
|
+
disposition: "preserve",
|
|
598
|
+
},
|
|
599
|
+
...legacyFlowInventoryTargets(paths.workspace, issues).map((path) => ({
|
|
600
|
+
path,
|
|
601
|
+
category: "project-history" as const,
|
|
602
|
+
disposition: "preserve" as const,
|
|
603
|
+
})),
|
|
604
|
+
{ path: paths.opencodeConfig, category: "host-configuration", disposition: "unknown" },
|
|
605
|
+
{ path: paths.cursorSettings, category: "host-configuration", disposition: "unknown" },
|
|
606
|
+
{ path: paths.cursorMcp, category: "host-configuration", disposition: "unknown" },
|
|
607
|
+
{
|
|
608
|
+
path: paths.cursorPluginDir,
|
|
609
|
+
category: "integration",
|
|
610
|
+
disposition: "unknown",
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
path: path.join(paths.home, ".codex", "plugins", "workit"),
|
|
614
|
+
category: "integration",
|
|
615
|
+
disposition: "unknown",
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
path: paths.piConfig,
|
|
619
|
+
category: "host-configuration",
|
|
620
|
+
disposition: "unknown",
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
path: paths.piSettings,
|
|
624
|
+
category: "host-configuration",
|
|
625
|
+
disposition: "unknown",
|
|
626
|
+
},
|
|
627
|
+
];
|
|
628
|
+
|
|
629
|
+
const buildCutoverInventory = (paths: ReturnType<typeof resolvePaths>): CutoverInventory => {
|
|
630
|
+
const entries: CutoverInventoryEntry[] = [];
|
|
631
|
+
const issues = new Set<string>();
|
|
632
|
+
const seen = new Set<string>();
|
|
633
|
+
const cursorPluginRegistered = exactRegistrationEvidence(paths.cursorSettings, paths) !== null;
|
|
634
|
+
const add = (scope: CutoverScope, file: string, depth: number): void => {
|
|
635
|
+
if (entries.length >= MAX_CUTOVER_INVENTORY_ENTRIES) {
|
|
636
|
+
issues.add(`cutover inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} entries`);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (seen.has(file)) return;
|
|
640
|
+
seen.add(file);
|
|
641
|
+
|
|
642
|
+
if (depth === 0) {
|
|
643
|
+
try {
|
|
644
|
+
if (!noSymlinkAncestor(path.dirname(file))) {
|
|
645
|
+
issues.add(`symlinked parent is outside the inventoried scope: ${path.dirname(file)}`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
} catch (error) {
|
|
649
|
+
issues.add(`cannot inspect parent for ${file}: ${String(error)}`);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
let stat: ReturnType<typeof lstatSync> | null;
|
|
654
|
+
try {
|
|
655
|
+
stat = lstatOrNull(file);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
issues.add(`cannot inspect ${file}: ${String(error)}`);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (!stat) return;
|
|
661
|
+
|
|
662
|
+
const kind: CutoverInventoryEntry["kind"] = stat.isSymbolicLink()
|
|
663
|
+
? "symlink"
|
|
664
|
+
: stat.isDirectory()
|
|
665
|
+
? "directory"
|
|
666
|
+
: stat.isFile()
|
|
667
|
+
? "file"
|
|
668
|
+
: "other";
|
|
669
|
+
let digest: Digest | null = null;
|
|
670
|
+
if (kind === "file") {
|
|
671
|
+
try {
|
|
672
|
+
digest = digestFile(file);
|
|
673
|
+
if (digest === null) issues.add(`file changed during inventory: ${file}`);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
issues.add(`cannot read ${file}: ${String(error)}`);
|
|
676
|
+
}
|
|
677
|
+
} else if (kind === "symlink") {
|
|
678
|
+
issues.add(`symlink not followed during inventory: ${file}`);
|
|
679
|
+
} else if (kind === "other") {
|
|
680
|
+
issues.add(`unsupported filesystem entry during inventory: ${file}`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const inCursorPlugin =
|
|
684
|
+
file === paths.cursorPluginDir || file.startsWith(`${paths.cursorPluginDir}${path.sep}`);
|
|
685
|
+
const cursorAssetPath = inCursorPlugin
|
|
686
|
+
? path.relative(paths.cursorPluginDir, file).split(path.sep).join("/")
|
|
687
|
+
: "";
|
|
688
|
+
const expectedCursorDigest = LEGACY_CURSOR_ASSET_DIGESTS[cursorAssetPath];
|
|
689
|
+
const verifiedCursorAsset =
|
|
690
|
+
kind === "file" && digest !== null && expectedCursorDigest === digest;
|
|
691
|
+
const ownershipEvidence = verifiedCursorAsset
|
|
692
|
+
? `SHA-256 matches generated Cursor asset from Workit 1.2.1: ${cursorAssetPath}`
|
|
693
|
+
: kind === "file"
|
|
694
|
+
? exactRegistrationEvidence(file, paths)
|
|
695
|
+
: inCursorPlugin && cursorPluginRegistered
|
|
696
|
+
? "Cursor settings registers this plugin directory; individual files remain unverified"
|
|
697
|
+
: null;
|
|
698
|
+
const disposition =
|
|
699
|
+
file === path.join(paths.configDir, "config.json")
|
|
700
|
+
? "convert"
|
|
701
|
+
: verifiedCursorAsset || (ownershipEvidence && scope.category === "host-configuration")
|
|
702
|
+
? "convert"
|
|
703
|
+
: scope.disposition;
|
|
704
|
+
entries.push({
|
|
705
|
+
path: file,
|
|
706
|
+
kind,
|
|
707
|
+
category: scope.category,
|
|
708
|
+
disposition,
|
|
709
|
+
bytes: kind === "file" ? stat.size : 0,
|
|
710
|
+
digest,
|
|
711
|
+
ownershipEvidence,
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
if (kind !== "directory") return;
|
|
715
|
+
if (depth >= MAX_CUTOVER_INVENTORY_DEPTH) {
|
|
716
|
+
issues.add(`inventory depth exceeds ${MAX_CUTOVER_INVENTORY_DEPTH}: ${file}`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
const { names, truncated } = readDirNamesBounded(
|
|
721
|
+
file,
|
|
722
|
+
Math.max(0, MAX_CUTOVER_INVENTORY_ENTRIES - entries.length),
|
|
723
|
+
);
|
|
724
|
+
if (truncated)
|
|
725
|
+
issues.add(`cutover inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} entries`);
|
|
726
|
+
for (const name of names) add(scope, path.join(file, name), depth + 1);
|
|
727
|
+
} catch (error) {
|
|
728
|
+
issues.add(`cannot enumerate ${file}: ${String(error)}`);
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
for (const scope of cutoverInventoryScopes(paths, issues)) add(scope, scope.path, 0);
|
|
733
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
734
|
+
const bytesByCategory: CutoverInventory["bytesByCategory"] = {};
|
|
735
|
+
let files = 0;
|
|
736
|
+
let bytes = 0;
|
|
737
|
+
const hash = createHash("sha256");
|
|
738
|
+
for (const entry of entries) {
|
|
739
|
+
if (entry.kind === "file") {
|
|
740
|
+
files += 1;
|
|
741
|
+
bytes += entry.bytes;
|
|
742
|
+
bytesByCategory[entry.category] = (bytesByCategory[entry.category] ?? 0) + entry.bytes;
|
|
743
|
+
}
|
|
744
|
+
hash.update(`${entry.path}\0${entry.kind}\0${entry.bytes}\0${entry.digest ?? ""}\n`);
|
|
745
|
+
}
|
|
746
|
+
const issueList = [...issues].sort();
|
|
747
|
+
for (const issue of issueList) hash.update(`!${issue}\n`);
|
|
748
|
+
return {
|
|
749
|
+
complete: issueList.length === 0,
|
|
750
|
+
entries,
|
|
751
|
+
files,
|
|
752
|
+
bytes,
|
|
753
|
+
bytesByCategory,
|
|
754
|
+
issues: issueList,
|
|
755
|
+
revision: hash.digest("hex"),
|
|
756
|
+
};
|
|
757
|
+
};
|
|
758
|
+
|
|
270
759
|
const hostMutationTargets = (
|
|
271
760
|
paths: ReturnType<typeof resolvePaths>,
|
|
272
761
|
hosts: CutoverHost[],
|
|
273
762
|
): string[] => {
|
|
274
763
|
const targets: string[] = [];
|
|
275
764
|
for (const host of hosts) {
|
|
276
|
-
if (host === "cursor")
|
|
277
|
-
targets.push(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
765
|
+
if (host === "cursor")
|
|
766
|
+
targets.push(
|
|
767
|
+
path.join(paths.cursorPluginDir, "skills"),
|
|
768
|
+
path.join(paths.cursorPluginDir, "commands"),
|
|
769
|
+
path.join(paths.cursorPluginDir, "rules", "workit-contract.mdc"),
|
|
770
|
+
);
|
|
281
771
|
if (host === "codex") targets.push(path.join(paths.home, ".codex", "plugins", "workit"));
|
|
282
|
-
if (host === "pi") targets.push(
|
|
772
|
+
if (host === "pi") targets.push(paths.piConfig, paths.piSettings);
|
|
283
773
|
}
|
|
284
774
|
return targets;
|
|
285
775
|
};
|
|
286
776
|
|
|
287
|
-
const expandBackupTargets = (targets: string[]): string[] => {
|
|
777
|
+
const expandBackupTargets = (targets: string[]): { files: string[]; issues: string[] } => {
|
|
288
778
|
const files: string[] = [];
|
|
779
|
+
const issues = new Set<string>();
|
|
289
780
|
for (const target of targets) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
781
|
+
const walk = (file: string, depth: number): void => {
|
|
782
|
+
if (files.length >= MAX_CUTOVER_INVENTORY_ENTRIES) {
|
|
783
|
+
issues.add(`backup inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} files`);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (depth === 0 && !noSymlinkAncestor(path.dirname(file))) {
|
|
787
|
+
issues.add(`symlinked parent is outside the backup scope: ${path.dirname(file)}`);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
let stat: ReturnType<typeof lstatSync> | null;
|
|
791
|
+
try {
|
|
792
|
+
stat = lstatOrNull(file);
|
|
793
|
+
} catch (error) {
|
|
794
|
+
issues.add(`cannot inspect backup target ${file}: ${String(error)}`);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (!stat) return;
|
|
798
|
+
if (stat.isSymbolicLink()) {
|
|
799
|
+
issues.add(`symlink not followed in backup target: ${file}`);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (stat.isDirectory()) {
|
|
803
|
+
if (depth >= MAX_CUTOVER_INVENTORY_DEPTH) {
|
|
804
|
+
issues.add(`backup inventory depth exceeds ${MAX_CUTOVER_INVENTORY_DEPTH}: ${file}`);
|
|
805
|
+
return;
|
|
298
806
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
807
|
+
try {
|
|
808
|
+
const { names, truncated } = readDirNamesBounded(
|
|
809
|
+
file,
|
|
810
|
+
Math.max(0, MAX_CUTOVER_INVENTORY_ENTRIES - files.length),
|
|
811
|
+
);
|
|
812
|
+
if (truncated)
|
|
813
|
+
issues.add(`backup inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} files`);
|
|
814
|
+
for (const name of names) walk(path.join(file, name), depth + 1);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
issues.add(`cannot enumerate backup target ${file}: ${String(error)}`);
|
|
817
|
+
}
|
|
818
|
+
} else if (stat.isFile()) {
|
|
819
|
+
files.push(file);
|
|
820
|
+
} else {
|
|
821
|
+
issues.add(`unsupported backup entry: ${file}`);
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
walk(target, 0);
|
|
304
825
|
}
|
|
305
|
-
return files;
|
|
826
|
+
return { files, issues: [...issues] };
|
|
306
827
|
};
|
|
307
828
|
|
|
308
829
|
const cutoverBackupTargets = (
|
|
@@ -310,8 +831,29 @@ const cutoverBackupTargets = (
|
|
|
310
831
|
hosts: CutoverHost[],
|
|
311
832
|
): string[] => [...new Set([...managedCutoverFiles(paths), ...hostMutationTargets(paths, hosts)])];
|
|
312
833
|
|
|
313
|
-
const backupRoot = (
|
|
314
|
-
|
|
834
|
+
const backupRoot = (archiveDir: string, backupId: Id) => path.join(archiveDir, backupId);
|
|
835
|
+
|
|
836
|
+
const archiveDestinationError = (
|
|
837
|
+
archiveDir: string,
|
|
838
|
+
paths: ReturnType<typeof resolvePaths>,
|
|
839
|
+
): string | null => {
|
|
840
|
+
const resolved = path.resolve(archiveDir);
|
|
841
|
+
const scopes = [paths.configDir, paths.stateDir, paths.workspace].map((p) => path.resolve(p));
|
|
842
|
+
if (
|
|
843
|
+
scopes.some(
|
|
844
|
+
(scope) =>
|
|
845
|
+
resolved === scope ||
|
|
846
|
+
resolved.startsWith(scope + path.sep) ||
|
|
847
|
+
scope.startsWith(resolved + path.sep),
|
|
848
|
+
)
|
|
849
|
+
) {
|
|
850
|
+
return "archive destination must be separate from config, state, and workspace";
|
|
851
|
+
}
|
|
852
|
+
if (!noSymlinkAncestor(resolved)) return "archive destination has a symlinked ancestor";
|
|
853
|
+
const existing = lstatOrNull(resolved);
|
|
854
|
+
if (existing && !existing.isDirectory()) return "archive destination is not a directory";
|
|
855
|
+
return null;
|
|
856
|
+
};
|
|
315
857
|
|
|
316
858
|
const backupRelPath = (file: string, paths: ReturnType<typeof resolvePaths>): string => {
|
|
317
859
|
if (file.startsWith(paths.configDir + path.sep) || file === paths.configDir) {
|
|
@@ -320,24 +862,117 @@ const backupRelPath = (file: string, paths: ReturnType<typeof resolvePaths>): st
|
|
|
320
862
|
return path.join("home", path.relative(paths.home, file));
|
|
321
863
|
};
|
|
322
864
|
|
|
865
|
+
type VerifiedBackupFile = { digest: Digest; storagePath: string };
|
|
866
|
+
|
|
867
|
+
const readBackupFiles = (
|
|
868
|
+
backupId: Id,
|
|
869
|
+
archiveDir: string,
|
|
870
|
+
paths: ReturnType<typeof resolvePaths>,
|
|
871
|
+
): { files: Map<string, VerifiedBackupFile>; issues: string[] } => {
|
|
872
|
+
const root = backupRoot(archiveDir, backupId);
|
|
873
|
+
const manifestPath = path.join(root, "manifest.json");
|
|
874
|
+
const files = new Map<string, VerifiedBackupFile>();
|
|
875
|
+
const issues: string[] = [];
|
|
876
|
+
if (digestFile(manifestPath) === null) {
|
|
877
|
+
return { files, issues: ["backup manifest is missing or unsafe"] };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
let manifest: unknown;
|
|
881
|
+
try {
|
|
882
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
883
|
+
} catch {
|
|
884
|
+
return { files, issues: ["backup manifest is unreadable"] };
|
|
885
|
+
}
|
|
886
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
887
|
+
return { files, issues: ["backup manifest is malformed"] };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const data = manifest as Record<string, unknown>;
|
|
891
|
+
const hashed = data.schemaVersion === 1 && data.storage === "sha256-path-v1";
|
|
892
|
+
const legacy = data.schemaVersion === undefined && data.storage === undefined;
|
|
893
|
+
if (data.backupId !== backupId || (!hashed && !legacy) || !Array.isArray(data.files)) {
|
|
894
|
+
return { files, issues: ["backup manifest identity or format is invalid"] };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
for (const item of data.files) {
|
|
898
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
899
|
+
issues.push("backup manifest contains an invalid file entry");
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
const entry = item as Record<string, unknown>;
|
|
903
|
+
if (
|
|
904
|
+
typeof entry.path !== "string" ||
|
|
905
|
+
!path.isAbsolute(entry.path) ||
|
|
906
|
+
path.resolve(entry.path) !== entry.path
|
|
907
|
+
) {
|
|
908
|
+
issues.push("backup manifest contains an invalid file path");
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const digest = hashed ? entry.digest : entry.installedDigest;
|
|
912
|
+
if (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)) {
|
|
913
|
+
issues.push(`backup digest is invalid: ${entry.path}`);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
if (files.has(entry.path)) {
|
|
917
|
+
issues.push(`backup path is duplicated: ${entry.path}`);
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const relative = hashed
|
|
922
|
+
? path.join("files", createHash("sha256").update(entry.path).digest("hex"))
|
|
923
|
+
: backupRelPath(entry.path, paths);
|
|
924
|
+
const storagePath = path.resolve(root, relative);
|
|
925
|
+
const fromRoot = path.relative(path.resolve(root), storagePath);
|
|
926
|
+
if (
|
|
927
|
+
fromRoot === "" ||
|
|
928
|
+
fromRoot === ".." ||
|
|
929
|
+
fromRoot.startsWith(`..${path.sep}`) ||
|
|
930
|
+
path.isAbsolute(fromRoot)
|
|
931
|
+
) {
|
|
932
|
+
issues.push(`backup path escapes its directory: ${entry.path}`);
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (digestFile(storagePath) !== digest) {
|
|
936
|
+
issues.push(`backup content failed verification: ${entry.path}`);
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
files.set(entry.path, { digest: digest as Digest, storagePath });
|
|
940
|
+
}
|
|
941
|
+
return { files, issues };
|
|
942
|
+
};
|
|
943
|
+
|
|
323
944
|
const writeBackup = (
|
|
324
945
|
backupId: Id,
|
|
946
|
+
archiveDir: string,
|
|
325
947
|
paths: ReturnType<typeof resolvePaths>,
|
|
326
948
|
files: string[],
|
|
327
949
|
): void => {
|
|
328
|
-
const root = backupRoot(
|
|
950
|
+
const root = backupRoot(archiveDir, backupId);
|
|
329
951
|
mkdirSync(root, { recursive: true });
|
|
330
|
-
const manifest:
|
|
952
|
+
const manifest: { path: string; digest: Digest }[] = [];
|
|
331
953
|
for (const file of files) {
|
|
332
954
|
if (!existsSync(file)) continue;
|
|
333
|
-
const
|
|
955
|
+
const before = digestFile(file);
|
|
956
|
+
if (before === null) throw new Error(`backup source is not a regular file: ${file}`);
|
|
957
|
+
const dest = path.join(
|
|
958
|
+
root,
|
|
959
|
+
"files",
|
|
960
|
+
createHash("sha256").update(path.resolve(file)).digest("hex"),
|
|
961
|
+
);
|
|
334
962
|
mkdirSync(path.dirname(dest), { recursive: true });
|
|
335
|
-
cpSync(file, dest);
|
|
336
|
-
|
|
963
|
+
cpSync(file, dest, { dereference: false, errorOnExist: true });
|
|
964
|
+
if (digestFile(file) !== before || digestFile(dest) !== before) {
|
|
965
|
+
throw new Error(`backup verification failed: ${file}`);
|
|
966
|
+
}
|
|
967
|
+
manifest.push({ path: path.resolve(file), digest: before });
|
|
337
968
|
}
|
|
338
|
-
|
|
969
|
+
writeFileAtomic(
|
|
339
970
|
path.join(root, "manifest.json"),
|
|
340
|
-
JSON.stringify(
|
|
971
|
+
JSON.stringify(
|
|
972
|
+
{ schemaVersion: 1, storage: "sha256-path-v1", backupId, files: manifest },
|
|
973
|
+
null,
|
|
974
|
+
2,
|
|
975
|
+
) + "\n",
|
|
341
976
|
);
|
|
342
977
|
};
|
|
343
978
|
|
|
@@ -345,11 +980,72 @@ const receiptPath = (stateDir: string, backupId: Id) =>
|
|
|
345
980
|
path.join(stateDir, "cutover", "receipts", `${backupId}.json`);
|
|
346
981
|
|
|
347
982
|
const writeReceipt = (receipt: CutoverReceipt, stateDir: string): void => {
|
|
348
|
-
|
|
349
|
-
|
|
983
|
+
writeFileAtomic(receiptPath(stateDir, receipt.backupId), JSON.stringify(receipt, null, 2) + "\n");
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
const cutoverJournalPath = (stateDir: string, backupId: Id): string =>
|
|
987
|
+
path.join(stateDir, "cutover", "journals", `${backupId}.json`);
|
|
988
|
+
|
|
989
|
+
const writeCutoverJournal = (journal: CutoverJournal, stateDir: string): void => {
|
|
990
|
+
writeFileAtomic(
|
|
991
|
+
cutoverJournalPath(stateDir, journal.backupId),
|
|
992
|
+
JSON.stringify(journal, null, 2) + "\n",
|
|
993
|
+
);
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
const isCutoverHost = (value: unknown): value is CutoverHost =>
|
|
997
|
+
value === "opencode" || value === "cursor" || value === "codex" || value === "pi";
|
|
998
|
+
|
|
999
|
+
const readCutoverJournal = (backupId: Id, stateDir: string): CutoverJournal | null => {
|
|
1000
|
+
if (!validCutoverId(backupId)) return null;
|
|
1001
|
+
const file = cutoverJournalPath(stateDir, backupId);
|
|
1002
|
+
if (digestFile(file) === null) return null;
|
|
1003
|
+
try {
|
|
1004
|
+
const value = JSON.parse(readFileSync(file, "utf8")) as Partial<CutoverJournal>;
|
|
1005
|
+
const stepNames = new Set([
|
|
1006
|
+
"configuration",
|
|
1007
|
+
"generation",
|
|
1008
|
+
"verification",
|
|
1009
|
+
"receipt",
|
|
1010
|
+
...(value.hosts ?? []).map((h) => `host:${h}`),
|
|
1011
|
+
]);
|
|
1012
|
+
if (
|
|
1013
|
+
value.schemaVersion !== 1 ||
|
|
1014
|
+
value.backupId !== backupId ||
|
|
1015
|
+
typeof value.archiveDir !== "string" ||
|
|
1016
|
+
!path.isAbsolute(value.archiveDir) ||
|
|
1017
|
+
path.resolve(value.archiveDir) !== value.archiveDir ||
|
|
1018
|
+
typeof value.planId !== "string" ||
|
|
1019
|
+
!Array.isArray(value.hosts) ||
|
|
1020
|
+
!value.hosts.every(isCutoverHost) ||
|
|
1021
|
+
!value.hosts.length ||
|
|
1022
|
+
!value.resolutions ||
|
|
1023
|
+
typeof value.resolutions !== "object" ||
|
|
1024
|
+
Object.values(value.resolutions).some((entry) => typeof entry !== "string") ||
|
|
1025
|
+
typeof value.conversionNeeded !== "boolean" ||
|
|
1026
|
+
!["applying", "interrupted", "partial", "complete"].includes(value.phase ?? "") ||
|
|
1027
|
+
!Array.isArray(value.completedSteps) ||
|
|
1028
|
+
value.completedSteps.some((step) => typeof step !== "string" || !stepNames.has(step)) ||
|
|
1029
|
+
!Array.isArray(value.appliedHosts) ||
|
|
1030
|
+
value.appliedHosts.some((host) => !isCutoverHost(host) || !value.hosts?.includes(host)) ||
|
|
1031
|
+
!(
|
|
1032
|
+
value.currentStep === null ||
|
|
1033
|
+
(typeof value.currentStep === "string" && stepNames.has(value.currentStep))
|
|
1034
|
+
) ||
|
|
1035
|
+
!(value.error === null || typeof value.error === "string") ||
|
|
1036
|
+
typeof value.startedAt !== "string" ||
|
|
1037
|
+
!Array.isArray(value.notes) ||
|
|
1038
|
+
value.notes.some((note) => typeof note !== "string")
|
|
1039
|
+
)
|
|
1040
|
+
return null;
|
|
1041
|
+
return value as CutoverJournal;
|
|
1042
|
+
} catch {
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
350
1045
|
};
|
|
351
1046
|
|
|
352
1047
|
export const readCutoverReceipt = (backupId: Id, stateDir: string): CutoverReceipt | null => {
|
|
1048
|
+
if (!validCutoverId(backupId)) return null;
|
|
353
1049
|
const file = receiptPath(stateDir, backupId);
|
|
354
1050
|
if (!existsSync(file)) return null;
|
|
355
1051
|
try {
|
|
@@ -387,6 +1083,16 @@ const plannedAfterDigest = (
|
|
|
387
1083
|
);
|
|
388
1084
|
return createHash("sha256").update(JSON.stringify(merged.config)).digest("hex");
|
|
389
1085
|
}
|
|
1086
|
+
if (file === paths.piConfig && paths.dev) {
|
|
1087
|
+
const current = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
1088
|
+
const next = mergePiConfig(current, paths.dev);
|
|
1089
|
+
return createHash("sha256").update(JSON.stringify(next)).digest("hex");
|
|
1090
|
+
}
|
|
1091
|
+
if (file === paths.piSettings && paths.dev) {
|
|
1092
|
+
const current = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
1093
|
+
const next = mergePiSettings(current, paths.dev);
|
|
1094
|
+
return createHash("sha256").update(JSON.stringify(next)).digest("hex");
|
|
1095
|
+
}
|
|
390
1096
|
if (file.endsWith("hooks-cursor.json")) {
|
|
391
1097
|
const entry = cursorHooksEntry(paths.cursorPluginDir);
|
|
392
1098
|
const base = existsSync(file)
|
|
@@ -406,13 +1112,28 @@ export function previewCutover(
|
|
|
406
1112
|
hosts: CutoverHost[] = ["opencode", "cursor"],
|
|
407
1113
|
): CutoverPlan {
|
|
408
1114
|
const paths = resolvePaths(options);
|
|
409
|
-
const conversion = previewConversion({ configDir: paths.configDir });
|
|
410
1115
|
const blocked: string[] = [];
|
|
1116
|
+
const archiveDir = options.archiveDir ? path.resolve(options.archiveDir) : null;
|
|
1117
|
+
if (archiveDir) {
|
|
1118
|
+
const archiveError = archiveDestinationError(archiveDir, paths);
|
|
1119
|
+
if (archiveError) blocked.push(archiveError);
|
|
1120
|
+
}
|
|
1121
|
+
const inventory = buildCutoverInventory(paths);
|
|
1122
|
+
let conversion: ConversionPreview = { mappings: [], preserved: [], unresolved: [] };
|
|
1123
|
+
if (inventory.complete) {
|
|
1124
|
+
try {
|
|
1125
|
+
conversion = previewConversion({ configDir: paths.configDir });
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
blocked.push(`cannot preview configuration conversion: ${String(error)}`);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
411
1130
|
const unresolved = [...conversion.unresolved];
|
|
412
1131
|
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
1132
|
+
if (inventory.complete) {
|
|
1133
|
+
for (const host of hosts) {
|
|
1134
|
+
const gen = classifyHostGeneration(host, paths);
|
|
1135
|
+
if (gen === "mixed") blocked.push(`${host}: mixed legacy and v1 components`);
|
|
1136
|
+
}
|
|
416
1137
|
}
|
|
417
1138
|
|
|
418
1139
|
for (const session of paths.sessions) {
|
|
@@ -421,17 +1142,51 @@ export function previewCutover(
|
|
|
421
1142
|
}
|
|
422
1143
|
}
|
|
423
1144
|
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1145
|
+
for (const issue of inventory.issues) blocked.push(`inventory: ${issue}`);
|
|
1146
|
+
|
|
1147
|
+
const managedFiles = managedCutoverFiles(paths).map((filePath) => {
|
|
1148
|
+
if (!noSymlinkAncestor(path.dirname(filePath))) {
|
|
1149
|
+
blocked.push(`symlinked parent is outside the inventoried scope: ${path.dirname(filePath)}`);
|
|
1150
|
+
return {
|
|
1151
|
+
path: filePath,
|
|
1152
|
+
beforeDigest: null,
|
|
1153
|
+
afterDigest: createHash("sha256").update("").digest("hex"),
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
const stat = lstatOrNull(filePath);
|
|
1157
|
+
if (stat && !stat.isFile()) {
|
|
1158
|
+
blocked.push(`managed path is not a regular file: ${filePath}`);
|
|
1159
|
+
return {
|
|
1160
|
+
path: filePath,
|
|
1161
|
+
beforeDigest: null,
|
|
1162
|
+
afterDigest: createHash("sha256").update("").digest("hex"),
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
const beforeDigest = digestFile(filePath);
|
|
1166
|
+
try {
|
|
1167
|
+
return {
|
|
1168
|
+
path: filePath,
|
|
1169
|
+
beforeDigest,
|
|
1170
|
+
afterDigest:
|
|
1171
|
+
beforeDigest === null
|
|
1172
|
+
? createHash("sha256").update("").digest("hex")
|
|
1173
|
+
: plannedAfterDigest(filePath, hosts[0] ?? "cursor", paths),
|
|
1174
|
+
};
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
blocked.push(`cannot plan managed file ${filePath}: ${String(error)}`);
|
|
1177
|
+
return {
|
|
1178
|
+
path: filePath,
|
|
1179
|
+
beforeDigest,
|
|
1180
|
+
afterDigest: beforeDigest ?? createHash("sha256").update("").digest("hex"),
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
431
1184
|
|
|
432
1185
|
return {
|
|
433
1186
|
id: randomUUID(),
|
|
1187
|
+
archiveDir,
|
|
434
1188
|
managedFiles,
|
|
1189
|
+
inventory,
|
|
435
1190
|
sessions: paths.sessions,
|
|
436
1191
|
unresolved,
|
|
437
1192
|
conversion,
|
|
@@ -440,32 +1195,56 @@ export function previewCutover(
|
|
|
440
1195
|
};
|
|
441
1196
|
}
|
|
442
1197
|
|
|
443
|
-
const syncV1CursorSkills = (pluginDir: string, dev: string | null) => {
|
|
1198
|
+
const syncV1CursorSkills = (pluginDir: string, dev: string | null, notes: string[]) => {
|
|
1199
|
+
const syncGeneratedFile = (relative: string, file: string, content: string): void => {
|
|
1200
|
+
if (!noSymlinkAncestor(path.dirname(file))) throw new Error(`symlinked parent: ${file}`);
|
|
1201
|
+
const stat = lstatOrNull(file);
|
|
1202
|
+
if (stat?.isSymbolicLink()) throw new Error(`refusing to write through symlink: ${file}`);
|
|
1203
|
+
if (stat && !stat.isFile()) throw new Error(`refusing to replace non-file: ${file}`);
|
|
1204
|
+
const desiredDigest = createHash("sha256").update(content).digest("hex");
|
|
1205
|
+
if (!stat) {
|
|
1206
|
+
writeFileExclusive(file, content);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const currentDigest = digestFile(file);
|
|
1210
|
+
if (currentDigest === desiredDigest) return;
|
|
1211
|
+
if (currentDigest === LEGACY_CURSOR_ASSET_DIGESTS[relative]) {
|
|
1212
|
+
writeFileAtomic(file, content);
|
|
1213
|
+
notes.push(`cursor: refreshed verified generated asset ${relative}`);
|
|
1214
|
+
} else {
|
|
1215
|
+
notes.push(`cursor: preserved modified or unknown asset ${relative}`);
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
444
1218
|
mkdirSync(path.join(pluginDir, "skills"), { recursive: true });
|
|
445
1219
|
for (const skill of WORKIT_METHOD_SKILLS) {
|
|
446
1220
|
const src = dev ? path.join(dev, "packages/workit-cursor/skills", skill, "SKILL.md") : null;
|
|
447
1221
|
const destDir = path.join(pluginDir, "skills", skill);
|
|
448
1222
|
mkdirSync(destDir, { recursive: true });
|
|
449
|
-
|
|
1223
|
+
syncGeneratedFile(
|
|
1224
|
+
`skills/${skill}/SKILL.md`,
|
|
450
1225
|
path.join(destDir, "SKILL.md"),
|
|
451
1226
|
src && existsSync(src) ? readFileSync(src, "utf8") : "# v1\n",
|
|
452
1227
|
);
|
|
453
1228
|
}
|
|
454
|
-
for (const name of readdirSync(path.join(pluginDir, "skills"))) {
|
|
455
|
-
if (name.startsWith("wk-"))
|
|
456
|
-
rmSync(path.join(pluginDir, "skills", name), { recursive: true, force: true });
|
|
457
|
-
}
|
|
458
1229
|
// Bare slash aliases ship next to skills so installs match the repo.
|
|
459
1230
|
mkdirSync(path.join(pluginDir, "commands"), { recursive: true });
|
|
460
1231
|
for (const alias of Object.keys(WORKIT_SKILL_ALIASES)) {
|
|
461
1232
|
const src = dev ? path.join(dev, "packages/workit-cursor/commands", `${alias}.md`) : null;
|
|
462
|
-
|
|
1233
|
+
syncGeneratedFile(
|
|
1234
|
+
`commands/${alias}.md`,
|
|
463
1235
|
path.join(pluginDir, "commands", `${alias}.md`),
|
|
464
1236
|
src && existsSync(src) ? readFileSync(src, "utf8") : `# /${alias}\n`,
|
|
465
1237
|
);
|
|
466
1238
|
}
|
|
467
|
-
const
|
|
468
|
-
if (existsSync(
|
|
1239
|
+
const ruleSource = dev && path.join(dev, "packages/workit-cursor/rules/workit-contract.mdc");
|
|
1240
|
+
if (ruleSource && existsSync(ruleSource)) {
|
|
1241
|
+
mkdirSync(path.join(pluginDir, "rules"), { recursive: true });
|
|
1242
|
+
syncGeneratedFile(
|
|
1243
|
+
"rules/workit-contract.mdc",
|
|
1244
|
+
path.join(pluginDir, "rules", "workit-contract.mdc"),
|
|
1245
|
+
readFileSync(ruleSource, "utf8"),
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
469
1248
|
};
|
|
470
1249
|
|
|
471
1250
|
const applyHostCutover = (
|
|
@@ -474,18 +1253,18 @@ const applyHostCutover = (
|
|
|
474
1253
|
notes: string[],
|
|
475
1254
|
): boolean => {
|
|
476
1255
|
if (host === "cursor") {
|
|
477
|
-
syncV1CursorSkills(paths.cursorPluginDir, paths.dev);
|
|
1256
|
+
syncV1CursorSkills(paths.cursorPluginDir, paths.dev, notes);
|
|
478
1257
|
const settings = existsSync(paths.cursorSettings)
|
|
479
1258
|
? JSON.parse(readFileSync(paths.cursorSettings, "utf8"))
|
|
480
1259
|
: {};
|
|
481
|
-
|
|
1260
|
+
writeFileAtomic(
|
|
482
1261
|
paths.cursorSettings,
|
|
483
1262
|
JSON.stringify(mergeCursorSettings(settings, paths.cursorPluginDir).config, null, 2) + "\n",
|
|
484
1263
|
);
|
|
485
1264
|
const mcp = existsSync(paths.cursorMcp)
|
|
486
1265
|
? JSON.parse(readFileSync(paths.cursorMcp, "utf8"))
|
|
487
1266
|
: { mcpServers: {} };
|
|
488
|
-
|
|
1267
|
+
writeFileAtomic(
|
|
489
1268
|
paths.cursorMcp,
|
|
490
1269
|
JSON.stringify(
|
|
491
1270
|
mergeCursorMcp(mcp, "workit", cursorMcpServerEntry(paths.cursorPluginDir)).config,
|
|
@@ -498,10 +1277,36 @@ const applyHostCutover = (
|
|
|
498
1277
|
? JSON.parse(readFileSync(hooksPath, "utf8"))
|
|
499
1278
|
: { version: 1, hooks: {} };
|
|
500
1279
|
const entry = cursorHooksEntry(paths.cursorPluginDir);
|
|
501
|
-
|
|
1280
|
+
const hookConfig = hooks && typeof hooks === "object" ? hooks : { version: 1, hooks: {} };
|
|
1281
|
+
const hookMap =
|
|
1282
|
+
hookConfig.hooks && typeof hookConfig.hooks === "object" ? hookConfig.hooks : {};
|
|
1283
|
+
const sessionStart = Array.isArray(hookMap.sessionStart) ? hookMap.sessionStart : [];
|
|
1284
|
+
const isWorkitSessionStart = (value: unknown): boolean => {
|
|
1285
|
+
const command =
|
|
1286
|
+
typeof value === "string"
|
|
1287
|
+
? value
|
|
1288
|
+
: value && typeof value === "object" && "command" in value
|
|
1289
|
+
? String(value.command)
|
|
1290
|
+
: "";
|
|
1291
|
+
return (
|
|
1292
|
+
command === entry.command ||
|
|
1293
|
+
command ===
|
|
1294
|
+
`npx -y --prefer-online --min-release-age=0 --package=${CURSOR_RUNTIME_PACKAGE} workit-cursor-session-start`
|
|
1295
|
+
);
|
|
1296
|
+
};
|
|
1297
|
+
writeFileAtomic(
|
|
502
1298
|
hooksPath,
|
|
503
1299
|
JSON.stringify(
|
|
504
|
-
{
|
|
1300
|
+
{
|
|
1301
|
+
...hookConfig,
|
|
1302
|
+
hooks: {
|
|
1303
|
+
...hookMap,
|
|
1304
|
+
sessionStart: [
|
|
1305
|
+
...sessionStart.filter((item: unknown) => !isWorkitSessionStart(item)),
|
|
1306
|
+
entry,
|
|
1307
|
+
],
|
|
1308
|
+
},
|
|
1309
|
+
},
|
|
505
1310
|
null,
|
|
506
1311
|
2,
|
|
507
1312
|
) + "\n",
|
|
@@ -516,11 +1321,11 @@ const applyHostCutover = (
|
|
|
516
1321
|
const distDir = path.join(paths.dev, "packages/workit-opencode/dist");
|
|
517
1322
|
mkdirSync(distDir, { recursive: true });
|
|
518
1323
|
const dist = path.join(distDir, "plugin.js");
|
|
519
|
-
if (!existsSync(dist))
|
|
1324
|
+
if (!existsSync(dist)) writeFileExclusive(dist, "export default {};\n");
|
|
520
1325
|
const current = existsSync(paths.opencodeConfig)
|
|
521
1326
|
? JSON.parse(readFileSync(paths.opencodeConfig, "utf8"))
|
|
522
1327
|
: {};
|
|
523
|
-
|
|
1328
|
+
writeFileAtomic(
|
|
524
1329
|
paths.opencodeConfig,
|
|
525
1330
|
JSON.stringify(mergeOpenCodeConfig(current, pin).config, null, 2) + "\n",
|
|
526
1331
|
);
|
|
@@ -535,22 +1340,33 @@ const applyHostCutover = (
|
|
|
535
1340
|
path.join(pluginDir, ".codex-plugin"),
|
|
536
1341
|
{
|
|
537
1342
|
recursive: true,
|
|
1343
|
+
force: false,
|
|
1344
|
+
errorOnExist: false,
|
|
1345
|
+
dereference: false,
|
|
538
1346
|
},
|
|
539
1347
|
);
|
|
540
1348
|
notes.push("codex: installed v1 plugin scaffold");
|
|
541
1349
|
return true;
|
|
542
1350
|
}
|
|
543
1351
|
if (host === "pi" && paths.dev) {
|
|
544
|
-
|
|
545
|
-
mkdirSync(path.dirname(
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
skills: [`${path.join(paths.dev, "packages/workit-pi/skills")}`],
|
|
1352
|
+
mkdirSync(path.dirname(paths.piConfig), { recursive: true });
|
|
1353
|
+
mkdirSync(path.dirname(paths.piSettings), { recursive: true });
|
|
1354
|
+
const readConfig = (file: string): Record<string, unknown> => {
|
|
1355
|
+
if (!existsSync(file)) return {};
|
|
1356
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
1357
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
551
1358
|
};
|
|
552
|
-
|
|
553
|
-
|
|
1359
|
+
writeFileAtomic(
|
|
1360
|
+
paths.piConfig,
|
|
1361
|
+
JSON.stringify(mergePiConfig(readConfig(paths.piConfig), paths.dev), null, 2) + "\n",
|
|
1362
|
+
);
|
|
1363
|
+
writeFileAtomic(
|
|
1364
|
+
paths.piSettings,
|
|
1365
|
+
JSON.stringify(mergePiSettings(readConfig(paths.piSettings), paths.dev), null, 2) + "\n",
|
|
1366
|
+
);
|
|
1367
|
+
notes.push(
|
|
1368
|
+
"pi: registered v1 package, extension, and skills while preserving unrelated entries",
|
|
1369
|
+
);
|
|
554
1370
|
return true;
|
|
555
1371
|
}
|
|
556
1372
|
return false;
|
|
@@ -574,6 +1390,25 @@ export function applyCutover(
|
|
|
574
1390
|
}
|
|
575
1391
|
|
|
576
1392
|
const paths = resolvePaths(options);
|
|
1393
|
+
if (!options.archiveDir || !plan.archiveDir)
|
|
1394
|
+
return failure("needs_input", "archive destination is required (--archive-dir)");
|
|
1395
|
+
const archiveDir = path.resolve(options.archiveDir);
|
|
1396
|
+
if (archiveDir !== plan.archiveDir)
|
|
1397
|
+
return failure("revision_conflict", "archive destination changed since preview");
|
|
1398
|
+
const archiveError = archiveDestinationError(archiveDir, paths);
|
|
1399
|
+
if (archiveError) return failure("requirements_unsatisfied", archiveError);
|
|
1400
|
+
if (!plan.inventory?.complete) {
|
|
1401
|
+
return failure("requirements_unsatisfied", "cutover inventory is incomplete");
|
|
1402
|
+
}
|
|
1403
|
+
const currentInventory = buildCutoverInventory(paths);
|
|
1404
|
+
if (!currentInventory.complete) {
|
|
1405
|
+
return failure("requirements_unsatisfied", "cutover inventory is incomplete", {
|
|
1406
|
+
fields: currentInventory.issues.map((issue) => ({ path: issue, reason: "inventory" })),
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
if (currentInventory.revision !== plan.inventory.revision) {
|
|
1410
|
+
return failure("revision_conflict", "cutover sources changed since preview");
|
|
1411
|
+
}
|
|
577
1412
|
for (const entry of plan.managedFiles) {
|
|
578
1413
|
const current = digestFile(entry.path);
|
|
579
1414
|
if (entry.beforeDigest !== null && current !== entry.beforeDigest) {
|
|
@@ -598,74 +1433,267 @@ export function applyCutover(
|
|
|
598
1433
|
|
|
599
1434
|
const backupId = randomUUID();
|
|
600
1435
|
const backupTargets = cutoverBackupTargets(paths, decision.hosts);
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1436
|
+
const backupInventory = expandBackupTargets(backupTargets);
|
|
1437
|
+
if (backupInventory.issues.length > 0) {
|
|
1438
|
+
return failure("requirements_unsatisfied", "cutover backup inventory is incomplete", {
|
|
1439
|
+
fields: backupInventory.issues.map((issue) => ({ path: issue, reason: "inventory" })),
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
try {
|
|
1443
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
1444
|
+
writeBackup(backupId, archiveDir, paths, backupInventory.files);
|
|
1445
|
+
const journal: CutoverJournal = {
|
|
1446
|
+
schemaVersion: 1,
|
|
1447
|
+
backupId,
|
|
1448
|
+
archiveDir,
|
|
1449
|
+
planId: plan.id,
|
|
1450
|
+
hosts: [...decision.hosts],
|
|
1451
|
+
resolutions: { ...decision.resolutions },
|
|
1452
|
+
conversionNeeded: plan.unresolved.length > 0 || plan.conversion.mappings.length > 0,
|
|
1453
|
+
phase: "applying",
|
|
1454
|
+
completedSteps: [],
|
|
1455
|
+
appliedHosts: [],
|
|
1456
|
+
currentStep: null,
|
|
1457
|
+
error: null,
|
|
1458
|
+
startedAt: new Date().toISOString(),
|
|
1459
|
+
notes: [],
|
|
1460
|
+
};
|
|
1461
|
+
writeCutoverJournal(journal, paths.stateDir);
|
|
1462
|
+
return runCutoverJournal(journal, paths, plan.conversion);
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
return failure("requirements_unsatisfied", `could not stage cutover backup: ${String(error)}`);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const runCutoverJournal = (
|
|
1469
|
+
journal: CutoverJournal,
|
|
1470
|
+
paths: ReturnType<typeof resolvePaths>,
|
|
1471
|
+
conversion?: ConversionPreview,
|
|
1472
|
+
): Result<CutoverReceipt> => {
|
|
1473
|
+
const completed = (step: string): boolean => journal.completedSteps.includes(step);
|
|
1474
|
+
const fail = (step: string, error: unknown): Result<CutoverReceipt> => {
|
|
1475
|
+
journal.phase = "interrupted";
|
|
1476
|
+
journal.currentStep = step;
|
|
1477
|
+
journal.error = String(error);
|
|
1478
|
+
try {
|
|
1479
|
+
writeCutoverJournal(journal, paths.stateDir);
|
|
1480
|
+
} catch (journalError) {
|
|
1481
|
+
return failure(
|
|
1482
|
+
"requirements_unsatisfied",
|
|
1483
|
+
`cutover interrupted at ${step}; journal write failed: ${String(journalError)}`,
|
|
1484
|
+
{ path: journal.backupId, operation: step },
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
return failure(
|
|
1488
|
+
"requirements_unsatisfied",
|
|
1489
|
+
`cutover interrupted at ${step}; resume backup ${journal.backupId}`,
|
|
1490
|
+
{
|
|
1491
|
+
path: journal.backupId,
|
|
1492
|
+
operation: step,
|
|
1493
|
+
guidance: journal.error,
|
|
1494
|
+
},
|
|
613
1495
|
);
|
|
1496
|
+
};
|
|
1497
|
+
const begin = (step: string): void => {
|
|
1498
|
+
journal.phase = "applying";
|
|
1499
|
+
journal.currentStep = step;
|
|
1500
|
+
journal.error = null;
|
|
1501
|
+
writeCutoverJournal(journal, paths.stateDir);
|
|
1502
|
+
};
|
|
1503
|
+
const finish = (step: string): void => {
|
|
1504
|
+
if (!completed(step)) journal.completedSteps.push(step);
|
|
1505
|
+
journal.currentStep = null;
|
|
1506
|
+
journal.error = null;
|
|
1507
|
+
writeCutoverJournal(journal, paths.stateDir);
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
if (journal.conversionNeeded && !completed("configuration")) {
|
|
1511
|
+
try {
|
|
1512
|
+
begin("configuration");
|
|
1513
|
+
const applied = applyConversionConfig(
|
|
1514
|
+
paths.configDir,
|
|
1515
|
+
conversion ?? previewConversion({ configDir: paths.configDir }),
|
|
1516
|
+
journal.resolutions,
|
|
1517
|
+
);
|
|
1518
|
+
const note = `config: wrote ${applied.configPath} and recorded choices at ${applied.choicesPath}`;
|
|
1519
|
+
if (!journal.notes.includes(note)) journal.notes.push(note);
|
|
1520
|
+
finish("configuration");
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
return fail("configuration", error);
|
|
1523
|
+
}
|
|
614
1524
|
}
|
|
615
1525
|
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
1526
|
+
for (const host of journal.hosts) {
|
|
1527
|
+
const step = `host:${host}`;
|
|
1528
|
+
if (completed(step)) continue;
|
|
1529
|
+
try {
|
|
1530
|
+
begin(step);
|
|
1531
|
+
const before = new Set(journal.notes);
|
|
1532
|
+
if (!applyHostCutover(host, paths, journal.notes)) {
|
|
1533
|
+
journal.phase = "partial";
|
|
1534
|
+
journal.currentStep = null;
|
|
1535
|
+
journal.error = `${host} requires a local dev installation before activation`;
|
|
1536
|
+
writeCutoverJournal(journal, paths.stateDir);
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
journal.appliedHosts.push(host);
|
|
1540
|
+
journal.notes = [...before, ...journal.notes.filter((note) => !before.has(note))];
|
|
1541
|
+
finish(step);
|
|
1542
|
+
} catch (error) {
|
|
1543
|
+
return fail(step, error);
|
|
1544
|
+
}
|
|
623
1545
|
}
|
|
624
1546
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1547
|
+
const partial = journal.appliedHosts.length !== journal.hosts.length;
|
|
1548
|
+
if (!partial && !completed("generation")) {
|
|
1549
|
+
try {
|
|
1550
|
+
begin("generation");
|
|
1551
|
+
writeGenerationState(paths.configDir, {
|
|
1552
|
+
target: "v1",
|
|
1553
|
+
cutover: { backupId: journal.backupId, planId: journal.planId, at: journal.startedAt },
|
|
1554
|
+
});
|
|
1555
|
+
finish("generation");
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
return fail("generation", error);
|
|
1558
|
+
}
|
|
630
1559
|
}
|
|
631
1560
|
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
1561
|
+
const backupTargets = cutoverBackupTargets(paths, journal.hosts);
|
|
1562
|
+
const postCutoverInventory = expandBackupTargets(backupTargets);
|
|
1563
|
+
if (postCutoverInventory.issues.length > 0) {
|
|
1564
|
+
return fail("verification", postCutoverInventory.issues.join("; "));
|
|
1565
|
+
}
|
|
1566
|
+
const managedFiles: CutoverReceipt["managedFiles"] = [];
|
|
1567
|
+
for (const file of postCutoverInventory.files) {
|
|
1568
|
+
const installedDigest = digestFile(file);
|
|
1569
|
+
if (installedDigest === null) return fail("verification", `cannot verify ${file}`);
|
|
1570
|
+
managedFiles.push({ path: file, installedDigest });
|
|
1571
|
+
}
|
|
635
1572
|
|
|
636
1573
|
const receipt: CutoverReceipt = {
|
|
637
|
-
backupId,
|
|
638
|
-
|
|
1574
|
+
backupId: journal.backupId,
|
|
1575
|
+
archiveDir: journal.archiveDir,
|
|
1576
|
+
planId: journal.planId,
|
|
639
1577
|
generation: partial ? "legacy" : "v1",
|
|
640
|
-
hosts:
|
|
1578
|
+
hosts: [...journal.appliedHosts],
|
|
641
1579
|
managedFiles,
|
|
642
1580
|
partial,
|
|
643
|
-
notes,
|
|
1581
|
+
notes: [...journal.notes],
|
|
644
1582
|
};
|
|
645
|
-
|
|
1583
|
+
try {
|
|
1584
|
+
begin("receipt");
|
|
1585
|
+
writeReceipt(receipt, paths.stateDir);
|
|
1586
|
+
journal.phase = partial ? "partial" : "complete";
|
|
1587
|
+
journal.currentStep = null;
|
|
1588
|
+
journal.error = partial ? journal.error : null;
|
|
1589
|
+
finish("receipt");
|
|
1590
|
+
return success(null, null, receipt);
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
return fail("receipt", error);
|
|
1593
|
+
}
|
|
1594
|
+
};
|
|
646
1595
|
|
|
647
|
-
|
|
1596
|
+
export function resumeCutover(backupId: Id, options: CutoverPaths = {}): Result<CutoverReceipt> {
|
|
1597
|
+
if (!validCutoverId(backupId))
|
|
1598
|
+
return failure("not_found", `cutover journal not found: ${backupId}`);
|
|
1599
|
+
const paths = resolvePaths(options);
|
|
1600
|
+
const journal = readCutoverJournal(backupId, paths.stateDir);
|
|
1601
|
+
if (!journal) return failure("not_found", `cutover journal not found: ${backupId}`);
|
|
1602
|
+
if (journal.phase === "complete") {
|
|
1603
|
+
const receipt = readCutoverReceipt(backupId, paths.stateDir);
|
|
1604
|
+
return receipt
|
|
1605
|
+
? success(null, null, receipt)
|
|
1606
|
+
: failure("requirements_unsatisfied", `cutover receipt is missing: ${backupId}`);
|
|
1607
|
+
}
|
|
1608
|
+
return runCutoverJournal(journal, paths);
|
|
648
1609
|
}
|
|
649
1610
|
|
|
650
1611
|
export function previewRollback(backupId: Id, options: CutoverPaths = {}): RollbackPreview {
|
|
651
1612
|
const paths = resolvePaths(options);
|
|
652
|
-
|
|
1613
|
+
if (!validCutoverId(backupId)) {
|
|
1614
|
+
return {
|
|
1615
|
+
backupId,
|
|
1616
|
+
restorable: [],
|
|
1617
|
+
conflicts: [],
|
|
1618
|
+
preserved: [],
|
|
1619
|
+
issues: ["invalid cutover backup id"],
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
653
1622
|
const receipt = readCutoverReceipt(backupId, paths.stateDir);
|
|
654
1623
|
const restorable: RollbackPreview["restorable"] = [];
|
|
655
1624
|
const conflicts: RollbackPreview["conflicts"] = [];
|
|
656
1625
|
const preserved: string[] = [];
|
|
1626
|
+
const issues: string[] = [];
|
|
657
1627
|
|
|
658
|
-
if (
|
|
1628
|
+
if (
|
|
1629
|
+
!receipt ||
|
|
1630
|
+
receipt.backupId !== backupId ||
|
|
1631
|
+
typeof receipt.archiveDir !== "string" ||
|
|
1632
|
+
!path.isAbsolute(receipt.archiveDir) ||
|
|
1633
|
+
path.resolve(receipt.archiveDir) !== receipt.archiveDir
|
|
1634
|
+
) {
|
|
1635
|
+
return {
|
|
1636
|
+
backupId,
|
|
1637
|
+
restorable,
|
|
1638
|
+
conflicts,
|
|
1639
|
+
preserved,
|
|
1640
|
+
issues: ["cutover receipt or archive destination is missing"],
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
const archiveError = archiveDestinationError(receipt.archiveDir, paths);
|
|
1644
|
+
if (archiveError) {
|
|
1645
|
+
return { backupId, restorable, conflicts, preserved, issues: [archiveError] };
|
|
1646
|
+
}
|
|
1647
|
+
const root = backupRoot(receipt.archiveDir, backupId);
|
|
659
1648
|
|
|
1649
|
+
if (!existsSync(root)) {
|
|
1650
|
+
return { backupId, restorable, conflicts, preserved, issues: ["backup directory is missing"] };
|
|
1651
|
+
}
|
|
1652
|
+
if (!receipt) {
|
|
1653
|
+
return {
|
|
1654
|
+
backupId,
|
|
1655
|
+
restorable,
|
|
1656
|
+
conflicts,
|
|
1657
|
+
preserved,
|
|
1658
|
+
issues: ["cutover receipt is missing or unreadable"],
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
const backup = readBackupFiles(backupId, receipt.archiveDir, paths);
|
|
1662
|
+
issues.push(...backup.issues);
|
|
1663
|
+
|
|
1664
|
+
const seen = new Set<string>();
|
|
660
1665
|
for (const entry of receipt.managedFiles) {
|
|
1666
|
+
if (
|
|
1667
|
+
!path.isAbsolute(entry.path) ||
|
|
1668
|
+
path.resolve(entry.path) !== entry.path ||
|
|
1669
|
+
seen.has(entry.path)
|
|
1670
|
+
) {
|
|
1671
|
+
issues.push(`receipt contains an invalid or duplicate path: ${entry.path}`);
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
seen.add(entry.path);
|
|
661
1675
|
const current = digestFile(entry.path);
|
|
662
|
-
|
|
1676
|
+
const original = backup.files.get(entry.path);
|
|
1677
|
+
if (current === null) {
|
|
1678
|
+
if (original || lstatOrNull(entry.path)) {
|
|
1679
|
+
conflicts.push({
|
|
1680
|
+
path: entry.path,
|
|
1681
|
+
currentDigest: null,
|
|
1682
|
+
installedDigest: entry.installedDigest,
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
663
1687
|
if (current === entry.installedDigest) {
|
|
664
1688
|
restorable.push({
|
|
665
1689
|
path: entry.path,
|
|
666
1690
|
currentDigest: current,
|
|
667
1691
|
installedDigest: entry.installedDigest,
|
|
1692
|
+
restore: original ? "backup" : "remove",
|
|
668
1693
|
});
|
|
1694
|
+
} else if (original && current === original.digest) {
|
|
1695
|
+
// A previous rollback attempt already restored this file.
|
|
1696
|
+
continue;
|
|
669
1697
|
} else {
|
|
670
1698
|
conflicts.push({
|
|
671
1699
|
path: entry.path,
|
|
@@ -678,15 +1706,22 @@ export function previewRollback(backupId: Id, options: CutoverPaths = {}): Rollb
|
|
|
678
1706
|
const v1Task = path.join(paths.workspace, ".workit");
|
|
679
1707
|
if (existsSync(v1Task)) preserved.push(v1Task);
|
|
680
1708
|
|
|
681
|
-
return { backupId, restorable, conflicts, preserved };
|
|
1709
|
+
return { backupId, restorable, conflicts, preserved, issues };
|
|
682
1710
|
}
|
|
683
1711
|
|
|
684
1712
|
export function applyRollback(
|
|
685
1713
|
backupId: Id,
|
|
686
1714
|
options: CutoverPaths = {},
|
|
687
1715
|
): Result<{ restored: string[] }> {
|
|
1716
|
+
if (!validCutoverId(backupId)) return failure("not_found", `backup not found: ${backupId}`);
|
|
688
1717
|
const paths = resolvePaths(options);
|
|
689
1718
|
const preview = previewRollback(backupId, options);
|
|
1719
|
+
if (preview.issues.length > 0) {
|
|
1720
|
+
return failure("recovery_required", "rollback backup failed integrity checks", {
|
|
1721
|
+
path: paths.stateDir,
|
|
1722
|
+
guidance: preview.issues.join("; "),
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
690
1725
|
if (preview.conflicts.length > 0) {
|
|
691
1726
|
return failure(
|
|
692
1727
|
"revision_conflict",
|
|
@@ -697,15 +1732,47 @@ export function applyRollback(
|
|
|
697
1732
|
);
|
|
698
1733
|
}
|
|
699
1734
|
|
|
700
|
-
const
|
|
1735
|
+
const receipt = readCutoverReceipt(backupId, paths.stateDir);
|
|
1736
|
+
if (!receipt) return failure("not_found", `backup not found: ${backupId}`);
|
|
1737
|
+
const root = backupRoot(receipt.archiveDir, backupId);
|
|
701
1738
|
if (!existsSync(root)) return failure("not_found", `backup not found: ${backupId}`);
|
|
1739
|
+
const backup = readBackupFiles(backupId, receipt.archiveDir, paths);
|
|
1740
|
+
if (backup.issues.length > 0) {
|
|
1741
|
+
return failure("recovery_required", "rollback backup failed integrity checks", {
|
|
1742
|
+
path: root,
|
|
1743
|
+
guidance: backup.issues.join("; "),
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
702
1746
|
|
|
703
1747
|
const restored: string[] = [];
|
|
704
1748
|
for (const entry of preview.restorable) {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1749
|
+
if (digestFile(entry.path) !== entry.currentDigest) {
|
|
1750
|
+
return failure("revision_conflict", "managed file changed during rollback", {
|
|
1751
|
+
path: entry.path,
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
if (entry.restore === "remove") {
|
|
1755
|
+
unlinkSync(entry.path);
|
|
1756
|
+
restored.push(entry.path);
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
const original = backup.files.get(entry.path);
|
|
1760
|
+
if (!original) {
|
|
1761
|
+
return failure("recovery_required", "verified rollback content is missing", {
|
|
1762
|
+
path: entry.path,
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
const content = readFileSync(original.storagePath);
|
|
1766
|
+
const digest = createHash("sha256").update(content).digest("hex");
|
|
1767
|
+
if (digest !== original.digest) {
|
|
1768
|
+
return failure("recovery_required", "rollback content changed during apply", {
|
|
1769
|
+
path: original.storagePath,
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
writeFileAtomic(entry.path, content.toString("utf8"));
|
|
1773
|
+
if (digestFile(entry.path) !== original.digest) {
|
|
1774
|
+
return failure("storage_error", "restored file failed verification", { path: entry.path });
|
|
1775
|
+
}
|
|
709
1776
|
restored.push(entry.path);
|
|
710
1777
|
}
|
|
711
1778
|
|