@brainervirus/workit-core 0.9.2 → 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/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/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
|
+
}
|