@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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/scripts/bundled-deps.json +1 -7
- package/scripts/install-codex-plugin.sh +1 -2
- package/scripts/install-cursor-plugin.sh +3 -4
- package/scripts/install-opencode-plugin.sh +2 -3
- package/scripts/install-pi-package.sh +2 -3
- package/scripts/sync-runtime.sh +0 -13
- package/src/core/init.ts +1 -1
- package/src/core/support-matrix.ts +4 -4
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/forge/pr-ops.ts +13 -1
- package/src/hooks/hosts/opencode.ts +2 -2
- package/src/ledger.ts +43 -1
- package/src/stack.ts +1915 -0
- package/scripts/doctor-check.ts +0 -37
- package/src/core/config-conversion.ts +0 -180
- package/src/core/cutover.ts +0 -1789
- package/src/core/detect-hosts.ts +0 -115
- package/src/core/doctor.ts +0 -1882
- package/src/core/host-install.ts +0 -617
- package/src/core/legacy-ownership.ts +0 -47
- package/src/core/registration.ts +0 -306
- package/src/core/setup-state.ts +0 -61
- package/src/core/setup.ts +0 -1582
- package/src/core/uninstall.ts +0 -544
package/src/core/uninstall.ts
DELETED
|
@@ -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
|
-
}
|