@brainervirus/workit-core 2.8.0 → 3.0.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.
@@ -1,544 +0,0 @@
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
- import {
14
- claudeWorkitInstalls,
15
- findHostExecutable,
16
- runHostCommand,
17
- type HostCommandRunner,
18
- } from "./host-install";
19
-
20
- export type UninstallHost = "opencode" | "cursor" | "codex" | "pi" | "claude-code";
21
-
22
- export type UninstallAction =
23
- | { kind: "edit-json-remove"; path: string; detail: string }
24
- | { kind: "remove-dir"; path: string; detail: string }
25
- /** A host-native uninstall (Claude Code owns its plugin cache and registry). */
26
- | {
27
- kind: "host-command";
28
- path: string;
29
- detail: string;
30
- command: "claude";
31
- args: string[];
32
- /** The project a project/local-scope uninstall must run from; null for user scope. */
33
- cwd: string | null;
34
- };
35
-
36
- export type UninstallHostPlan = {
37
- host: UninstallHost;
38
- installed: boolean;
39
- actions: UninstallAction[];
40
- };
41
-
42
- export type UninstallPlan = {
43
- hosts: UninstallHostPlan[];
44
- };
45
-
46
- export type UninstallResultStatus = "removed" | "skipped" | "failed";
47
-
48
- export type UninstallResultEntry = {
49
- host: UninstallHost;
50
- path: string;
51
- status: UninstallResultStatus;
52
- detail?: string;
53
- };
54
-
55
- export type UninstallResult = {
56
- ok: boolean;
57
- entries: UninstallResultEntry[];
58
- };
59
-
60
- /** Injectable homes mirroring setup's ApplySetupOptions subset (D-07). */
61
- export type UninstallPaths = {
62
- home?: string;
63
- env?: NodeJS.ProcessEnv;
64
- opencodeConfig?: string;
65
- cursorSettings?: string;
66
- cursorMcp?: string;
67
- cursorPluginDir?: string;
68
- /** Injectable host-command runner for host-native uninstall actions. */
69
- runHostCommand?: HostCommandRunner;
70
- /** Project directory for Claude Code project/local-scope installs (default: process cwd). */
71
- cwd?: string;
72
- };
73
-
74
- type ResolvedUninstall = {
75
- opencodeConfig: string;
76
- cursorSettings: string;
77
- cursorMcp: string;
78
- cursorPluginDir: string;
79
- codexPluginDir: string;
80
- piConfig: string;
81
- };
82
-
83
- const resolveUninstallPaths = (options: UninstallPaths = {}): ResolvedUninstall => {
84
- // Same chain as setup.ts (parity advisory): explicit option > env.HOME >
85
- // homedir. No process.env tier — an ambient HOME must never leak past an
86
- // explicitly empty injected env.
87
- const home = options.home ?? options.env?.HOME ?? os.homedir();
88
- return {
89
- opencodeConfig:
90
- options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
91
- cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
92
- cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
93
- cursorPluginDir:
94
- options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
95
- codexPluginDir: path.join(home, ".codex", "plugins", "workit"),
96
- piConfig: path.join(home, ".pi", "config.json"),
97
- };
98
- };
99
-
100
- const isRecord = (v: unknown): v is Record<string, unknown> =>
101
- v !== null && typeof v === "object" && !Array.isArray(v);
102
-
103
- type Existing =
104
- | { kind: "missing" }
105
- | { kind: "malformed"; error: string }
106
- | { kind: "record"; value: Record<string, unknown> };
107
-
108
- const readJsonRecord = (file: string): Existing => {
109
- let raw: string;
110
- try {
111
- raw = readFileSync(file, "utf8");
112
- } catch {
113
- // A read-permission error (EACCES) must not look like a missing file (same
114
- // disambiguation as setup.ts readExisting): classify it malformed so plan
115
- // keeps the host installed and apply reports Failed with the path untouched.
116
- if (existsSync(file)) return { kind: "malformed", error: `${file} is not readable` };
117
- return { kind: "missing" };
118
- }
119
- try {
120
- const value = JSON.parse(raw);
121
- return isRecord(value)
122
- ? { kind: "record", value }
123
- : { kind: "malformed", error: `${file} is not a JSON object` };
124
- } catch {
125
- return { kind: "malformed", error: `${file} is not valid JSON` };
126
- }
127
- };
128
-
129
- // Mirror of mergeCursorEnabledPlugins' strip(): trailing-separator-insensitive
130
- // comparison that guards the filesystem root.
131
- const stripTrailingSep = (p: string): string => {
132
- const j = path.join(p);
133
- return path.dirname(j) === j ? j : j.replace(/[\\/]+$/, "");
134
- };
135
-
136
- const CURSOR_LEGACY_IDENTITIES = ["workflow-toolkit", "local/workflow-toolkit"];
137
-
138
- // Inverse of mergeCursorSettings: drop workit identities from enabled_plugins,
139
- // drop the canonical plugin dir entry from plugin_dirs. Returns the next record
140
- // plus whether anything changed (plan/apply share this so outcomes match).
141
- function cleanCursorSettings(
142
- settings: Record<string, unknown>,
143
- pluginDir: string,
144
- ): { next: Record<string, unknown>; changed: boolean } {
145
- const next = { ...settings };
146
- let changed = false;
147
- if (isRecord(next.enabled_plugins)) {
148
- const enabled = { ...next.enabled_plugins };
149
- for (const identity of ["workit", ...CURSOR_LEGACY_IDENTITIES]) {
150
- if (identity in enabled) {
151
- delete enabled[identity];
152
- changed = true;
153
- }
154
- }
155
- next.enabled_plugins = enabled;
156
- }
157
- if (Array.isArray(next.plugin_dirs)) {
158
- const canonical = stripTrailingSep(pluginDir);
159
- const kept = (next.plugin_dirs as unknown[])
160
- .map(String)
161
- .filter((d) => stripTrailingSep(d) !== canonical);
162
- if (kept.length !== (next.plugin_dirs as unknown[]).length) {
163
- next.plugin_dirs = kept;
164
- changed = true;
165
- }
166
- }
167
- return { next, changed };
168
- }
169
-
170
- // Inverse of mergeOpenCodeConfig's plugin registration: remove every workit
171
- // identity from the plugin list.
172
- function cleanOpenCodeConfig(config: Record<string, unknown>): {
173
- next: Record<string, unknown>;
174
- changed: boolean;
175
- } {
176
- const next = { ...config };
177
- let changed = false;
178
- if (Array.isArray(next.plugin)) {
179
- const plugins = (next.plugin as unknown[]).map(String);
180
- const kept = plugins.filter((p) => !isWorkitPlugin(p));
181
- if (kept.length !== plugins.length) {
182
- next.plugin = kept;
183
- changed = true;
184
- }
185
- } else if (typeof next.plugin === "string" && isWorkitPlugin(next.plugin)) {
186
- delete next.plugin;
187
- changed = true;
188
- }
189
- return { next, changed };
190
- }
191
-
192
- // Inverse of mergeCursorMcp: drop the canonical server name and its legacy twin.
193
- function cleanCursorMcp(mcp: Record<string, unknown>): {
194
- next: Record<string, unknown>;
195
- changed: boolean;
196
- } {
197
- const next = { ...mcp };
198
- let changed = false;
199
- if (isRecord(next.mcpServers)) {
200
- const servers = { ...next.mcpServers };
201
- for (const name of ["workit", "workflow-toolkit"]) {
202
- if (name in servers) {
203
- delete servers[name];
204
- changed = true;
205
- }
206
- }
207
- next.mcpServers = servers;
208
- }
209
- return { next, changed };
210
- }
211
-
212
- // Shared edit-json-remove executor for plan parity: parse → clean by target →
213
- // report change without writing.
214
- type JsonCleaner = (record: Record<string, unknown>) => {
215
- next: Record<string, unknown>;
216
- changed: boolean;
217
- };
218
-
219
- function cleanPiConfig(config: Record<string, unknown>): {
220
- next: Record<string, unknown>;
221
- changed: boolean;
222
- } {
223
- const next = { ...config };
224
- let changed = false;
225
- for (const key of ["extensions", "skills"] as const) {
226
- const value = next[key];
227
- if (!Array.isArray(value)) continue;
228
- const kept = value.filter((entry) => !String(entry).includes("workit"));
229
- if (kept.length !== value.length) {
230
- next[key] = kept;
231
- changed = true;
232
- }
233
- }
234
- const pi = isRecord(next.pi) ? { ...next.pi } : null;
235
- if (pi && Array.isArray(pi.extensions)) {
236
- const kept = pi.extensions.filter((entry) => !String(entry).includes("workit"));
237
- if (kept.length !== pi.extensions.length) {
238
- pi.extensions = kept;
239
- next.pi = pi;
240
- changed = true;
241
- }
242
- }
243
- return { next, changed };
244
- }
245
-
246
- const jsonCleanerFor = (target: string, res: ResolvedUninstall): JsonCleaner | null => {
247
- if (target === res.opencodeConfig) return cleanOpenCodeConfig;
248
- if (target === res.cursorSettings) return (r) => cleanCursorSettings(r, res.cursorPluginDir);
249
- if (target === res.cursorMcp) return cleanCursorMcp;
250
- if (target === res.piConfig) return cleanPiConfig;
251
- return null;
252
- };
253
-
254
- /** Pure planner: reports the uninstall actions Apply would perform. Reads host
255
- * config files but never writes; ~/.config/workit is never an action target. */
256
- export function planUninstall(paths: UninstallPaths = {}): UninstallPlan {
257
- const res = resolveUninstallPaths(paths);
258
-
259
- const ocExisting = readJsonRecord(res.opencodeConfig);
260
- const ocDirty =
261
- ocExisting.kind === "malformed" ||
262
- (ocExisting.kind === "record" && cleanOpenCodeConfig(ocExisting.value).changed);
263
- const opencode: UninstallHostPlan = {
264
- host: "opencode",
265
- installed: ocDirty,
266
- actions: ocDirty
267
- ? [
268
- {
269
- kind: "edit-json-remove",
270
- path: res.opencodeConfig,
271
- detail: "remove workit plugin entries from opencode.json",
272
- },
273
- ]
274
- : [],
275
- };
276
-
277
- const settingsExisting = readJsonRecord(res.cursorSettings);
278
- const mcpExisting = readJsonRecord(res.cursorMcp);
279
- // A malformed host file is still planned: apply must surface the failure
280
- // (file untouched) instead of silently pretending the host is clean.
281
- const settingsDirty =
282
- settingsExisting.kind === "malformed" ||
283
- (settingsExisting.kind === "record" &&
284
- cleanCursorSettings(settingsExisting.value, res.cursorPluginDir).changed);
285
- const mcpDirty =
286
- mcpExisting.kind === "malformed" ||
287
- (mcpExisting.kind === "record" && cleanCursorMcp(mcpExisting.value).changed);
288
- const dirExists = existsSync(res.cursorPluginDir);
289
- const actions: UninstallAction[] = [];
290
- if (settingsDirty) {
291
- actions.push({
292
- kind: "edit-json-remove",
293
- path: res.cursorSettings,
294
- detail: "remove workit enabled_plugins/plugin_dirs entries from settings.json",
295
- });
296
- }
297
- if (mcpDirty) {
298
- actions.push({
299
- kind: "edit-json-remove",
300
- path: res.cursorMcp,
301
- detail: "remove the workit MCP server registration from mcp.json",
302
- });
303
- }
304
- if (dirExists) {
305
- actions.push({
306
- kind: "remove-dir",
307
- path: res.cursorPluginDir,
308
- detail: "delete the local workit plugin directory",
309
- });
310
- }
311
- const cursor: UninstallHostPlan = {
312
- host: "cursor",
313
- installed: settingsDirty || mcpDirty || dirExists,
314
- actions,
315
- };
316
-
317
- const codexInstalled = existsSync(res.codexPluginDir);
318
- const codex: UninstallHostPlan = {
319
- host: "codex",
320
- installed: codexInstalled,
321
- actions: codexInstalled
322
- ? [{ kind: "remove-dir", path: res.codexPluginDir, detail: "remove Codex workit plugin dir" }]
323
- : [],
324
- };
325
-
326
- const piExisting = readJsonRecord(res.piConfig);
327
- const piDirty =
328
- piExisting.kind === "malformed" ||
329
- (piExisting.kind === "record" && cleanPiConfig(piExisting.value).changed);
330
- const pi: UninstallHostPlan = {
331
- host: "pi",
332
- installed: piDirty,
333
- actions: piDirty
334
- ? [
335
- {
336
- kind: "edit-json-remove",
337
- path: res.piConfig,
338
- detail: "remove workit pi extension/skills entries",
339
- },
340
- ]
341
- : [],
342
- };
343
-
344
- // Claude Code: one native `claude plugin uninstall <id> --scope <scope>`
345
- // per recorded Workit install that applies here (user scope, plus this
346
- // project's project/local scope, run from that project). A --plugin-dir
347
- // pin is never recorded.
348
- const home = paths.home ?? paths.env?.HOME ?? os.homedir();
349
- const claudeInstalls = claudeWorkitInstalls(
350
- home,
351
- paths.env ?? process.env,
352
- paths.cwd ?? process.cwd(),
353
- );
354
- const claude: UninstallHostPlan = {
355
- host: "claude-code",
356
- installed: claudeInstalls.length > 0,
357
- actions: claudeInstalls.map((install) => ({
358
- kind: "host-command" as const,
359
- path: install.installPath,
360
- detail: `claude plugin uninstall ${install.id} --scope ${install.scope}${
361
- install.projectPath ? ` (in ${install.projectPath})` : ""
362
- }`,
363
- command: "claude" as const,
364
- args: ["plugin", "uninstall", install.id, "--scope", install.scope],
365
- cwd: install.projectPath,
366
- })),
367
- };
368
-
369
- return { hosts: [opencode, cursor, codex, pi, claude] };
370
- }
371
-
372
- // CA-14 traversal guard: rm -rf is permitted ONLY on the exact resolved
373
- // canonical <home>/.cursor/plugins/local/workit directory. Any other resolved
374
- // path (symlinked alias, sibling, traversal) fails closed without touching disk.
375
- // Belt-and-braces (Task 8 advisory): when both sides resolve, their real paths
376
- // must agree too — an ancestor symlink swapped in between plan and apply cannot
377
- // widen the rm target. An unresolvable path falls through to the lexical
378
- // verdict (apply then reports skipped/failed downstream).
379
- const canonicalRemoveDirAllowed = (actionPath: string, res: ResolvedUninstall): boolean => {
380
- const expected = path.resolve(res.cursorPluginDir);
381
- if (
382
- !(
383
- actionPath === expected &&
384
- path.basename(expected) === "workit" &&
385
- path.basename(path.dirname(expected)) === "local" &&
386
- path.basename(path.dirname(path.dirname(expected))) === "plugins"
387
- )
388
- ) {
389
- return false;
390
- }
391
- try {
392
- return realpathSync(actionPath) === realpathSync(expected);
393
- } catch {
394
- return true;
395
- }
396
- };
397
-
398
- const applyEditJsonRemove = (
399
- target: string,
400
- cleaner: JsonCleaner,
401
- ): { status: UninstallResultStatus; detail?: string } => {
402
- const existing = readJsonRecord(target);
403
- if (existing.kind === "malformed") {
404
- return { status: "failed", detail: `${existing.error} — file untouched` };
405
- }
406
- if (existing.kind === "missing") {
407
- return { status: "skipped", detail: "file already absent" };
408
- }
409
- const { next, changed } = cleaner(existing.value);
410
- // Only-if-changed: byte-preserving when there is nothing to remove.
411
- if (!changed) return { status: "skipped", detail: "no workit entries present" };
412
- const serialized = JSON.stringify(next, null, 2) + "\n";
413
- // The byte-compare re-read races any external writer; a vanished/unreadable
414
- // file between the two reads must fail THIS action, not abort the rest.
415
- let current: string;
416
- try {
417
- current = readFileSync(target, "utf8");
418
- } catch (error) {
419
- return {
420
- status: "failed",
421
- detail: `read failed before write: ${error instanceof Error ? error.message : String(error)}`,
422
- };
423
- }
424
- if (current === serialized) {
425
- return { status: "skipped", detail: "already clean" };
426
- }
427
- try {
428
- writeFileSync(target, serialized, "utf8");
429
- } catch (error) {
430
- return {
431
- status: "failed",
432
- detail: `write failed: ${error instanceof Error ? error.message : String(error)}`,
433
- };
434
- }
435
- return { status: "removed" };
436
- };
437
-
438
- const CLAUDE_PLUGIN_ID = /^workit@[A-Za-z0-9._-]+$/;
439
-
440
- /** Runs a reviewed `claude plugin uninstall workit@<marketplace>`; any other
441
- * argv is refused, so a tampered plan cannot run an arbitrary command. */
442
- const applyClaudeUninstall = (
443
- action: Extract<UninstallAction, { kind: "host-command" }>,
444
- paths: UninstallPaths,
445
- ): { status: UninstallResultStatus; detail?: string } => {
446
- const [verb, sub, id, flag, scope, ...rest] = action.args;
447
- const projectScoped = scope === "project" || scope === "local";
448
- if (
449
- action.command !== "claude" ||
450
- verb !== "plugin" ||
451
- sub !== "uninstall" ||
452
- !CLAUDE_PLUGIN_ID.test(id ?? "") ||
453
- flag !== "--scope" ||
454
- !(scope === "user" || projectScoped) ||
455
- rest.length > 0 ||
456
- // user scope runs anywhere; project/local only from their project.
457
- (projectScoped ? typeof action.cwd !== "string" : action.cwd !== null)
458
- )
459
- return {
460
- status: "failed",
461
- detail: `refusing unreviewed host command: ${action.args.join(" ")}`,
462
- };
463
- const home = paths.home ?? paths.env?.HOME ?? os.homedir();
464
- const env = paths.env ?? process.env;
465
- const recorded = claudeWorkitInstalls(home, env, action.cwd).some(
466
- (install) =>
467
- install.id === id &&
468
- install.scope === scope &&
469
- (install.projectPath === null
470
- ? action.cwd === null
471
- : path.resolve(install.projectPath) === path.resolve(action.cwd ?? "")),
472
- );
473
- if (!recorded)
474
- return { status: "skipped", detail: `${id} (${scope} scope) is no longer installed` };
475
- const executable = findHostExecutable("claude", { home, env });
476
- if (!executable && !paths.runHostCommand)
477
- return { status: "failed", detail: "claude executable was not found on PATH" };
478
- const step = {
479
- command: executable ?? "claude",
480
- args: action.args,
481
- ...(action.cwd === null ? {} : { cwd: action.cwd }),
482
- purpose: `Uninstall the Workit Claude Code plugin ${id} (${scope} scope)`,
483
- };
484
- const result = paths.runHostCommand
485
- ? paths.runHostCommand(step)
486
- : runHostCommand(step, { home, env });
487
- return result.exitCode === 0
488
- ? { status: "removed", detail: action.detail }
489
- : { status: "failed", detail: result.stderr || `exit ${result.exitCode}` };
490
- };
491
-
492
- /** Applies ONLY the reviewed plan actions with the given path options. Each
493
- * planned action yields exactly one result entry; malformed host JSON fails
494
- * its own action untouched while the remaining actions proceed (CA-13). */
495
- export function applyUninstall(plan: UninstallPlan, paths: UninstallPaths = {}): UninstallResult {
496
- const res = resolveUninstallPaths(paths);
497
- const entries: UninstallResultEntry[] = [];
498
- for (const hostPlan of plan.hosts) {
499
- for (const action of hostPlan.actions) {
500
- let status: UninstallResultStatus;
501
- let detail: string | undefined;
502
- if (action.kind === "host-command") {
503
- const outcome = applyClaudeUninstall(action, paths);
504
- status = outcome.status;
505
- detail = outcome.detail;
506
- } else if (action.kind === "remove-dir") {
507
- // Resolve before comparing so ".."/symlink tricks can never widen the rm.
508
- const resolved = path.resolve(action.path);
509
- const allowed =
510
- canonicalRemoveDirAllowed(resolved, res) ||
511
- (resolved === path.resolve(res.codexPluginDir) &&
512
- path.basename(resolved) === "workit" &&
513
- path.basename(path.dirname(resolved)) === "plugins");
514
- if (!allowed) {
515
- status = "failed";
516
- detail = `refusing to remove non-canonical plugin directory: ${resolved}`;
517
- } else if (!existsSync(resolved)) {
518
- status = "skipped";
519
- detail = "directory already absent";
520
- } else {
521
- try {
522
- rmSync(resolved, { recursive: true, force: true });
523
- status = "removed";
524
- } catch (error) {
525
- status = "failed";
526
- detail = error instanceof Error ? error.message : String(error);
527
- }
528
- }
529
- } else {
530
- const cleaner = jsonCleanerFor(action.path, res);
531
- if (cleaner === null) {
532
- status = "failed";
533
- detail = `${action.path} is not a recognized uninstall target for this host`;
534
- } else {
535
- const outcome = applyEditJsonRemove(action.path, cleaner);
536
- status = outcome.status;
537
- detail = outcome.detail;
538
- }
539
- }
540
- entries.push({ host: hostPlan.host, path: action.path, status, detail });
541
- }
542
- }
543
- return { ok: entries.every((e) => e.status !== "failed"), entries };
544
- }