@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Workit — workflow rails for agentic coding: specs, plans, YouTrack, CI-gated commits (shared core)",
6
6
  "keywords": [
@@ -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})?$`) before passing it.
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`, …).
@@ -122,7 +122,9 @@ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
122
122
 
123
123
  export const configDir = (): string => ensureConfigDir();
124
124
 
125
- export const LOCALE_RE = /^[a-z]{2,3}(-[A-Z]{2})?$/;
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
+ }