@brainervirus/workit-core 2.9.0 → 4.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/src/core/setup.ts DELETED
@@ -1,1582 +0,0 @@
1
- import {
2
- copyFileSync,
3
- cpSync,
4
- existsSync,
5
- lstatSync,
6
- mkdirSync,
7
- mkdtempSync,
8
- readFileSync,
9
- readdirSync,
10
- realpathSync,
11
- renameSync,
12
- rmSync,
13
- statSync,
14
- writeFileSync,
15
- } from "node:fs";
16
- import os from "node:os";
17
- import path from "node:path";
18
- import { isDeepStrictEqual } from "node:util";
19
- import {
20
- configDir,
21
- mergePreset,
22
- readConfigFromDir,
23
- resolveConfigDir,
24
- type BranchPreset,
25
- type ToolkitConfig,
26
- } from "./config";
27
- import { detectBranchPolicy } from "./branch-policy";
28
- import { readSetupState, type SetupState } from "./setup-state";
29
- import {
30
- readWorkspacesResult,
31
- resolveWorkspaceFrom,
32
- validateWorkspacesDocument,
33
- validateWorkspaceGlob,
34
- workspacesRevision,
35
- type WorkspaceConfig,
36
- } from "./workspaces";
37
- import { GITIGNORE_ENTRIES } from "./gitignore";
38
- import { planHygieneFiles } from "./hygiene";
39
- import { packageRoot } from "./package-root";
40
- import {
41
- cursorMcpServerEntry,
42
- CURSOR_RUNTIME_PACKAGE,
43
- isWorkitPlugin,
44
- mergeCursorMcp,
45
- mergeOpenCodePlugins,
46
- mergeCursorSettings,
47
- mergeOpenCodeConfig,
48
- OPENCODE_NPM_PIN,
49
- } from "./registration";
50
- import { runDoctor, type DoctorReport } from "./doctor";
51
- import { writeFileExclusive } from "./safe-write";
52
- import {
53
- isCodexWorkitInstalled,
54
- isPiWorkitInstalled,
55
- isClaudeWorkitInstalled,
56
- claudeConfigDir,
57
- planHostInstall,
58
- runHostInstall,
59
- type HostCommandRunner,
60
- type HostId,
61
- type HostInstallCommand,
62
- runHostCommand,
63
- } from "./host-install";
64
-
65
- // Setup preview + apply (WZ-04-WZ-09, WZ-13-WZ-15; CA-12, CA-14, CA-22, CA-23).
66
- // buildSetupPreview is a pure reader: it classifies the current setup state and
67
- // returns the exact mutations Apply would perform WITHOUT applying them, so the
68
- // preview is authoritative and nothing touches the filesystem before Apply.
69
- // applySetupPreview applies ONLY the reviewed mutations using package-native
70
- // registration/assets, reports every platform/file independently, preserves
71
- // unrelated user config byte-for-byte, and verifies the result with the shared
72
- // offline doctor. Integrations are neutral and optional: youtrack is skipped
73
- // when baseUrl is empty, vcs when the provider is "skip".
74
-
75
- export const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
76
-
77
- export type VcsProvider = "gitlab" | "github";
78
-
79
- export function parseList(raw: string): string[] {
80
- return raw
81
- .split(",")
82
- .map((s) => s.trim())
83
- .filter(Boolean);
84
- }
85
-
86
- export type SetupPreviewInput = {
87
- platforms?: string[];
88
- locale: string;
89
- branchPreset: BranchPreset;
90
- branchAllowed: string;
91
- branchProtected: string;
92
- baseUrl: string;
93
- vcsProvider: VcsProvider | "skip";
94
- workspaces: WorkspaceConfig[];
95
- applyProject: boolean;
96
- /** Deliberate token-path overrides. When they differ from an existing
97
- * configured tokenFile, the replacement is planned as its own distinct
98
- * reviewed mutation (AR-10). */
99
- tokenPaths?: { youtrack?: string; gitlab?: string; github?: string };
100
- /** Optional global commit policy used as the base for repository overrides. */
101
- commitPolicy?: ToolkitConfig["commitPolicy"];
102
- };
103
-
104
- export type SetupMutation =
105
- | { type: "create-file"; path: string; content: string; mode?: number }
106
- | { type: "merge-json"; path: string; value: unknown }
107
- | {
108
- type: "update-workspaces";
109
- path: string;
110
- entries: WorkspaceConfig[];
111
- expectedRevision: string;
112
- }
113
- | { type: "append-gitignore"; path: string; entries: string[] }
114
- // AR-09: host registration merges and the adapter package copy are planned
115
- // during preview so the reviewed mutation set IS the Apply write set.
116
- | { type: "register-platform"; platform: Platform; path: string }
117
- | { type: "install-adapter"; platform: "cursor"; path: string }
118
- | { type: "install-host"; platform: HostId; path: string; commands: HostInstallCommand[] }
119
- // AR-10: changing a configured tokenFile path is never hidden inside a
120
- // generic merge — it is its own reviewed mutation.
121
- | { type: "set-token-path"; path: string; key: string; value: string };
122
-
123
- export type SetupOverride = {
124
- envKey: string;
125
- affects: string;
126
- value: string;
127
- note: string;
128
- };
129
-
130
- export type SetupPreview = {
131
- ok: boolean;
132
- /** Path-specific blocking diagnostics, set when any setup file is malformed. */
133
- blocked: string[];
134
- mutations: SetupMutation[];
135
- /** Environment overrides active for the shell init/apply path (RL-06). */
136
- overrides: SetupOverride[];
137
- /** Credential files left byte-for-byte untouched. */
138
- preserved: string[];
139
- /** Selected platform adapters to register on Apply (WZ-09). */
140
- platforms: string[];
141
- state: SetupState;
142
- };
143
-
144
- const YT_OVERRIDES: { envKey: string; affects: string }[] = [
145
- { envKey: "WORKFLOW_YT_BASE_URL", affects: "youtrack.json baseUrl" },
146
- { envKey: "WORKFLOW_YT_TOKEN_FILE", affects: "youtrack.json tokenFile" },
147
- { envKey: "WORKFLOW_YT_MEETING_ISSUE", affects: "youtrack.json meetingIssue" },
148
- { envKey: "WORKFLOW_YT_WEB_MEETING_ISSUE", affects: "youtrack.json web meeting issue" },
149
- ];
150
-
151
- const VCS_OVERRIDES: { envKey: string; affects: string }[] = [
152
- { envKey: "WORKFLOW_VCS_PROVIDER", affects: "vcs.json provider" },
153
- { envKey: "WORKFLOW_VCS_TARGET_BRANCH", affects: "vcs.json defaultTargetBranch" },
154
- { envKey: "WORKFLOW_GITLAB_HOST", affects: "vcs.json gitlab.host" },
155
- { envKey: "WORKFLOW_GITLAB_API_URL", affects: "vcs.json gitlab.apiUrl" },
156
- { envKey: "WORKFLOW_GITHUB_HOST", affects: "vcs.json github.host" },
157
- ];
158
-
159
- const SETUP_OVERRIDES = [...YT_OVERRIDES, ...VCS_OVERRIDES];
160
-
161
- export const activeSetupOverrides = (env: NodeJS.ProcessEnv = process.env): SetupOverride[] => {
162
- const overrides: SetupOverride[] = [];
163
- for (const { envKey, affects } of SETUP_OVERRIDES) {
164
- const value = env[envKey];
165
- if (value === undefined || value === "") continue;
166
- overrides.push({
167
- envKey,
168
- affects,
169
- value,
170
- note: "set in the environment; the interactive wizard does not apply it, but the shell init/apply path would use it",
171
- });
172
- }
173
- return overrides;
174
- };
175
-
176
- // Neutral youtrack.json draft: no organization names, issue IDs, greetings, or
177
- // language defaults (WZ-04/CA-14). merge-json preserves unrelated keys on Apply.
178
- // The tokenFile is the RESOLVED path (existing configured path reused, override
179
- // honored) — a default path never silently replaces a custom one (AR-10).
180
- function youtrackDraft(values: SetupPreviewInput, tokenPath: string): Record<string, unknown> {
181
- return {
182
- baseUrl: values.baseUrl.replace(/\/+$/, ""),
183
- tokenFile: tokenPath,
184
- locale: values.locale,
185
- tokenDefaults: {
186
- name: "workit",
187
- description: "OpenCode workit — /wk-issue-update and /wk-meetings",
188
- scopes: ["YouTrack"],
189
- profileTab: "account-security",
190
- },
191
- };
192
- }
193
-
194
- function vcsDraft(provider: VcsProvider): Record<string, unknown> {
195
- return {
196
- provider,
197
- gitlab: {
198
- host: "gitlab.com",
199
- apiUrl: "https://gitlab.com/api/v4",
200
- },
201
- github: { host: "github.com" },
202
- pr: { squashOnMerge: true, removeSourceBranch: true, pushBranch: true, confirmSkip: true },
203
- };
204
- }
205
-
206
- // Resolve the token file for a service: an explicit override wins, then the
207
- // configured tokenFile from an existing valid config, then the default path.
208
- // Reuse means a custom credential location stays authoritative (AR-10).
209
- const configuredTokenPath = (
210
- config: Record<string, unknown> | null,
211
- ...keys: string[]
212
- ): string | null => {
213
- let cursor: unknown = config;
214
- for (const key of keys) {
215
- if (!isRecord(cursor)) return null;
216
- cursor = cursor[key];
217
- }
218
- if (typeof cursor === "string" && cursor.trim() !== "") return cursor;
219
- return null;
220
- };
221
-
222
- const resolveTokenPath = (
223
- configured: string | null,
224
- fallback: string,
225
- override?: string,
226
- ): string => {
227
- if (override && override.trim() !== "") return override;
228
- return configured ?? fallback;
229
- };
230
-
231
- // Read a config file that readSetupState already classified as valid. Only
232
- // called inside the blocked.length === 0 branch, so unreadable/malformed files
233
- // never reach here; a missing file returns null.
234
- const readConfigRecord = (file: string): Record<string, unknown> | null => {
235
- try {
236
- const value = JSON.parse(readFileSync(file, "utf8"));
237
- return isRecord(value) ? value : null;
238
- } catch {
239
- return null;
240
- }
241
- };
242
-
243
- export function buildSetupPreview(
244
- values: SetupPreviewInput,
245
- opts: SetupPreviewOptions = {},
246
- ): SetupPreview {
247
- const env = opts.env ?? process.env;
248
- const state = readSetupState(opts.dir ?? resolveConfigDir());
249
- const blocked: string[] = [];
250
- const mutations: SetupMutation[] = [];
251
- const preserved: string[] = [];
252
- const overrides = activeSetupOverrides(env);
253
-
254
- for (const entry of [state.config, state.youtrack, state.vcs, state.workspaces]) {
255
- if (entry.status === "malformed") blocked.push(entry.error ?? entry.file);
256
- }
257
-
258
- if (blocked.length === 0) {
259
- const current = readConfigFromDir(state.configDir);
260
- mutations.push({
261
- type: "merge-json",
262
- path: path.join(state.configDir, "config.json"),
263
- value: {
264
- locale: values.locale,
265
- localeOptions: current.localeOptions,
266
- ...(values.commitPolicy === undefined ? {} : { commitPolicy: values.commitPolicy }),
267
- branchPolicy: mergePreset(
268
- values.branchPreset,
269
- {
270
- allowed: values.branchPreset === "custom" ? parseList(values.branchAllowed) : undefined,
271
- protectedNames:
272
- values.branchPreset === "custom" ? parseList(values.branchProtected) : undefined,
273
- },
274
- current,
275
- ),
276
- },
277
- });
278
-
279
- // WZ-12 parity: the draft is authoritative but a no-op workspaces section
280
- // (unchanged vs disk, including the initial seed) must not claim a rewrite.
281
- // Removing every workspace differs from disk and therefore still writes [].
282
- const diskWorkspaceResult = readWorkspacesResult(state.configDir);
283
- const diskWorkspaces = diskWorkspaceResult.entries;
284
- if (!isDeepStrictEqual(values.workspaces, diskWorkspaces)) {
285
- // RL-08: unsupported matcher grammar is rejected before any write —
286
- // same gate the direct writeWorkspaces path and the wizard enforce.
287
- for (const entry of values.workspaces) {
288
- if (!entry || typeof entry.glob !== "string") continue;
289
- const v = validateWorkspaceGlob(entry.glob);
290
- if (!v.ok) {
291
- blocked.push(v.error);
292
- break;
293
- }
294
- }
295
- if (blocked.length === 0) {
296
- mutations.push({
297
- type: "update-workspaces",
298
- path: path.join(state.configDir, "workspaces.json"),
299
- entries: values.workspaces,
300
- expectedRevision: diskWorkspaceResult.revision!,
301
- });
302
- }
303
- }
304
-
305
- if (values.baseUrl.trim()) {
306
- const ytPath = path.join(state.configDir, "youtrack.json");
307
- const ytExisting = readConfigRecord(ytPath);
308
- const ytConfigured = configuredTokenPath(ytExisting, "tokenFile");
309
- const tokenPath = resolveTokenPath(
310
- ytConfigured,
311
- path.join(state.configDir, "youtrack.token"),
312
- values.tokenPaths?.youtrack,
313
- );
314
- const draft = youtrackDraft(values, tokenPath);
315
- if (ytConfigured !== null && tokenPath !== ytConfigured) {
316
- // AR-10: a real tokenFile replacement is its own reviewed mutation —
317
- // never hidden inside the generic config merge.
318
- delete draft.tokenFile;
319
- mutations.push({
320
- type: "set-token-path",
321
- path: ytPath,
322
- key: "tokenFile",
323
- value: tokenPath,
324
- });
325
- }
326
- mutations.push({
327
- type: "merge-json",
328
- path: ytPath,
329
- value: draft,
330
- });
331
- if (existsSync(tokenPath)) preserved.push(tokenPath);
332
- else
333
- mutations.push({
334
- type: "create-file",
335
- path: tokenPath,
336
- content: TOKEN_PLACEHOLDER + "\n",
337
- mode: 0o600,
338
- });
339
- }
340
-
341
- if (values.vcsProvider !== "skip") {
342
- const vcsPath = path.join(state.configDir, "vcs.json");
343
- mutations.push({
344
- type: "merge-json",
345
- path: vcsPath,
346
- value: vcsDraft(values.vcsProvider),
347
- });
348
- }
349
-
350
- // AR-09: host registrations and the adapter package copy are planned here
351
- // with the exact target paths Apply will write, so the reviewed mutation
352
- // set IS the Apply write set. Apply re-resolves the adapter SOURCE with
353
- // its own options (dev/cwd) — a resolution failure reports Failed without
354
- // writing, it never creates an unreviewed write.
355
- const platforms = values.platforms ?? [];
356
- if (platforms.length > 0) {
357
- const paths = resolveSetupPaths(opts, state.configDir);
358
- for (const platform of platforms) {
359
- if (platform === "opencode") {
360
- mutations.push({ type: "register-platform", platform, path: paths.opencodeConfig });
361
- } else if (platform === "cursor") {
362
- const cursorRoot = adapterRoot("cursor", {
363
- ...paths,
364
- dev: opts.dev ?? env.WORKFLOW_TOOLKIT_DEV ?? null,
365
- });
366
- if (cursorRoot === null) {
367
- const commands = planHostInstall("cursor", {
368
- home: paths.home,
369
- cwd: paths.cwd,
370
- env,
371
- packageVersion: "latest",
372
- targetDir: paths.cursorPluginDir,
373
- });
374
- mutations.push({
375
- type: "install-host",
376
- platform: "cursor",
377
- path: path.join(
378
- paths.home,
379
- ".cursor",
380
- "plugins",
381
- "local",
382
- ".workit-package-bootstrap",
383
- ),
384
- commands,
385
- });
386
- }
387
- mutations.push({ type: "install-adapter", platform, path: paths.cursorPluginDir });
388
- mutations.push({ type: "register-platform", platform, path: paths.cursorSettings });
389
- mutations.push({ type: "register-platform", platform, path: paths.cursorMcp });
390
- } else if (platform === "codex" || platform === "pi" || platform === "claude-code") {
391
- const commands = planHostInstall(platform, { home: paths.home, cwd: paths.cwd, env });
392
- if (commands.length > 0) {
393
- mutations.push({
394
- type: "install-host",
395
- platform,
396
- path:
397
- platform === "claude-code"
398
- ? claudeInstallRecord(paths.home, env)
399
- : platform === "codex"
400
- ? path.join(
401
- env.CODEX_HOME ?? path.join(paths.home, ".codex"),
402
- "plugins",
403
- "cache",
404
- "workflow-toolkit",
405
- "workit",
406
- )
407
- : path.join(
408
- env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"),
409
- "settings.json",
410
- ),
411
- commands,
412
- });
413
- }
414
- }
415
- }
416
- }
417
-
418
- if (values.applyProject) {
419
- const root = path.resolve(opts.cwd ?? process.cwd());
420
- const gitignorePath = path.join(root, ".gitignore");
421
- const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
422
- const existingLines = new Set(
423
- existing
424
- .split("\n")
425
- .map((l) => l.trim())
426
- .filter(Boolean),
427
- );
428
- const entries = GITIGNORE_ENTRIES.filter(
429
- (e) => e.trim() !== "" && !existingLines.has(e.trim()),
430
- );
431
- mutations.push({ type: "append-gitignore", path: gitignorePath, entries });
432
- for (const planned of planHygieneFiles(root)) {
433
- mutations.push({ type: "create-file", path: planned.path, content: planned.content });
434
- }
435
- }
436
- }
437
-
438
- return {
439
- ok: blocked.length === 0,
440
- blocked,
441
- mutations,
442
- overrides,
443
- preserved,
444
- platforms: values.platforms ?? [],
445
- state,
446
- };
447
- }
448
-
449
- // ---------------------------------------------------------------------------
450
- // Apply (Task 14: WZ-09, WZ-10, WZ-13-WZ-15; CA-08, CA-13, CA-14, CA-31).
451
- // ---------------------------------------------------------------------------
452
-
453
- /** The Claude Code plugin registry a Workit install is recorded in. */
454
- const claudeInstallRecord = (home: string, env: NodeJS.ProcessEnv): string =>
455
- path.join(claudeConfigDir(home, env), "plugins", "installed_plugins.json");
456
-
457
- export type Platform = "opencode" | "cursor";
458
-
459
- export type SetupResultStatus = "Installed" | "Configured" | "Skipped" | "Failed";
460
-
461
- export type SetupResultEntry = {
462
- platform: HostId | "core";
463
- file: string;
464
- status: SetupResultStatus;
465
- detail?: string;
466
- };
467
-
468
- export type SetupResult = {
469
- ok: boolean;
470
- exitCode: number;
471
- entries: SetupResultEntry[];
472
- /** Credential files left byte-for-byte untouched. */
473
- preserved: string[];
474
- /** Path-specific blocking diagnostics when the preview was malformed. */
475
- blocked: string[];
476
- /** Post-apply doctor reports, one per verified platform host. */
477
- doctor: DoctorReport[];
478
- };
479
-
480
- export type SetupPreviewOptions = ApplySetupOptions & { dir?: string };
481
-
482
- export type ApplySetupOptions = {
483
- home?: string;
484
- configDir?: string;
485
- dev?: string;
486
- cwd?: string;
487
- env?: NodeJS.ProcessEnv;
488
- opencodeConfig?: string;
489
- cursorSettings?: string;
490
- cursorMcp?: string;
491
- cursorPluginDir?: string;
492
- stateDir?: string;
493
- /** Injectable synchronous host runner; defaults to a bounded no-shell spawn. */
494
- runHostCommand?: HostCommandRunner;
495
- /** Prefer staged npm adapter assets, used by the explicit upgrade flow. */
496
- preferInstalledAdapter?: boolean;
497
- };
498
-
499
- type ResolvedApply = {
500
- home: string;
501
- configDir: string;
502
- dev: string | null;
503
- cwd: string;
504
- env: NodeJS.ProcessEnv;
505
- opencodeConfig: string;
506
- cursorSettings: string;
507
- cursorMcp: string;
508
- cursorPluginDir: string;
509
- };
510
-
511
- // Shared path resolution for preview AND apply: the preview plans the platform
512
- // write targets with the same resolution apply uses, so the mutation paths ARE
513
- // the apply write paths (AR-09). Callers must build the preview and apply with
514
- // the same options (defaulting both from env) — tests pass `home` explicitly.
515
- const resolveSetupPaths = (
516
- options: ApplySetupOptions,
517
- resolvedConfigDir: string,
518
- ): Omit<ResolvedApply, "dev"> => {
519
- const env = options.env ?? process.env;
520
- const home = options.home ?? env.HOME ?? os.homedir();
521
- return {
522
- home,
523
- configDir: resolvedConfigDir,
524
- cwd: options.cwd ?? process.cwd(),
525
- env,
526
- opencodeConfig:
527
- options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
528
- cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
529
- cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
530
- cursorPluginDir:
531
- options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
532
- };
533
- };
534
-
535
- function resolveApply(preview: SetupPreview, options: ApplySetupOptions): ResolvedApply {
536
- const paths = resolveSetupPaths(options, options.configDir ?? preview.state.configDir);
537
- return {
538
- ...paths,
539
- dev: options.dev ?? paths.env.WORKFLOW_TOOLKIT_DEV ?? null,
540
- };
541
- }
542
-
543
- const isRecord = (v: unknown): v is Record<string, unknown> =>
544
- v !== null && typeof v === "object" && !Array.isArray(v);
545
-
546
- type Existing =
547
- | { kind: "missing" }
548
- | { kind: "malformed"; error: string }
549
- | { kind: "record"; value: Record<string, unknown>; raw: string };
550
-
551
- // Platform config (opencode.json/settings.json/mcp.json) is NOT covered by the
552
- // WZ-06 preview scan (which gates only the workit config dir), so a malformed
553
- // file here must not be silently replaced with a fresh config (CA-14). Callers
554
- // turn { kind: "malformed" } into a Failed entry and leave the file untouched.
555
- const readExisting = (p: string): Existing => {
556
- let raw: string;
557
- try {
558
- raw = readFileSync(p, "utf8");
559
- } catch {
560
- // A read-permission error (EACCES) must not look like a missing file: a
561
- // write attempt would throw after partially touching the filesystem.
562
- // Classify it malformed so callers report Failed with the path untouched.
563
- if (existsSync(p)) return { kind: "malformed", error: `${p} is not readable` };
564
- return { kind: "missing" };
565
- }
566
- let value: unknown;
567
- try {
568
- value = JSON.parse(raw);
569
- } catch {
570
- return { kind: "malformed", error: `${p} is not valid JSON` };
571
- }
572
- if (isRecord(value)) return { kind: "record", value, raw };
573
- return { kind: "malformed", error: `${p} is not a JSON object` };
574
- };
575
-
576
- // Deep-merge preserves every unrelated key/value; arrays and scalars are
577
- // replaced wholesale by the patch (the review is authoritative on its fields).
578
- function deepMerge(
579
- base: Record<string, unknown>,
580
- patch: Record<string, unknown>,
581
- ): Record<string, unknown> {
582
- const out: Record<string, unknown> = { ...base };
583
- for (const [key, value] of Object.entries(patch)) {
584
- const prev = out[key];
585
- out[key] = isRecord(prev) && isRecord(value) ? deepMerge(prev, value) : value;
586
- }
587
- return out;
588
- }
589
-
590
- const readFileSafe = (p: string): string | null => {
591
- try {
592
- return readFileSync(p, "utf8");
593
- } catch {
594
- return null;
595
- }
596
- };
597
-
598
- // Core mutations are dispatched by applyMutation; platform mutations
599
- // (register-platform / install-adapter) are handled by the dispatcher with
600
- // adapter resolution (AR-09).
601
- type CoreMutation = Exclude<
602
- SetupMutation,
603
- { type: "register-platform" } | { type: "install-adapter" } | { type: "install-host" }
604
- >;
605
-
606
- const workspaceEntriesAlreadyMatch = (
607
- contents: string | null,
608
- file: string,
609
- entries: WorkspaceConfig[],
610
- ): boolean => {
611
- if (contents === null) return entries.length === 0;
612
- try {
613
- const current = validateWorkspacesDocument(JSON.parse(contents), file);
614
- return (
615
- current.status === "valid" && JSON.stringify(current.entries) === JSON.stringify(entries)
616
- );
617
- } catch {
618
- return false;
619
- }
620
- };
621
-
622
- function applyMutation(m: CoreMutation): SetupResultEntry {
623
- const dir = path.dirname(m.path);
624
- switch (m.type) {
625
- case "create-file": {
626
- mkdirSync(dir, { recursive: true });
627
- const res = writeFileExclusive(m.path, m.content, m.mode);
628
- return res === "created"
629
- ? { platform: "core", file: m.path, status: "Installed" }
630
- : {
631
- platform: "core",
632
- file: m.path,
633
- status: "Skipped",
634
- detail: "file already exists — preserved",
635
- };
636
- }
637
- case "merge-json": {
638
- const existing = readExisting(m.path);
639
- if (existing.kind === "malformed") {
640
- return {
641
- platform: "core",
642
- file: m.path,
643
- status: "Failed",
644
- detail: `cannot merge into malformed config: ${existing.error}`,
645
- };
646
- }
647
- const merged =
648
- existing.kind === "record"
649
- ? deepMerge(existing.value, m.value as Record<string, unknown>)
650
- : (m.value as Record<string, unknown>);
651
- if (existing.kind === "record" && JSON.stringify(existing.value) === JSON.stringify(merged)) {
652
- return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
653
- }
654
- mkdirSync(dir, { recursive: true });
655
- writeFileSync(m.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
656
- return existing.kind === "record"
657
- ? { platform: "core", file: m.path, status: "Configured" }
658
- : { platform: "core", file: m.path, status: "Installed" };
659
- }
660
- case "update-workspaces": {
661
- const prev = readFileSafe(m.path);
662
- if (workspaceEntriesAlreadyMatch(prev, m.path, m.entries))
663
- return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
664
- if (workspacesRevision(prev) !== m.expectedRevision) {
665
- return {
666
- platform: "core",
667
- file: m.path,
668
- status: "Failed",
669
- detail:
670
- "workspace config changed after preview; rebuild the preview and review the new values",
671
- };
672
- }
673
- let document: Record<string, unknown> = {};
674
- if (prev !== null) {
675
- try {
676
- const current = validateWorkspacesDocument(JSON.parse(prev), m.path);
677
- if (current.status === "malformed" || current.status === "invalid") {
678
- return { platform: "core", file: m.path, status: "Failed", detail: current.error };
679
- }
680
- document = current.document ?? {};
681
- } catch {
682
- return {
683
- platform: "core",
684
- file: m.path,
685
- status: "Failed",
686
- detail: `${m.path} is not valid JSON`,
687
- };
688
- }
689
- }
690
- const proposed = validateWorkspacesDocument({ ...document, workspaces: m.entries }, m.path);
691
- if (proposed.status !== "valid") {
692
- return { platform: "core", file: m.path, status: "Failed", detail: proposed.error };
693
- }
694
- const next = JSON.stringify({ ...document, workspaces: m.entries }, null, 2) + "\n";
695
- if (prev === next)
696
- return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
697
- mkdirSync(dir, { recursive: true });
698
- const tmp = `${m.path}.${process.pid}.tmp`;
699
- try {
700
- writeFileSync(tmp, next, "utf8");
701
- if (workspacesRevision(readFileSafe(m.path)) !== m.expectedRevision) {
702
- rmSync(tmp, { force: true });
703
- return {
704
- platform: "core",
705
- file: m.path,
706
- status: "Failed",
707
- detail: "workspace config changed while applying the preview; rebuild and review it",
708
- };
709
- }
710
- renameSync(tmp, m.path);
711
- } catch (error) {
712
- rmSync(tmp, { force: true });
713
- return {
714
- platform: "core",
715
- file: m.path,
716
- status: "Failed",
717
- detail: `failed to write ${m.path}: ${error instanceof Error ? error.message : String(error)}`,
718
- };
719
- }
720
- return prev === null
721
- ? { platform: "core", file: m.path, status: "Installed" }
722
- : { platform: "core", file: m.path, status: "Configured" };
723
- }
724
- case "append-gitignore": {
725
- const existing = readFileSafe(m.path) ?? "";
726
- const existingLines = new Set(
727
- existing
728
- .split("\n")
729
- .map((l) => l.trim())
730
- .filter(Boolean),
731
- );
732
- const add = m.entries.filter((e) => e.trim() !== "" && !existingLines.has(e.trim()));
733
- if (add.length === 0) {
734
- return {
735
- platform: "core",
736
- file: m.path,
737
- status: "Skipped",
738
- detail: "all entries already present",
739
- };
740
- }
741
- mkdirSync(dir, { recursive: true });
742
- const separator = existing && !existing.endsWith("\n") ? "\n" : "";
743
- writeFileSync(
744
- m.path,
745
- existing + separator + (existing ? "\n" : "") + add.join("\n") + "\n",
746
- "utf8",
747
- );
748
- return {
749
- platform: "core",
750
- file: m.path,
751
- status: "Configured",
752
- detail: `appended ${add.length} entr${add.length === 1 ? "y" : "ies"}`,
753
- };
754
- }
755
- case "set-token-path": {
756
- // AR-10: a reviewed tokenFile replacement. Writes ONLY the config key at
757
- // the key path — the token file itself is a separate create-file or
758
- // preserved entry, and any file at the old path is never touched.
759
- const existing = readExisting(m.path);
760
- if (existing.kind === "malformed") {
761
- return {
762
- platform: "core",
763
- file: m.path,
764
- status: "Failed",
765
- detail: `cannot merge into malformed config: ${existing.error}`,
766
- };
767
- }
768
- const keys = m.key.split(".");
769
- const patch: Record<string, unknown> = {};
770
- let cursor = patch;
771
- for (const key of keys.slice(0, -1)) {
772
- const next: Record<string, unknown> = {};
773
- cursor[key] = next;
774
- cursor = next;
775
- }
776
- cursor[keys[keys.length - 1]] = m.value;
777
- const merged = deepMerge(existing.kind === "record" ? existing.value : {}, patch);
778
- if (existing.kind === "record" && JSON.stringify(existing.value) === JSON.stringify(merged)) {
779
- return {
780
- platform: "core",
781
- file: m.path,
782
- status: "Skipped",
783
- detail: "already configured",
784
- };
785
- }
786
- mkdirSync(path.dirname(m.path), { recursive: true });
787
- writeFileSync(m.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
788
- return existing.kind === "record"
789
- ? { platform: "core", file: m.path, status: "Configured" }
790
- : { platform: "core", file: m.path, status: "Installed" };
791
- }
792
- }
793
- }
794
-
795
- // Package-native adapter resolution: the packaged CLI/core resolve the adapter
796
- // packages from the same install (node_modules sibling), a dev checkout
797
- // (WORKFLOW_TOOLKIT_DEV / packages/workit-<host> walking up from cwd), or the
798
- // share clone (~/.local/share/workit) — mirroring the install scripts.
799
- const isAdapter = (root: string, platform: Platform): boolean => {
800
- try {
801
- const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as {
802
- name?: string;
803
- };
804
- return pkg.name === `@brainervirus/workit-${platform}`;
805
- } catch {
806
- return false;
807
- }
808
- };
809
-
810
- function adapterRoot(
811
- platform: Platform,
812
- res: ResolvedApply,
813
- preferInstalledAdapter = false,
814
- ): string | null {
815
- const candidates: string[] = [];
816
- const stagedCursor = path.join(
817
- res.home,
818
- ".cursor",
819
- "plugins",
820
- "local",
821
- ".workit-package-bootstrap",
822
- "node_modules",
823
- "@brainervirus",
824
- "workit-cursor",
825
- );
826
- if (platform === "cursor" && preferInstalledAdapter) candidates.push(stagedCursor);
827
- if (res.dev) {
828
- // A dev checkout is the complete source of truth for adapter packages: when
829
- // WORKFLOW_TOOLKIT_DEV is set, a package missing from it is a real failure,
830
- // not a reason to fall through to the running process's own install.
831
- candidates.push(path.join(res.dev, "packages", `workit-${platform}`));
832
- } else {
833
- // node_modules sibling (packed install) or monorepo sibling (source)
834
- candidates.push(path.join(packageRoot(), "..", `workit-${platform}`));
835
- // dev checkout walking up from cwd
836
- let dir = path.resolve(res.cwd);
837
- while (true) {
838
- candidates.push(path.join(dir, "packages", `workit-${platform}`));
839
- const parent = path.dirname(dir);
840
- if (parent === dir) break;
841
- dir = parent;
842
- }
843
- }
844
- candidates.push(
845
- path.join(res.home, ".local", "share", "workit", "packages", `workit-${platform}`),
846
- );
847
- if (platform === "cursor") candidates.push(stagedCursor);
848
- for (const candidate of candidates) {
849
- if (isAdapter(candidate, platform)) return candidate;
850
- }
851
- return null;
852
- }
853
-
854
- /** True when the adapter root is a monorepo checkout (dev pin), not a packed node_modules install. */
855
- export const isDevOpenCodeAdapterRoot = (root: string, dev: string | null | undefined): boolean => {
856
- if (dev) return true;
857
- const norm = root.replaceAll("\\", "/");
858
- return /\/packages\/workit-opencode\/?$/.test(norm);
859
- };
860
-
861
- /** Published installs pin the npm package; checkout installs pin file:// entry. */
862
- export const resolveOpenCodePin = (
863
- root: string,
864
- opts: { dev?: string | null } = {},
865
- ): string | null => {
866
- if (!isDevOpenCodeAdapterRoot(root, opts.dev)) return OPENCODE_NPM_PIN;
867
- for (const rel of ["src/plugin.ts", "dist/plugin.js"]) {
868
- const entry = path.join(root, rel);
869
- if (existsSync(entry)) return `file://${entry}`;
870
- }
871
- return null;
872
- };
873
-
874
- const opencodePin = (root: string, res: ResolvedApply): string | null =>
875
- resolveOpenCodePin(root, { dev: res.dev });
876
-
877
- function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
878
- const resolvedPin = opencodePin(root, res);
879
- if (!resolvedPin) {
880
- return {
881
- platform: "opencode",
882
- file: res.opencodeConfig,
883
- status: "Failed",
884
- detail:
885
- "workit-opencode package has no loadable plugin entry (src/plugin.ts or dist/plugin.js)",
886
- };
887
- }
888
- mkdirSync(path.dirname(res.opencodeConfig), { recursive: true });
889
- const existing = readExisting(res.opencodeConfig);
890
- if (existing.kind === "malformed") {
891
- return {
892
- platform: "opencode",
893
- file: res.opencodeConfig,
894
- status: "Failed",
895
- detail: `cannot merge into malformed config: ${existing.error} — repair or remove the file`,
896
- };
897
- }
898
- const configuredPlugins =
899
- existing.kind === "record"
900
- ? Array.isArray(existing.value.plugins)
901
- ? existing.value.plugins
902
- : typeof existing.value.plugins === "string"
903
- ? [existing.value.plugins]
904
- : typeof existing.value.plugin === "string"
905
- ? [existing.value.plugin]
906
- : Array.isArray(existing.value.plugin)
907
- ? existing.value.plugin
908
- : []
909
- : [];
910
- const configuredPin = configuredPlugins.find(
911
- (entry): entry is string => typeof entry === "string" && isWorkitPlugin(entry),
912
- );
913
- const pin = configuredPin ?? resolvedPin;
914
- const base = existing.kind === "record" ? existing.value : {};
915
- const merged =
916
- existing.kind === "record" && Object.hasOwn(existing.value, "plugins")
917
- ? (() => {
918
- const plugins = mergeOpenCodePlugins(existing.value.plugins, pin);
919
- return {
920
- config: { ...existing.value, plugins: plugins.config },
921
- changed: plugins.changed.length > 0 ? ["plugins"] : [],
922
- };
923
- })()
924
- : mergeOpenCodeConfig(base, pin);
925
- if (merged.changed.length === 0) {
926
- return {
927
- platform: "opencode",
928
- file: res.opencodeConfig,
929
- status: "Skipped",
930
- detail: "already registered",
931
- };
932
- }
933
- writeFileSync(res.opencodeConfig, JSON.stringify(merged.config, null, 2) + "\n", "utf8");
934
- return existing.kind === "record"
935
- ? {
936
- platform: "opencode",
937
- file: res.opencodeConfig,
938
- status: "Configured",
939
- detail: `pinned ${pin}`,
940
- }
941
- : {
942
- platform: "opencode",
943
- file: res.opencodeConfig,
944
- status: "Installed",
945
- detail: `pinned ${pin}`,
946
- };
947
- }
948
-
949
- // Mirror the sync-runtime plugin mirror: copy the package (minus node_modules)
950
- // and write the same `.workit-root` marker so a re-sync from the same
951
- // source is a truthful Skipped. The shipped mcp.json is the Marketplace-safe
952
- // npx command (CA-17), so it is copied verbatim — no absolute dist derivation.
953
- const samePluginContent = (src: string, dest: string, relative = ""): boolean => {
954
- try {
955
- for (const entry of readdirSync(src, { withFileTypes: true })) {
956
- if (entry.name === "node_modules") continue;
957
- const source = path.join(src, entry.name);
958
- const installed = path.join(dest, entry.name);
959
- if (entry.isDirectory()) {
960
- if (
961
- !statSync(installed).isDirectory() ||
962
- !samePluginContent(source, installed, path.join(relative, entry.name))
963
- )
964
- return false;
965
- } else if (!readFileSync(source).equals(readFileSync(installed))) {
966
- return false;
967
- }
968
- }
969
- for (const entry of readdirSync(dest, { withFileTypes: true })) {
970
- if (existsSync(path.join(src, entry.name))) continue;
971
- const rel = path.join(relative, entry.name);
972
- if (rel === ".workit-root") continue;
973
- if (relative === "rules" && entry.isFile() && entry.name.endsWith(".mdc")) continue;
974
- return false;
975
- }
976
- return true;
977
- } catch {
978
- return false;
979
- }
980
- };
981
-
982
- const preservedCursorRules = (src: string, dest: string): Map<string, Buffer> => {
983
- const preserved = new Map<string, Buffer>();
984
- const sourceRules = path.join(src, "rules");
985
- const installedRules = path.join(dest, "rules");
986
- try {
987
- for (const entry of readdirSync(installedRules, { withFileTypes: true })) {
988
- if (!entry.isFile() || !entry.name.endsWith(".mdc")) continue;
989
- if (existsSync(path.join(sourceRules, entry.name))) continue;
990
- preserved.set(entry.name, readFileSync(path.join(installedRules, entry.name)));
991
- }
992
- } catch {
993
- /* no compiled user rules */
994
- }
995
- return preserved;
996
- };
997
-
998
- /** Install Cursor plugin files as a real directory (never a symlink into a package cache). */
999
- export function copyPluginDir(src: string, dest: string): SetupResultStatus {
1000
- // pnpm dlx/pacquet (and similar) expose packages as symlinks into a content
1001
- // store. Node's cpSync copies a symlink *root* as a symlink, so the installed
1002
- // plugin would point into an ephemeral cache. Always materialize from the
1003
- // realpath so ~/.cursor/plugins/local/workit is a real directory.
1004
- const realSrc = realpathSync(src);
1005
- const marker = path.join(dest, ".workit-root");
1006
- const destIsLink = existsSync(dest) && lstatSync(dest).isSymbolicLink();
1007
- const synced =
1008
- !destIsLink && readFileSafe(marker)?.trim() === realSrc && samePluginContent(realSrc, dest);
1009
- if (synced) return "Skipped";
1010
- const hadDir = existsSync(dest);
1011
- const rules = preservedCursorRules(realSrc, dest);
1012
- const parent = path.dirname(dest);
1013
- mkdirSync(parent, { recursive: true });
1014
- const swap = mkdtempSync(path.join(parent, `.${path.basename(dest)}.swap-`));
1015
- const stage = path.join(swap, "stage");
1016
- const backup = path.join(swap, "backup");
1017
- try {
1018
- cpSync(realSrc, stage, {
1019
- recursive: true,
1020
- filter: (entry) => !path.relative(realSrc, entry).split(path.sep).includes("node_modules"),
1021
- });
1022
- for (const [name, content] of rules) {
1023
- mkdirSync(path.join(stage, "rules"), { recursive: true });
1024
- writeFileSync(path.join(stage, "rules", name), content);
1025
- }
1026
- writeFileSync(path.join(stage, ".workit-root"), realSrc + "\n", "utf8");
1027
- if (!samePluginContent(realSrc, stage)) throw new Error("staged adapter content is incomplete");
1028
- if (lstatSync(stage).isSymbolicLink()) {
1029
- throw new Error("staged adapter resolved to a symlink — refusing fragile cache install");
1030
- }
1031
- if (hadDir) renameSync(dest, backup);
1032
- try {
1033
- renameSync(stage, dest);
1034
- } catch (error) {
1035
- if (hadDir && !existsSync(dest)) renameSync(backup, dest);
1036
- throw error;
1037
- }
1038
- rmSync(backup, { recursive: true, force: true });
1039
- } finally {
1040
- rmSync(swap, { recursive: true, force: true });
1041
- }
1042
- return hadDir ? "Configured" : "Installed";
1043
- }
1044
-
1045
- // CA-08/CA-09: remove the exact legacy local plugin identity only AFTER the
1046
- // canonical `workit` copy and registration succeeded. The legacy dir's own
1047
- // user-compiled rules are carried forward first so no user data is lost; the
1048
- // `.workit-root` marker, share path, and unrelated sibling dirs are
1049
- // left untouched.
1050
- const removeLegacyCursorDir = (res: ResolvedApply): void => {
1051
- const legacy = path.join(res.home, ".cursor", "plugins", "local", "workflow-toolkit");
1052
- if (legacy === res.cursorPluginDir || !existsSync(legacy)) return;
1053
- const legacyRules = path.join(legacy, "rules");
1054
- if (existsSync(legacyRules)) {
1055
- try {
1056
- mkdirSync(path.join(res.cursorPluginDir, "rules"), { recursive: true });
1057
- for (const entry of readdirSync(legacyRules, { withFileTypes: true })) {
1058
- if (!entry.isFile() || !entry.name.endsWith(".mdc")) continue;
1059
- const target = path.join(res.cursorPluginDir, "rules", entry.name);
1060
- if (!existsSync(target)) copyFileSync(path.join(legacyRules, entry.name), target);
1061
- }
1062
- } catch {
1063
- /* best-effort rule carry-forward */
1064
- }
1065
- }
1066
- rmSync(legacy, { recursive: true, force: true });
1067
- };
1068
-
1069
- // One reviewed mutation per Cursor write target (AR-09): the settings merge and
1070
- // the mcp merge are dispatched independently, exactly like the adapter copy.
1071
- function applyCursorSettings(root: string, res: ResolvedApply): SetupResultEntry {
1072
- mkdirSync(path.dirname(res.cursorSettings), { recursive: true });
1073
- const settingsExisting = readExisting(res.cursorSettings);
1074
- if (settingsExisting.kind === "malformed") {
1075
- return {
1076
- platform: "cursor",
1077
- file: res.cursorSettings,
1078
- status: "Failed",
1079
- detail: `cannot merge into malformed config: ${settingsExisting.error} — repair or remove the file`,
1080
- };
1081
- }
1082
- const settings = mergeCursorSettings(
1083
- settingsExisting.kind === "record" ? settingsExisting.value : {},
1084
- res.cursorPluginDir,
1085
- );
1086
- if (settings.changed.length === 0) {
1087
- return {
1088
- platform: "cursor",
1089
- file: res.cursorSettings,
1090
- status: "Skipped",
1091
- detail: "already registered",
1092
- };
1093
- }
1094
- writeFileSync(res.cursorSettings, JSON.stringify(settings.config, null, 2) + "\n", "utf8");
1095
- return {
1096
- platform: "cursor",
1097
- file: res.cursorSettings,
1098
- status: settingsExisting.kind === "record" ? "Configured" : "Installed",
1099
- };
1100
- }
1101
-
1102
- function applyCursorMcp(root: string, res: ResolvedApply): SetupResultEntry {
1103
- mkdirSync(path.dirname(res.cursorMcp), { recursive: true });
1104
- const mcpExisting = readExisting(res.cursorMcp);
1105
- if (mcpExisting.kind === "malformed") {
1106
- return {
1107
- platform: "cursor",
1108
- file: res.cursorMcp,
1109
- status: "Failed",
1110
- detail: `cannot merge into malformed config: ${mcpExisting.error} — repair or remove the file`,
1111
- };
1112
- }
1113
- const mcpBase = mcpExisting.kind === "record" ? mcpExisting.value : {};
1114
- const server = cursorMcpServerEntry(res.cursorPluginDir);
1115
- const servers = isRecord(mcpBase.mcpServers) ? mcpBase.mcpServers : {};
1116
- const current = isRecord(servers.workit) ? servers.workit : {};
1117
- const previousArgs = Array.isArray(current.args) ? current.args.map(String) : [];
1118
- const previousPackage = previousArgs.find(
1119
- (arg) =>
1120
- arg.startsWith("--package=@brainervirus/workit-cursor@") &&
1121
- arg !== `--package=${CURSOR_RUNTIME_PACKAGE}`,
1122
- );
1123
- if (previousPackage) {
1124
- const index = server.args.findIndex((arg) => arg.startsWith("--package="));
1125
- if (index >= 0) server.args[index] = previousPackage;
1126
- }
1127
- const mcp = mergeCursorMcp(mcpBase, "workit", server);
1128
- if (mcp.changed.length === 0) {
1129
- return {
1130
- platform: "cursor",
1131
- file: res.cursorMcp,
1132
- status: "Skipped",
1133
- detail: "already registered",
1134
- };
1135
- }
1136
- writeFileSync(res.cursorMcp, JSON.stringify(mcp.config, null, 2) + "\n", "utf8");
1137
- return {
1138
- platform: "cursor",
1139
- file: res.cursorMcp,
1140
- status: mcpExisting.kind === "record" ? "Configured" : "Installed",
1141
- };
1142
- }
1143
-
1144
- function verifyPlatform(platform: Platform, res: ResolvedApply): DoctorReport | null {
1145
- if (platform === "opencode" && !existsSync(res.opencodeConfig)) return null;
1146
- if (platform === "cursor" && !existsSync(res.cursorSettings) && !existsSync(res.cursorMcp)) {
1147
- return null;
1148
- }
1149
- return runDoctor({
1150
- host: platform,
1151
- installer: true,
1152
- home: res.home,
1153
- configDir: res.configDir,
1154
- stateDir: res.env.WORKFLOW_TOOLKIT_STATE,
1155
- dev: res.dev ?? undefined,
1156
- cwd: res.cwd,
1157
- env: res.env,
1158
- opencodeConfig: res.opencodeConfig,
1159
- cursorSettings: res.cursorSettings,
1160
- cursorMcp: res.cursorMcp,
1161
- cursorPluginDir: res.cursorPluginDir,
1162
- });
1163
- }
1164
-
1165
- const failedDetail = (report: DoctorReport): string =>
1166
- report.checks
1167
- .filter((c) => c.status === "fail")
1168
- .map((c) => `${c.id}: ${c.detail}`)
1169
- .join("; ");
1170
-
1171
- export function applySetupPreview(
1172
- preview: SetupPreview,
1173
- options: ApplySetupOptions = {},
1174
- ): SetupResult {
1175
- if (!preview.ok) {
1176
- const malformed = [
1177
- preview.state.config,
1178
- preview.state.youtrack,
1179
- preview.state.vcs,
1180
- preview.state.workspaces,
1181
- ].filter((s) => s.status === "malformed");
1182
- return {
1183
- ok: false,
1184
- exitCode: 1,
1185
- entries: malformed.map((s) => ({
1186
- platform: "core" as const,
1187
- file: s.file,
1188
- status: "Failed" as const,
1189
- detail: s.error ?? "apply blocked by malformed configuration",
1190
- })),
1191
- preserved: [],
1192
- blocked: preview.blocked,
1193
- doctor: [],
1194
- };
1195
- }
1196
-
1197
- const workspaceMutation = preview.mutations.find(
1198
- (mutation): mutation is Extract<SetupMutation, { type: "update-workspaces" }> =>
1199
- mutation.type === "update-workspaces",
1200
- );
1201
- const currentWorkspaceContents = workspaceMutation ? readFileSafe(workspaceMutation.path) : null;
1202
- if (
1203
- workspaceMutation &&
1204
- workspacesRevision(currentWorkspaceContents) !== workspaceMutation.expectedRevision &&
1205
- !workspaceEntriesAlreadyMatch(
1206
- currentWorkspaceContents,
1207
- workspaceMutation.path,
1208
- workspaceMutation.entries,
1209
- )
1210
- ) {
1211
- return {
1212
- ok: false,
1213
- exitCode: 1,
1214
- entries: [
1215
- {
1216
- platform: "core",
1217
- file: workspaceMutation.path,
1218
- status: "Failed",
1219
- detail:
1220
- "workspace config changed after preview; rebuild the preview and review the new values",
1221
- },
1222
- ],
1223
- preserved: preview.preserved,
1224
- blocked: [],
1225
- doctor: [],
1226
- };
1227
- }
1228
-
1229
- const res = resolveApply(preview, options);
1230
- const entries: SetupResultEntry[] = [];
1231
- // AR-09/AR-13: Apply dispatches ONLY the reviewed mutation union. The adapter
1232
- // source is re-resolved with the apply options (dev/cwd); a resolution
1233
- // failure reports Failed without writing — it never produces a write that
1234
- // was not previewed. The reviewed path itself is code-enforced: a platform
1235
- // mutation whose planned path does not exactly equal the apply-time resolved
1236
- // path (a caller that resolved the preview with different options) fails
1237
- // fast — Failed, no write — instead of silently writing an unreviewed path.
1238
- const rootFor = new Map<Platform, string | null>();
1239
- // CA-09: cursor registration and legacy cleanup only proceed after the
1240
- // canonical copy succeeded — a failed replacement must never remove the
1241
- // legacy identity or its registration.
1242
- let cursorCopyOk = false;
1243
- for (const mutation of preview.mutations) {
1244
- if (mutation.type === "install-host") {
1245
- const expectedPath =
1246
- mutation.platform === "cursor"
1247
- ? path.join(res.home, ".cursor", "plugins", "local", ".workit-package-bootstrap")
1248
- : mutation.platform === "codex"
1249
- ? path.join(
1250
- res.env.CODEX_HOME ?? path.join(res.home, ".codex"),
1251
- "plugins",
1252
- "cache",
1253
- "workflow-toolkit",
1254
- "workit",
1255
- )
1256
- : mutation.platform === "pi"
1257
- ? path.join(
1258
- res.env.PI_CODING_AGENT_DIR ?? path.join(res.home, ".pi", "agent"),
1259
- "settings.json",
1260
- )
1261
- : mutation.platform === "claude-code"
1262
- ? claudeInstallRecord(res.home, res.env)
1263
- : "";
1264
- if (mutation.path !== expectedPath) {
1265
- entries.push({
1266
- platform: mutation.platform,
1267
- file: mutation.path,
1268
- status: "Failed",
1269
- detail: `preview/apply path mismatch: apply resolved ${expectedPath}, the previewed host install was ${mutation.path} — rebuild the preview with the same options`,
1270
- });
1271
- continue;
1272
- }
1273
- const run =
1274
- options.runHostCommand ??
1275
- ((step: HostInstallCommand) => runHostCommand(step, { home: res.home, env: res.env }));
1276
- const installed = runHostInstall(mutation.commands, run);
1277
- for (const { command, result } of installed.results) {
1278
- entries.push({
1279
- platform: mutation.platform,
1280
- file: command.command,
1281
- status: result.exitCode === 0 ? "Installed" : "Failed",
1282
- detail:
1283
- result.exitCode === 0
1284
- ? `${command.purpose} (${command.args.join(" ")})`
1285
- : `${command.purpose} failed (${command.args.join(" ")}): ${result.stderr || `exit ${result.exitCode}`}`,
1286
- });
1287
- }
1288
- if (!installed.ok && installed.results.length === 0) {
1289
- entries.push({
1290
- platform: mutation.platform,
1291
- file: mutation.platform,
1292
- status: "Failed",
1293
- detail: "host installation did not run",
1294
- });
1295
- }
1296
- if (!installed.ok) continue;
1297
- if (mutation.platform === "cursor" && installed.packageRoot) {
1298
- rootFor.set("cursor", installed.packageRoot);
1299
- }
1300
- const verified =
1301
- mutation.platform === "codex"
1302
- ? isCodexWorkitInstalled(res.home, res.env)
1303
- : mutation.platform === "pi"
1304
- ? isPiWorkitInstalled(res.home, res.env)
1305
- : mutation.platform === "claude-code"
1306
- ? isClaudeWorkitInstalled(res.home, res.env, null)
1307
- : true;
1308
- if (!verified) {
1309
- entries.push({
1310
- platform: mutation.platform,
1311
- file: mutation.platform,
1312
- status: "Failed",
1313
- detail:
1314
- "the host command succeeded, but Workit registration was not found; inspect the host's native package list and retry",
1315
- });
1316
- }
1317
- continue;
1318
- }
1319
- if (mutation.type !== "register-platform" && mutation.type !== "install-adapter") {
1320
- entries.push(applyMutation(mutation));
1321
- continue;
1322
- }
1323
- const platform = mutation.platform;
1324
- const resolvedPath =
1325
- mutation.type === "install-adapter"
1326
- ? res.cursorPluginDir
1327
- : platform === "opencode"
1328
- ? res.opencodeConfig
1329
- : mutation.path === res.cursorSettings
1330
- ? res.cursorSettings
1331
- : res.cursorMcp;
1332
- if (mutation.path !== resolvedPath) {
1333
- entries.push({
1334
- platform,
1335
- file: mutation.path,
1336
- status: "Failed",
1337
- detail: `preview/apply path mismatch: apply resolved ${resolvedPath}, the previewed write was ${mutation.path} — rebuild the preview with the same options`,
1338
- });
1339
- continue;
1340
- }
1341
- let root = rootFor.get(platform);
1342
- if (root === undefined) {
1343
- root = adapterRoot(platform, res, options.preferInstalledAdapter);
1344
- rootFor.set(platform, root);
1345
- }
1346
- if (root === null) {
1347
- entries.push({
1348
- platform,
1349
- file: mutation.path,
1350
- status: "Failed",
1351
- detail: `@brainervirus/workit-${platform} package not found — set WORKFLOW_TOOLKIT_DEV to your checkout or install the package`,
1352
- });
1353
- continue;
1354
- }
1355
- if (mutation.type === "install-adapter") {
1356
- try {
1357
- const status = copyPluginDir(root, mutation.path);
1358
- cursorCopyOk = true;
1359
- entries.push({ platform, file: mutation.path, status });
1360
- } catch (error) {
1361
- entries.push({
1362
- platform,
1363
- file: mutation.path,
1364
- status: "Failed",
1365
- detail: `adapter install failed: ${error instanceof Error ? error.message : String(error)}`,
1366
- });
1367
- }
1368
- } else if (platform === "opencode") {
1369
- entries.push(applyOpenCode(root, res));
1370
- } else if (!cursorCopyOk) {
1371
- // CA-09: the canonical copy failed — leave the legacy registration intact.
1372
- continue;
1373
- } else {
1374
- entries.push(
1375
- mutation.path === res.cursorSettings
1376
- ? applyCursorSettings(root, res)
1377
- : applyCursorMcp(root, res),
1378
- );
1379
- }
1380
- }
1381
- // CA-08/CA-09: migrate the legacy local identity only after the canonical
1382
- // copy AND registration both succeeded.
1383
- if (
1384
- preview.platforms.includes("cursor") &&
1385
- cursorCopyOk &&
1386
- !entries.some((e) => e.platform === "cursor" && e.status === "Failed")
1387
- ) {
1388
- removeLegacyCursorDir(res);
1389
- }
1390
- for (const preserved of preview.preserved) {
1391
- entries.push({
1392
- platform: "core",
1393
- file: preserved,
1394
- status: "Skipped",
1395
- detail: "existing credential preserved",
1396
- });
1397
- }
1398
-
1399
- // Post-apply verification per platform (read-only): a registered host whose
1400
- // writes succeeded is verified by the shared offline doctor.
1401
- const doctor: DoctorReport[] = [];
1402
- for (const platform of preview.platforms) {
1403
- if (platform !== "opencode" && platform !== "cursor") continue;
1404
- const root = rootFor.get(platform);
1405
- if (root === null || root === undefined) continue;
1406
- if (!entries.some((e) => e.platform === platform && e.status !== "Failed")) continue;
1407
- const report = verifyPlatform(platform, res);
1408
- if (!report) continue;
1409
- doctor.push(report);
1410
- if (report.exitCode === 0) continue;
1411
- if (platform === "opencode") {
1412
- const entry = entries.find((e) => e.platform === "opencode" && e.file === res.opencodeConfig);
1413
- if (entry) {
1414
- entry.status = "Failed";
1415
- entry.detail = `${entry.detail ? entry.detail + " — " : ""}doctor: ${failedDetail(report)}`;
1416
- }
1417
- } else {
1418
- const primary = entries.find((e) => e.platform === "cursor" && e.file === res.cursorSettings);
1419
- if (primary) {
1420
- primary.status = "Failed";
1421
- primary.detail = `${primary.detail ? primary.detail + " — " : ""}doctor: ${failedDetail(report)}`;
1422
- } else {
1423
- entries.push({
1424
- platform: "cursor",
1425
- file: res.cursorSettings,
1426
- status: "Failed",
1427
- detail: `doctor: ${failedDetail(report)}`,
1428
- });
1429
- }
1430
- }
1431
- }
1432
-
1433
- const failed = entries.some((e) => e.status === "Failed");
1434
- return {
1435
- ok: !failed,
1436
- exitCode: failed ? 1 : 0,
1437
- entries,
1438
- preserved: preview.preserved,
1439
- blocked: [],
1440
- doctor,
1441
- };
1442
- }
1443
-
1444
- // Completion guidance shown by the CLI after Apply (and on the non-TTY path):
1445
- // the doctor command + /wk-status are the next steps for verifying the result.
1446
- export const setupCompletionGuidance = (): string[] => [
1447
- "Run `workit doctor` to verify your installation.",
1448
- "Run /wk-status in OpenCode to review token setup steps.",
1449
- ];
1450
-
1451
- // Shared per-workspace branch policy writer (CA-06): the initApplyData
1452
- // branch_policy action (OpenCode/Cursor) and the Task 5 wizard both route
1453
- // through this proposal→write path, so the written bytes are identical. The
1454
- // write is idempotent: a matching entry is reported already-configured without
1455
- // touching the file; an existing entry is updated; otherwise appended.
1456
- export function applyWorkspaceBranchPolicy(opts: {
1457
- workspace_root: string;
1458
- env?: NodeJS.ProcessEnv;
1459
- }): Record<string, any> {
1460
- const { workspace_root, env = process.env } = opts;
1461
- const dir = path.join(env.WORKFLOW_TOOLKIT_CONFIG ?? configDir());
1462
- const {
1463
- status,
1464
- path: wsPath,
1465
- entries,
1466
- document,
1467
- revision,
1468
- error: workspacesError,
1469
- } = readWorkspacesResult(dir);
1470
- if (status === "malformed" || status === "invalid")
1471
- return { ok: false, error: workspacesError ?? `invalid workspaces.json: ${wsPath}` };
1472
- const detection = detectBranchPolicy(workspace_root);
1473
- const name = String(env.WORKFLOW_BP_NAME ?? path.basename(workspace_root));
1474
- const integration = (env.WORKFLOW_BP_INTEGRATION ?? detection.integration) as "pr" | "merge";
1475
- const policy = {
1476
- preset: detection.preset,
1477
- developBranch: env.WORKFLOW_BP_DEVELOP ?? detection.developBranch ?? undefined,
1478
- prefixes: detection.prefixes,
1479
- allowed: detection.allowed,
1480
- protected: detection.protected,
1481
- integration,
1482
- };
1483
- const glob = `${workspace_root.replace(/[\\/]+$/, "")}/**`;
1484
- if (!validateWorkspaceGlob(glob).ok)
1485
- return { ok: false, error: `invalid workspace glob: ${glob}` };
1486
- let existing: WorkspaceConfig | null;
1487
- try {
1488
- existing = resolveWorkspaceFrom(workspace_root, dir);
1489
- } catch (error) {
1490
- const selectedName = env.WORKFLOW_BP_NAME?.trim();
1491
- if (!selectedName)
1492
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
1493
- try {
1494
- existing = resolveWorkspaceFrom(workspace_root, dir, selectedName);
1495
- } catch (selectionError) {
1496
- return {
1497
- ok: false,
1498
- error: selectionError instanceof Error ? selectionError.message : String(selectionError),
1499
- };
1500
- }
1501
- }
1502
- if (existing?.branchPolicy && isDeepStrictEqual(existing.branchPolicy, policy)) {
1503
- return {
1504
- ok: true,
1505
- status: "already-configured",
1506
- workspace: existing,
1507
- policy,
1508
- config_path: wsPath,
1509
- };
1510
- }
1511
- const next = existing
1512
- ? entries.map((w) => (w.name === existing?.name ? { ...w, branchPolicy: policy } : w))
1513
- : [...entries, { name, glob, branchPolicy: policy }];
1514
- if (!existing && entries.some((entry) => entry.name === name))
1515
- return { ok: false, error: `workspace name ${JSON.stringify(name)} is already configured` };
1516
- const proposed = validateWorkspacesDocument({ ...document, workspaces: next }, wsPath);
1517
- if (proposed.status !== "valid") return { ok: false, error: proposed.error };
1518
- if (workspacesRevision(readFileSafe(wsPath)) !== revision)
1519
- return {
1520
- ok: false,
1521
- error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
1522
- };
1523
- mkdirSync(path.dirname(wsPath), { recursive: true });
1524
- const tmp = `${wsPath}.${process.pid}.tmp`;
1525
- try {
1526
- writeFileSync(tmp, JSON.stringify({ ...document, workspaces: next }, null, 2) + "\n", "utf8");
1527
- if (workspacesRevision(readFileSafe(wsPath)) !== revision) {
1528
- rmSync(tmp, { force: true });
1529
- return {
1530
- ok: false,
1531
- error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
1532
- };
1533
- }
1534
- renameSync(tmp, wsPath);
1535
- } catch (error) {
1536
- try {
1537
- rmSync(tmp, { force: true });
1538
- } catch {}
1539
- return {
1540
- ok: false,
1541
- error: `failed to write ${wsPath}: ${error instanceof Error ? error.message : String(error)}`,
1542
- };
1543
- }
1544
- return {
1545
- ok: true,
1546
- status: existing ? "updated" : "configured",
1547
- workspace: existing
1548
- ? { ...existing, branchPolicy: policy }
1549
- : { name, glob, branchPolicy: policy },
1550
- policy,
1551
- config_path: wsPath,
1552
- };
1553
- }
1554
-
1555
- export {
1556
- applyCutover,
1557
- applyRollback,
1558
- resumeCutover,
1559
- classifyHostGeneration,
1560
- readCutoverReceipt,
1561
- detectCursorLatest,
1562
- detectSourceLinkedOpenCode,
1563
- previewCutover,
1564
- previewRollback,
1565
- readGenerationState,
1566
- type CutoverDecision,
1567
- type CutoverHost,
1568
- type CutoverPaths,
1569
- type CutoverPlan,
1570
- type CutoverReceipt,
1571
- type GenerationState,
1572
- type RollbackPreview,
1573
- } from "./cutover";
1574
- export {
1575
- applyConversionConfig,
1576
- previewConversion,
1577
- redactConversionPreview,
1578
- type ConversionApplyResult,
1579
- type ConversionInput,
1580
- type ConversionMapping,
1581
- type ConversionPreview,
1582
- } from "./config-conversion";