@brainervirus/workit-core 0.9.1 → 0.10.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/wk-init/SKILL.md +1 -1
- package/src/core/branch.ts +181 -27
- package/src/core/config.ts +3 -1
- package/src/core/uninstall.ts +377 -0
package/package.json
CHANGED
package/skills/wk-init/SKILL.md
CHANGED
|
@@ -19,7 +19,7 @@ Never ask for or accept tokens in chat. For missing YouTrack configuration, prev
|
|
|
19
19
|
|
|
20
20
|
When the user wants to personalize the toolkit (locale, timezone, branch policy), ask native questions one at a time:
|
|
21
21
|
|
|
22
|
-
1. **Locale** — present a combobox of `localeOptions` (en, es-CL, es-MX, es-AR, pt-BR) + custom answer; validate the answer against BCP-47 (`^[a-z]{2,3}(-[A-Z]{2})
|
|
22
|
+
1. **Locale** — present a combobox of `localeOptions` (en, es-CL, es-MX, es-AR, pt-BR) + custom answer; validate the answer against BCP-47 (`^[a-z]{2,3}(-(?:[A-Z]{2}|[0-9]{3}))?$`, so `es-419` is valid) before passing it.
|
|
23
23
|
2. **Timezone** — e.g. `America/Santiago`, custom allowed.
|
|
24
24
|
3. **Branch policy preset** — gitflow / github-flow / trunk-based / custom.
|
|
25
25
|
4. **Custom branch lists** (only when preset = custom): allowed patterns (`feature/*`, `codex/*`, …) and protected names (`main`, …).
|
package/src/core/branch.ts
CHANGED
|
@@ -35,6 +35,10 @@ const baseBranch = (cwd: string): { base: string } | { error: string } => {
|
|
|
35
35
|
};
|
|
36
36
|
const DECLARE_RE = /^\s*\*+Branch:\*+\s*`?([^`\s|]+)`?\s*$/gim;
|
|
37
37
|
const USE_CURRENT_RE = /^\s*\*+Branch:\*+\s*use-current\s*$/im;
|
|
38
|
+
// Windows portability: journal lines and stash-coverage sets carry
|
|
39
|
+
// repo-relative paths, which must match git's POSIX separator output even
|
|
40
|
+
// though path.join emits platform separators.
|
|
41
|
+
const toPosix = (p: string) => p.split(path.sep).join("/");
|
|
38
42
|
const readSafe = (p: string): string | null => {
|
|
39
43
|
try {
|
|
40
44
|
return readFileSync(p, "utf8");
|
|
@@ -274,14 +278,39 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
|
|
|
274
278
|
return fastForwardBase(cwd, base);
|
|
275
279
|
};
|
|
276
280
|
|
|
277
|
-
// CA-05:
|
|
278
|
-
//
|
|
281
|
+
// CA-05: crash-orphaned guard roots are garbage-collected at snapshot time.
|
|
282
|
+
// Fresh roots (any live invocation) and non-workit entries are never touched.
|
|
283
|
+
export const purgeStaleFlowGuardRoots = (now: number): void => {
|
|
284
|
+
const cutoff = now - 24 * 3600_000;
|
|
285
|
+
let entries: string[];
|
|
286
|
+
try {
|
|
287
|
+
entries = readdirSync(tmpdir());
|
|
288
|
+
} catch {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
if (!entry.startsWith("workit-flow-guard-")) continue;
|
|
293
|
+
try {
|
|
294
|
+
if (statSync(path.join(tmpdir(), entry)).mtimeMs < cutoff)
|
|
295
|
+
rmSync(path.join(tmpdir(), entry), { recursive: true, force: true });
|
|
296
|
+
} catch {
|
|
297
|
+
/* raced removal or unreadable entry: skip */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// CA-04: flow-state snapshots live under the OS tempdir scoped by a hash of
|
|
303
|
+
// the workspace path — never inside the repository or docs/. The pid+hrtime
|
|
304
|
+
// suffix makes every invocation unique, so two concurrent setups never share
|
|
305
|
+
// (and never clobber) a root; CA-05 GC reclaims anything a crash orphaned.
|
|
279
306
|
export const snapshotFlowState = (cwd: string): string => {
|
|
307
|
+
purgeStaleFlowGuardRoots(Date.now());
|
|
280
308
|
const root = path.join(
|
|
281
309
|
tmpdir(),
|
|
282
|
-
`workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}
|
|
310
|
+
`workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}.${
|
|
311
|
+
process.pid
|
|
312
|
+
}.${process.hrtime.bigint()}`,
|
|
283
313
|
);
|
|
284
|
-
rmSync(root, { recursive: true, force: true }); // drop a stale guard from a crashed run
|
|
285
314
|
mkdirSync(root, { recursive: true });
|
|
286
315
|
const docsDir = path.join(path.resolve(cwd), "docs");
|
|
287
316
|
let slugs: string[] = [];
|
|
@@ -291,15 +320,21 @@ export const snapshotFlowState = (cwd: string): string => {
|
|
|
291
320
|
return root; // no docs/ yet — zero-file snapshot
|
|
292
321
|
}
|
|
293
322
|
for (const slug of slugs) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
323
|
+
// The approval gate digests docs/<slug>/spec.md and docs/<slug>/plan.md,
|
|
324
|
+
// so those bytes must survive the stash window too: a stash push -u takes
|
|
325
|
+
// untracked spec/plan away and any concurrent effective flow read would
|
|
326
|
+
// classify document_missing drift and persist an approval-chain wipe.
|
|
327
|
+
for (const rel of [["sdd", "flow.json"], ["spec.md"], ["plan.md"]]) {
|
|
328
|
+
const src = path.join(docsDir, slug, ...rel);
|
|
329
|
+
try {
|
|
330
|
+
if (!statSync(src).isFile()) continue;
|
|
331
|
+
} catch {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const dest = path.join(root, "docs", slug, ...rel);
|
|
335
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
336
|
+
cpSync(src, dest);
|
|
299
337
|
}
|
|
300
|
-
const dest = path.join(root, "docs", slug, "sdd", "flow.json");
|
|
301
|
-
mkdirSync(path.dirname(dest), { recursive: true });
|
|
302
|
-
cpSync(src, dest);
|
|
303
338
|
}
|
|
304
339
|
return root;
|
|
305
340
|
};
|
|
@@ -307,14 +342,20 @@ export const snapshotFlowState = (cwd: string): string => {
|
|
|
307
342
|
// CA-04: restore-if-missing keeps the newest working-tree bytes; the snapshot
|
|
308
343
|
// root is removed only after every file is handled and retained on failure.
|
|
309
344
|
// A caught failure must not vanish: the message is returned so callers can
|
|
310
|
-
// surface it to operators as a warning.
|
|
311
|
-
|
|
345
|
+
// surface it to operators as a warning. `restored` lists ONLY the files this
|
|
346
|
+
// call actually wrote — present-at-restore files are skipped and excluded,
|
|
347
|
+
// which is what makes the redundant-stash coverage check honest.
|
|
348
|
+
export const restoreFlowSnapshot = (
|
|
349
|
+
snapDir: string,
|
|
350
|
+
cwd: string,
|
|
351
|
+
): { restored: string[]; warning?: string } => {
|
|
312
352
|
const walk = (dir: string, rel: string): string[] =>
|
|
313
353
|
readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
|
|
314
354
|
entry.isDirectory()
|
|
315
355
|
? walk(path.join(dir, entry.name), path.join(rel, entry.name))
|
|
316
356
|
: [path.join(rel, entry.name)],
|
|
317
357
|
);
|
|
358
|
+
const restored: string[] = [];
|
|
318
359
|
try {
|
|
319
360
|
const workspace = path.resolve(cwd);
|
|
320
361
|
for (const rel of walk(snapDir, "")) {
|
|
@@ -331,13 +372,17 @@ export const restoreFlowSnapshot = (snapDir: string, cwd: string): string | unde
|
|
|
331
372
|
rmSync(tmpDest, { force: true });
|
|
332
373
|
throw error;
|
|
333
374
|
}
|
|
375
|
+
restored.push(toPosix(rel));
|
|
334
376
|
}
|
|
335
377
|
rmSync(snapDir, { recursive: true, force: true });
|
|
336
|
-
return
|
|
378
|
+
return { restored };
|
|
337
379
|
} catch (error) {
|
|
338
|
-
return
|
|
339
|
-
|
|
340
|
-
|
|
380
|
+
return {
|
|
381
|
+
restored,
|
|
382
|
+
warning: `flow state snapshot restore failed: ${
|
|
383
|
+
error instanceof Error ? error.message : String(error)
|
|
384
|
+
}`,
|
|
385
|
+
};
|
|
341
386
|
}
|
|
342
387
|
};
|
|
343
388
|
|
|
@@ -348,12 +393,14 @@ export const branchSetup = ({
|
|
|
348
393
|
target_branch,
|
|
349
394
|
stash,
|
|
350
395
|
workspace_root,
|
|
396
|
+
log,
|
|
351
397
|
}: {
|
|
352
398
|
action?: string;
|
|
353
399
|
sdd_dir?: string;
|
|
354
400
|
target_branch?: string;
|
|
355
401
|
stash?: string;
|
|
356
402
|
workspace_root: string;
|
|
403
|
+
log?: (message: string) => void;
|
|
357
404
|
}) => {
|
|
358
405
|
const cwd = path.resolve(workspace_root);
|
|
359
406
|
const exec = (args: string[]): string =>
|
|
@@ -376,9 +423,60 @@ export const branchSetup = ({
|
|
|
376
423
|
writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
377
424
|
|
|
378
425
|
let snapDir: string | null = null;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
426
|
+
// CA-01: flow-guard journal brackets the stash/checkout mutation window so a
|
|
427
|
+
// mid-window wipe is pinpointable between adjacent checkpoint lines. With no
|
|
428
|
+
// logger injected every call below is a no-op and nothing extra runs —
|
|
429
|
+
// behaviorally identical to the pre-journal code.
|
|
430
|
+
const journal = (message: string) => log?.(`flow-guard: ${message}`);
|
|
431
|
+
const snapshotRelPaths = (): string[] => {
|
|
432
|
+
const dir = snapDir;
|
|
433
|
+
// Not gated on log: the redundant-stash coverage check below needs this
|
|
434
|
+
// walk even without a logger (the walk is read-only over the tmpdir
|
|
435
|
+
// snapshot).
|
|
436
|
+
if (!dir) return [];
|
|
437
|
+
try {
|
|
438
|
+
const walk = (from: string, rel: string): string[] =>
|
|
439
|
+
readdirSync(from, { withFileTypes: true }).flatMap((entry) =>
|
|
440
|
+
entry.isDirectory()
|
|
441
|
+
? walk(path.join(from, entry.name), path.join(rel, entry.name))
|
|
442
|
+
: [path.join(rel, entry.name)],
|
|
443
|
+
);
|
|
444
|
+
return walk(dir, "").map(toPosix);
|
|
445
|
+
} catch {
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
const journalSnapshot = () => {
|
|
450
|
+
const dir = snapDir;
|
|
451
|
+
if (!dir || !log) return;
|
|
452
|
+
const rels = snapshotRelPaths();
|
|
453
|
+
journal(`snapshot: ${rels.length} file(s)`);
|
|
454
|
+
for (const rel of rels) {
|
|
455
|
+
let shortHash = "";
|
|
456
|
+
try {
|
|
457
|
+
shortHash = createHash("sha256")
|
|
458
|
+
.update(readFileSync(path.join(dir, rel)))
|
|
459
|
+
.digest("hex")
|
|
460
|
+
.slice(0, 8);
|
|
461
|
+
} catch {}
|
|
462
|
+
journal(`snapshot: ${rel} sha=${shortHash}`);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const journalPresence = (phase: string) => {
|
|
466
|
+
for (const rel of snapshotRelPaths()) {
|
|
467
|
+
journal(`${phase}: ${rel} ${existsSync(path.join(cwd, rel)) ? "present" : "MISSING"}`);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
const restoreWithWarning = (dir: string | null): { restored: string[]; warnings: string[] } => {
|
|
471
|
+
if (!dir) return { restored: [], warnings: [] };
|
|
472
|
+
const total = log ? snapshotRelPaths().length : 0;
|
|
473
|
+
const { restored, warning } = restoreFlowSnapshot(dir, cwd);
|
|
474
|
+
if (log)
|
|
475
|
+
journal(
|
|
476
|
+
`restore: restored=${restored.length} skipped=${total - restored.length}${warning ? ` warning: ${warning}` : ""}`,
|
|
477
|
+
);
|
|
478
|
+
else if (warning) journal(`restore warning: ${warning}`);
|
|
479
|
+
return { restored, warnings: warning ? [warning] : [] };
|
|
382
480
|
};
|
|
383
481
|
|
|
384
482
|
if (action === "reapply_stash") {
|
|
@@ -387,22 +485,28 @@ export const branchSetup = ({
|
|
|
387
485
|
if (!ref) return { error: "no stash_ref in manifest" };
|
|
388
486
|
// D-03: guard flow.json across the stash pop window.
|
|
389
487
|
snapDir = snapshotFlowState(cwd);
|
|
488
|
+
journalSnapshot();
|
|
489
|
+
journal(`pre-pop: ${String(ref)}`);
|
|
390
490
|
try {
|
|
391
491
|
exec(["stash", "pop", String(ref)]);
|
|
392
492
|
} catch (error) {
|
|
393
493
|
// CA-03: the snapshot ran before the pop — a failing pop must still
|
|
394
494
|
// restore a mid-window-wiped flow.json before returning.
|
|
395
|
-
|
|
495
|
+
journal("pop: failed");
|
|
496
|
+
const {
|
|
497
|
+
warnings: [warning],
|
|
498
|
+
} = restoreWithWarning(snapDir);
|
|
396
499
|
return {
|
|
397
500
|
error: `${error instanceof Error ? error.message : "stash pop failed"}${
|
|
398
501
|
warning ? `; ${warning}` : ""
|
|
399
502
|
}`,
|
|
400
503
|
};
|
|
401
504
|
}
|
|
505
|
+
journal("pop: ok");
|
|
402
506
|
delete manifest.stash_ref;
|
|
403
507
|
delete manifest.stash_created_at;
|
|
404
508
|
writeManifest(manifest);
|
|
405
|
-
const warnings = restoreWithWarning(snapDir);
|
|
509
|
+
const { warnings } = restoreWithWarning(snapDir);
|
|
406
510
|
return { action: "reapply_stash", ok: true, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
407
511
|
}
|
|
408
512
|
|
|
@@ -427,6 +531,7 @@ export const branchSetup = ({
|
|
|
427
531
|
if ("error" in baseResolved) return { error: baseResolved.error };
|
|
428
532
|
base = baseResolved.base;
|
|
429
533
|
}
|
|
534
|
+
journal(`entry: current=${current} target=${target} base=${base ?? "-"}`);
|
|
430
535
|
|
|
431
536
|
let stash_ref: string | undefined;
|
|
432
537
|
// Best-effort restore; if the pop itself fails, the caller's error gains a
|
|
@@ -434,18 +539,23 @@ export const branchSetup = ({
|
|
|
434
539
|
const failAfterStash = (message: string): { error: string } => {
|
|
435
540
|
let suffix = "";
|
|
436
541
|
if (stash_ref) {
|
|
542
|
+
journal(`pre-pop: ${stash_ref}`);
|
|
437
543
|
try {
|
|
438
544
|
exec(["stash", "pop", stash_ref]);
|
|
439
545
|
stash_ref = undefined;
|
|
546
|
+
journal("pop: ok");
|
|
440
547
|
} catch {
|
|
548
|
+
journal("pop: failed");
|
|
441
549
|
suffix = " (changes preserved in stash)";
|
|
442
550
|
}
|
|
443
551
|
}
|
|
444
552
|
// CA-03: the snapshot ran before the stash push, so every error return
|
|
445
553
|
// here must still restore a mid-window-wiped flow.json and drop the
|
|
446
|
-
// guard root (a retained root is
|
|
447
|
-
//
|
|
448
|
-
const
|
|
554
|
+
// guard root (a retained root is purged by the next run's 24h GC).
|
|
555
|
+
// Never masks the original error.
|
|
556
|
+
const {
|
|
557
|
+
warnings: [warning],
|
|
558
|
+
} = restoreWithWarning(snapDir);
|
|
449
559
|
return { error: `${message}${suffix}${warning ? `; ${warning}` : ""}` };
|
|
450
560
|
};
|
|
451
561
|
if (current !== target) {
|
|
@@ -471,14 +581,17 @@ export const branchSetup = ({
|
|
|
471
581
|
// CA-03: snapshot before the stash push so flow.json survives the
|
|
472
582
|
// stash/checkout window even if the pathspec exclusion misses.
|
|
473
583
|
snapDir = snapshotFlowState(cwd);
|
|
584
|
+
journalSnapshot();
|
|
474
585
|
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`, "--", ":!docs/*/sdd"]);
|
|
475
586
|
} catch (error) {
|
|
476
587
|
return { error: error instanceof Error ? error.message : "stash push failed" };
|
|
477
588
|
}
|
|
478
589
|
stash_ref = "stash@{0}";
|
|
590
|
+
journal(`stash push: ${stash_ref}`);
|
|
479
591
|
}
|
|
480
592
|
try {
|
|
481
593
|
exec(["checkout", target]);
|
|
594
|
+
journalPresence("post-checkout");
|
|
482
595
|
} catch (error) {
|
|
483
596
|
const message = error instanceof Error ? error.message : "checkout failed";
|
|
484
597
|
if (/worktree/i.test(message)) {
|
|
@@ -501,6 +614,7 @@ export const branchSetup = ({
|
|
|
501
614
|
: ensureBaseBranch(cwd, effectiveBase);
|
|
502
615
|
if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
|
|
503
616
|
exec(["checkout", "-b", target]);
|
|
617
|
+
journalPresence("post-create");
|
|
504
618
|
} catch (createError) {
|
|
505
619
|
return failAfterStash(
|
|
506
620
|
createError instanceof Error ? createError.message : "branch create failed",
|
|
@@ -534,7 +648,47 @@ export const branchSetup = ({
|
|
|
534
648
|
}
|
|
535
649
|
return result;
|
|
536
650
|
}
|
|
537
|
-
|
|
651
|
+
// Files the guard actually wrote back, straight from the restore itself.
|
|
652
|
+
const { restored: restoredRels, warnings } = restoreWithWarning(snapDir);
|
|
653
|
+
// Round-1 fallout: the guard restores untracked spec/plan right after
|
|
654
|
+
// checkout, but the stash pushed the same files — a later reapply_stash pop
|
|
655
|
+
// refuses ("untracked working tree files would be overwritten"), stranding
|
|
656
|
+
// a stash_ref on every successful setup. When every stashed path was just
|
|
657
|
+
// restored byte-identical from the pre-stash snapshot, the stash is
|
|
658
|
+
// redundant: drop it and clear the manifest ref. Coverage counts ONLY
|
|
659
|
+
// actually-restored paths: a tracked-and-modified file survives its stash
|
|
660
|
+
// (reverted to HEAD bytes, still present), so restore skips it — counting
|
|
661
|
+
// it as covered would drop the only copy of the user's edit. Any stashed
|
|
662
|
+
// path not restored keeps the ref so reapply_stash stays available.
|
|
663
|
+
// Best-effort: any failure keeps today's keep-the-ref behavior.
|
|
664
|
+
if (stash_ref && snapDir && warnings.length === 0) {
|
|
665
|
+
const covered = new Set(restoredRels.map(toPosix));
|
|
666
|
+
try {
|
|
667
|
+
const stashed = exec([
|
|
668
|
+
"stash",
|
|
669
|
+
"show",
|
|
670
|
+
"--include-untracked",
|
|
671
|
+
"--name-only",
|
|
672
|
+
String(stash_ref),
|
|
673
|
+
])
|
|
674
|
+
.split("\n")
|
|
675
|
+
.map((line) => toPosix(line.trim()))
|
|
676
|
+
.filter(Boolean);
|
|
677
|
+
if (stashed.length > 0 && stashed.every((p) => covered.has(p))) {
|
|
678
|
+
exec(["stash", "drop", String(stash_ref)]);
|
|
679
|
+
stash_ref = undefined;
|
|
680
|
+
journal("dropped redundant stash (fully covered by snapshot restore)");
|
|
681
|
+
const manifest = readManifest();
|
|
682
|
+
delete manifest.stash_ref;
|
|
683
|
+
delete manifest.stash_created_at;
|
|
684
|
+
writeManifest(manifest);
|
|
685
|
+
} else {
|
|
686
|
+
journal(`kept stash ref ${stash_ref} (not fully covered by actual restores)`);
|
|
687
|
+
}
|
|
688
|
+
} catch {
|
|
689
|
+
/* keep the stash ref — reapply_stash stays available */
|
|
690
|
+
}
|
|
691
|
+
}
|
|
538
692
|
return {
|
|
539
693
|
action: "setup",
|
|
540
694
|
ok: true,
|
package/src/core/config.ts
CHANGED
|
@@ -122,7 +122,9 @@ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
|
|
|
122
122
|
|
|
123
123
|
export const configDir = (): string => ensureConfigDir();
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
// Region subtags: 2-letter ISO 3166 alpha-2 or 3-digit UN M.49 (es-419 =
|
|
126
|
+
// Latinoamérica), per BCP-47 well-formedness for the tags this toolkit stores.
|
|
127
|
+
export const LOCALE_RE = /^[a-z]{2,3}(-(?:[A-Z]{2}|[0-9]{3}))?$/;
|
|
126
128
|
|
|
127
129
|
const DEFAULTS: ToolkitConfig = {
|
|
128
130
|
locale: "en",
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Uninstall planning + apply (Task 8): the exact inverse of the setup/registration
|
|
2
|
+
// write set. planUninstall is a pure reader — it classifies the installed state
|
|
3
|
+
// and returns the actions apply WOULD perform; applyUninstall dispatches only
|
|
4
|
+
// reviewed plan actions, preserves unrelated user config byte-for-byte
|
|
5
|
+
// (write-only-if-changed), and never touches ~/.config/workit
|
|
6
|
+
// (CA-11, CA-12, CA-13, CA-14). Homes are injectable exactly like setup/doctor
|
|
7
|
+
// path options (D-07): tests pass explicit paths and no default ever resolves
|
|
8
|
+
// to a real user directory in tests.
|
|
9
|
+
import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { isWorkitPlugin } from "./registration";
|
|
13
|
+
|
|
14
|
+
export type UninstallHost = "opencode" | "cursor";
|
|
15
|
+
|
|
16
|
+
export type UninstallAction =
|
|
17
|
+
| { kind: "edit-json-remove"; path: string; detail: string }
|
|
18
|
+
| { kind: "remove-dir"; path: string; detail: string };
|
|
19
|
+
|
|
20
|
+
export type UninstallHostPlan = {
|
|
21
|
+
host: UninstallHost;
|
|
22
|
+
installed: boolean;
|
|
23
|
+
actions: UninstallAction[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type UninstallPlan = {
|
|
27
|
+
hosts: UninstallHostPlan[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type UninstallResultStatus = "removed" | "skipped" | "failed";
|
|
31
|
+
|
|
32
|
+
export type UninstallResultEntry = {
|
|
33
|
+
host: UninstallHost;
|
|
34
|
+
path: string;
|
|
35
|
+
status: UninstallResultStatus;
|
|
36
|
+
detail?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type UninstallResult = {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
entries: UninstallResultEntry[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Injectable homes mirroring setup's ApplySetupOptions subset (D-07). */
|
|
45
|
+
export type UninstallPaths = {
|
|
46
|
+
home?: string;
|
|
47
|
+
env?: NodeJS.ProcessEnv;
|
|
48
|
+
opencodeConfig?: string;
|
|
49
|
+
cursorSettings?: string;
|
|
50
|
+
cursorMcp?: string;
|
|
51
|
+
cursorPluginDir?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type ResolvedUninstall = {
|
|
55
|
+
opencodeConfig: string;
|
|
56
|
+
cursorSettings: string;
|
|
57
|
+
cursorMcp: string;
|
|
58
|
+
cursorPluginDir: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const resolveUninstallPaths = (options: UninstallPaths = {}): ResolvedUninstall => {
|
|
62
|
+
// Same chain as setup.ts (parity advisory): explicit option > env.HOME >
|
|
63
|
+
// homedir. No process.env tier — an ambient HOME must never leak past an
|
|
64
|
+
// explicitly empty injected env.
|
|
65
|
+
const home = options.home ?? options.env?.HOME ?? os.homedir();
|
|
66
|
+
return {
|
|
67
|
+
opencodeConfig:
|
|
68
|
+
options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
|
|
69
|
+
cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
|
|
70
|
+
cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
|
|
71
|
+
cursorPluginDir:
|
|
72
|
+
options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
77
|
+
v !== null && typeof v === "object" && !Array.isArray(v);
|
|
78
|
+
|
|
79
|
+
type Existing =
|
|
80
|
+
| { kind: "missing" }
|
|
81
|
+
| { kind: "malformed"; error: string }
|
|
82
|
+
| { kind: "record"; value: Record<string, unknown> };
|
|
83
|
+
|
|
84
|
+
const readJsonRecord = (file: string): Existing => {
|
|
85
|
+
let raw: string;
|
|
86
|
+
try {
|
|
87
|
+
raw = readFileSync(file, "utf8");
|
|
88
|
+
} catch {
|
|
89
|
+
// A read-permission error (EACCES) must not look like a missing file (same
|
|
90
|
+
// disambiguation as setup.ts readExisting): classify it malformed so plan
|
|
91
|
+
// keeps the host installed and apply reports Failed with the path untouched.
|
|
92
|
+
if (existsSync(file)) return { kind: "malformed", error: `${file} is not readable` };
|
|
93
|
+
return { kind: "missing" };
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const value = JSON.parse(raw);
|
|
97
|
+
return isRecord(value)
|
|
98
|
+
? { kind: "record", value }
|
|
99
|
+
: { kind: "malformed", error: `${file} is not a JSON object` };
|
|
100
|
+
} catch {
|
|
101
|
+
return { kind: "malformed", error: `${file} is not valid JSON` };
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Mirror of mergeCursorEnabledPlugins' strip(): trailing-separator-insensitive
|
|
106
|
+
// comparison that guards the filesystem root.
|
|
107
|
+
const stripTrailingSep = (p: string): string => {
|
|
108
|
+
const j = path.join(p);
|
|
109
|
+
return path.dirname(j) === j ? j : j.replace(/[\\/]+$/, "");
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const CURSOR_LEGACY_IDENTITIES = ["workflow-toolkit", "local/workflow-toolkit"];
|
|
113
|
+
|
|
114
|
+
// Inverse of mergeCursorSettings: drop workit identities from enabled_plugins,
|
|
115
|
+
// drop the canonical plugin dir entry from plugin_dirs. Returns the next record
|
|
116
|
+
// plus whether anything changed (plan/apply share this so outcomes match).
|
|
117
|
+
function cleanCursorSettings(
|
|
118
|
+
settings: Record<string, unknown>,
|
|
119
|
+
pluginDir: string,
|
|
120
|
+
): { next: Record<string, unknown>; changed: boolean } {
|
|
121
|
+
const next = { ...settings };
|
|
122
|
+
let changed = false;
|
|
123
|
+
if (isRecord(next.enabled_plugins)) {
|
|
124
|
+
const enabled = { ...(next.enabled_plugins as Record<string, unknown>) };
|
|
125
|
+
for (const identity of ["workit", ...CURSOR_LEGACY_IDENTITIES]) {
|
|
126
|
+
if (identity in enabled) {
|
|
127
|
+
delete enabled[identity];
|
|
128
|
+
changed = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
next.enabled_plugins = enabled;
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(next.plugin_dirs)) {
|
|
134
|
+
const canonical = stripTrailingSep(pluginDir);
|
|
135
|
+
const kept = (next.plugin_dirs as unknown[])
|
|
136
|
+
.map(String)
|
|
137
|
+
.filter((d) => stripTrailingSep(d) !== canonical);
|
|
138
|
+
if (kept.length !== (next.plugin_dirs as unknown[]).length) {
|
|
139
|
+
next.plugin_dirs = kept;
|
|
140
|
+
changed = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { next, changed };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Inverse of mergeOpenCodeConfig's plugin registration: remove every workit
|
|
147
|
+
// identity from the plugin list.
|
|
148
|
+
function cleanOpenCodeConfig(config: Record<string, unknown>): {
|
|
149
|
+
next: Record<string, unknown>;
|
|
150
|
+
changed: boolean;
|
|
151
|
+
} {
|
|
152
|
+
const next = { ...config };
|
|
153
|
+
let changed = false;
|
|
154
|
+
if (Array.isArray(next.plugin)) {
|
|
155
|
+
const plugins = (next.plugin as unknown[]).map(String);
|
|
156
|
+
const kept = plugins.filter((p) => !isWorkitPlugin(p));
|
|
157
|
+
if (kept.length !== plugins.length) {
|
|
158
|
+
next.plugin = kept;
|
|
159
|
+
changed = true;
|
|
160
|
+
}
|
|
161
|
+
} else if (typeof next.plugin === "string" && isWorkitPlugin(next.plugin)) {
|
|
162
|
+
delete next.plugin;
|
|
163
|
+
changed = true;
|
|
164
|
+
}
|
|
165
|
+
return { next, changed };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Inverse of mergeCursorMcp: drop the canonical server name and its legacy twin.
|
|
169
|
+
function cleanCursorMcp(mcp: Record<string, unknown>): {
|
|
170
|
+
next: Record<string, unknown>;
|
|
171
|
+
changed: boolean;
|
|
172
|
+
} {
|
|
173
|
+
const next = { ...mcp };
|
|
174
|
+
let changed = false;
|
|
175
|
+
if (isRecord(next.mcpServers)) {
|
|
176
|
+
const servers = { ...(next.mcpServers as Record<string, unknown>) };
|
|
177
|
+
for (const name of ["workit", "workflow-toolkit"]) {
|
|
178
|
+
if (name in servers) {
|
|
179
|
+
delete servers[name];
|
|
180
|
+
changed = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
next.mcpServers = servers;
|
|
184
|
+
}
|
|
185
|
+
return { next, changed };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Shared edit-json-remove executor for plan parity: parse → clean by target →
|
|
189
|
+
// report change without writing.
|
|
190
|
+
type JsonCleaner = (record: Record<string, unknown>) => {
|
|
191
|
+
next: Record<string, unknown>;
|
|
192
|
+
changed: boolean;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const jsonCleanerFor = (target: string, res: ResolvedUninstall): JsonCleaner | null => {
|
|
196
|
+
if (target === res.opencodeConfig) return cleanOpenCodeConfig;
|
|
197
|
+
if (target === res.cursorSettings) return (r) => cleanCursorSettings(r, res.cursorPluginDir);
|
|
198
|
+
if (target === res.cursorMcp) return cleanCursorMcp;
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** Pure planner: reports the uninstall actions Apply would perform. Reads host
|
|
203
|
+
* config files but never writes; ~/.config/workit is never an action target. */
|
|
204
|
+
export function planUninstall(paths: UninstallPaths = {}): UninstallPlan {
|
|
205
|
+
const res = resolveUninstallPaths(paths);
|
|
206
|
+
|
|
207
|
+
const ocExisting = readJsonRecord(res.opencodeConfig);
|
|
208
|
+
const ocDirty =
|
|
209
|
+
ocExisting.kind === "malformed" ||
|
|
210
|
+
(ocExisting.kind === "record" && cleanOpenCodeConfig(ocExisting.value).changed);
|
|
211
|
+
const opencode: UninstallHostPlan = {
|
|
212
|
+
host: "opencode",
|
|
213
|
+
installed: ocDirty,
|
|
214
|
+
actions: ocDirty
|
|
215
|
+
? [
|
|
216
|
+
{
|
|
217
|
+
kind: "edit-json-remove",
|
|
218
|
+
path: res.opencodeConfig,
|
|
219
|
+
detail: "remove workit plugin entries from opencode.json",
|
|
220
|
+
},
|
|
221
|
+
]
|
|
222
|
+
: [],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const settingsExisting = readJsonRecord(res.cursorSettings);
|
|
226
|
+
const mcpExisting = readJsonRecord(res.cursorMcp);
|
|
227
|
+
// A malformed host file is still planned: apply must surface the failure
|
|
228
|
+
// (file untouched) instead of silently pretending the host is clean.
|
|
229
|
+
const settingsDirty =
|
|
230
|
+
settingsExisting.kind === "malformed" ||
|
|
231
|
+
(settingsExisting.kind === "record" &&
|
|
232
|
+
cleanCursorSettings(settingsExisting.value, res.cursorPluginDir).changed);
|
|
233
|
+
const mcpDirty =
|
|
234
|
+
mcpExisting.kind === "malformed" ||
|
|
235
|
+
(mcpExisting.kind === "record" && cleanCursorMcp(mcpExisting.value).changed);
|
|
236
|
+
const dirExists = existsSync(res.cursorPluginDir);
|
|
237
|
+
const actions: UninstallAction[] = [];
|
|
238
|
+
if (settingsDirty) {
|
|
239
|
+
actions.push({
|
|
240
|
+
kind: "edit-json-remove",
|
|
241
|
+
path: res.cursorSettings,
|
|
242
|
+
detail: "remove workit enabled_plugins/plugin_dirs entries from settings.json",
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (mcpDirty) {
|
|
246
|
+
actions.push({
|
|
247
|
+
kind: "edit-json-remove",
|
|
248
|
+
path: res.cursorMcp,
|
|
249
|
+
detail: "remove the workit MCP server registration from mcp.json",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (dirExists) {
|
|
253
|
+
actions.push({
|
|
254
|
+
kind: "remove-dir",
|
|
255
|
+
path: res.cursorPluginDir,
|
|
256
|
+
detail: "delete the local workit plugin directory",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const cursor: UninstallHostPlan = {
|
|
260
|
+
host: "cursor",
|
|
261
|
+
installed: settingsDirty || mcpDirty || dirExists,
|
|
262
|
+
actions,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return { hosts: [opencode, cursor] };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// CA-14 traversal guard: rm -rf is permitted ONLY on the exact resolved
|
|
269
|
+
// canonical <home>/.cursor/plugins/local/workit directory. Any other resolved
|
|
270
|
+
// path (symlinked alias, sibling, traversal) fails closed without touching disk.
|
|
271
|
+
// Belt-and-braces (Task 8 advisory): when both sides resolve, their real paths
|
|
272
|
+
// must agree too — an ancestor symlink swapped in between plan and apply cannot
|
|
273
|
+
// widen the rm target. An unresolvable path falls through to the lexical
|
|
274
|
+
// verdict (apply then reports skipped/failed downstream).
|
|
275
|
+
const canonicalRemoveDirAllowed = (actionPath: string, res: ResolvedUninstall): boolean => {
|
|
276
|
+
const expected = path.resolve(res.cursorPluginDir);
|
|
277
|
+
if (
|
|
278
|
+
!(
|
|
279
|
+
actionPath === expected &&
|
|
280
|
+
path.basename(expected) === "workit" &&
|
|
281
|
+
path.basename(path.dirname(expected)) === "local" &&
|
|
282
|
+
path.basename(path.dirname(path.dirname(expected))) === "plugins"
|
|
283
|
+
)
|
|
284
|
+
) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
return realpathSync(actionPath) === realpathSync(expected);
|
|
289
|
+
} catch {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const applyEditJsonRemove = (
|
|
295
|
+
target: string,
|
|
296
|
+
cleaner: JsonCleaner,
|
|
297
|
+
): { status: UninstallResultStatus; detail?: string } => {
|
|
298
|
+
const existing = readJsonRecord(target);
|
|
299
|
+
if (existing.kind === "malformed") {
|
|
300
|
+
return { status: "failed", detail: `${existing.error} — file untouched` };
|
|
301
|
+
}
|
|
302
|
+
if (existing.kind === "missing") {
|
|
303
|
+
return { status: "skipped", detail: "file already absent" };
|
|
304
|
+
}
|
|
305
|
+
const { next, changed } = cleaner(existing.value);
|
|
306
|
+
// Only-if-changed: byte-preserving when there is nothing to remove.
|
|
307
|
+
if (!changed) return { status: "skipped", detail: "no workit entries present" };
|
|
308
|
+
const serialized = JSON.stringify(next, null, 2) + "\n";
|
|
309
|
+
// The byte-compare re-read races any external writer; a vanished/unreadable
|
|
310
|
+
// file between the two reads must fail THIS action, not abort the rest.
|
|
311
|
+
let current: string;
|
|
312
|
+
try {
|
|
313
|
+
current = readFileSync(target, "utf8");
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return {
|
|
316
|
+
status: "failed",
|
|
317
|
+
detail: `read failed before write: ${error instanceof Error ? error.message : String(error)}`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (current === serialized) {
|
|
321
|
+
return { status: "skipped", detail: "already clean" };
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
writeFileSync(target, serialized, "utf8");
|
|
325
|
+
} catch (error) {
|
|
326
|
+
return {
|
|
327
|
+
status: "failed",
|
|
328
|
+
detail: `write failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return { status: "removed" };
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** Applies ONLY the reviewed plan actions with the given path options. Each
|
|
335
|
+
* planned action yields exactly one result entry; malformed host JSON fails
|
|
336
|
+
* its own action untouched while the remaining actions proceed (CA-13). */
|
|
337
|
+
export function applyUninstall(plan: UninstallPlan, paths: UninstallPaths = {}): UninstallResult {
|
|
338
|
+
const res = resolveUninstallPaths(paths);
|
|
339
|
+
const entries: UninstallResultEntry[] = [];
|
|
340
|
+
for (const hostPlan of plan.hosts) {
|
|
341
|
+
for (const action of hostPlan.actions) {
|
|
342
|
+
let status: UninstallResultStatus;
|
|
343
|
+
let detail: string | undefined;
|
|
344
|
+
if (action.kind === "remove-dir") {
|
|
345
|
+
// Resolve before comparing so ".."/symlink tricks can never widen the rm.
|
|
346
|
+
const resolved = path.resolve(action.path);
|
|
347
|
+
if (!canonicalRemoveDirAllowed(resolved, res)) {
|
|
348
|
+
status = "failed";
|
|
349
|
+
detail = `refusing to remove non-canonical plugin directory: ${resolved}`;
|
|
350
|
+
} else if (!existsSync(resolved)) {
|
|
351
|
+
status = "skipped";
|
|
352
|
+
detail = "directory already absent";
|
|
353
|
+
} else {
|
|
354
|
+
try {
|
|
355
|
+
rmSync(resolved, { recursive: true, force: true });
|
|
356
|
+
status = "removed";
|
|
357
|
+
} catch (error) {
|
|
358
|
+
status = "failed";
|
|
359
|
+
detail = error instanceof Error ? error.message : String(error);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
const cleaner = jsonCleanerFor(action.path, res);
|
|
364
|
+
if (cleaner === null) {
|
|
365
|
+
status = "failed";
|
|
366
|
+
detail = `${action.path} is not a recognized uninstall target for this host`;
|
|
367
|
+
} else {
|
|
368
|
+
const outcome = applyEditJsonRemove(action.path, cleaner);
|
|
369
|
+
status = outcome.status;
|
|
370
|
+
detail = outcome.detail;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
entries.push({ host: hostPlan.host, path: action.path, status, detail });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { ok: entries.every((e) => e.status !== "failed"), entries };
|
|
377
|
+
}
|