@brainervirus/workit-core 0.6.0 → 0.7.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 +10 -14
- package/scripts/doctor-check.ts +20 -0
- package/scripts/install-cursor-plugin.sh +51 -28
- package/scripts/install-opencode-plugin.sh +25 -22
- package/scripts/rewrite-workspace-deps.ts +15 -9
- package/scripts/sync-runtime.sh +71 -19
- package/scripts/vendor-assets.ts +37 -0
- package/skills/wk-implement/SKILL.md +2 -2
- package/skills/wk-pr/SKILL.md +1 -1
- package/src/core/boundary.ts +27 -0
- package/src/core/branch-policy.ts +63 -0
- package/src/core/branch.ts +166 -57
- package/src/core/changelog.ts +17 -14
- package/src/core/config-guard.ts +9 -2
- package/src/core/config.ts +227 -32
- package/src/core/detector.ts +22 -11
- package/src/core/docs-layout.ts +251 -0
- package/src/core/docs-migration.ts +639 -0
- package/src/core/docs-repo.ts +58 -21
- package/src/core/docs-validate.ts +181 -43
- package/src/core/doctor.ts +801 -0
- package/src/core/flow-state.ts +1579 -137
- package/src/core/git.ts +22 -5
- package/src/core/gitignore.ts +11 -2
- package/src/core/handoff-context.ts +18 -5
- package/src/{tools/handoff.ts → core/handoff-tools.ts} +16 -63
- package/src/core/hygiene.ts +53 -17
- package/src/core/init.ts +128 -31
- package/src/core/logger.ts +321 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/parse-sections.ts +2 -2
- package/src/core/plan-tasks.ts +13 -3
- package/src/core/ports/init-toolkit-status.ts +1 -1
- package/src/core/ports/vcs-verify-token.ts +1 -1
- package/src/core/ports/youtrack-api.ts +4 -2
- package/src/core/ports/youtrack-config.ts +1 -3
- package/src/core/ports/youtrack-verify-token.ts +1 -1
- package/src/core/pr-create.ts +156 -29
- package/src/core/present.ts +11 -2
- package/src/core/registration.ts +215 -0
- package/src/core/reminder.ts +1 -2
- package/src/core/repo-context.ts +447 -0
- package/src/core/repo-tool.ts +4 -1
- package/src/core/repo-tools.ts +23 -0
- package/src/core/rules.ts +10 -7
- package/src/core/safe-write.ts +22 -0
- package/src/core/scripts.ts +3 -39
- package/src/core/sdd.ts +56 -31
- package/src/core/setup-state.ts +54 -0
- package/src/core/setup.ts +1216 -0
- package/src/core/skill-manifests.ts +95 -0
- package/src/core/support-matrix.ts +12 -0
- package/src/core/sync-runtime.ts +348 -0
- package/src/core/templates.ts +16 -6
- package/src/core/vcs-config.ts +191 -62
- package/src/core/verify-parse.ts +4 -2
- package/src/core/verify-project.ts +181 -0
- package/src/core/workspaces.ts +136 -17
- package/src/core/youtrack-tools.ts +228 -0
- package/src/core/youtrack.ts +320 -87
- package/src/core.ts +18 -3
- package/templates/execution-contract.md +9 -7
- package/templates/superpowers-doc-contract.md +5 -4
- package/scripts/_shared/common.sh +0 -158
- package/scripts/changelog-context.sh +0 -42
- package/scripts/docs-refresh-context.sh +0 -40
- package/scripts/init/apply.sh +0 -5
- package/scripts/init/status.sh +0 -5
- package/scripts/init/toolkit-status.sh +0 -5
- package/scripts/pr-create.sh +0 -5
- package/scripts/pr-ready-context.sh +0 -88
- package/scripts/present/ascii-wireframe.sh +0 -5
- package/scripts/present/flow-diagram.sh +0 -5
- package/scripts/release-notes-context.sh +0 -40
- package/scripts/vcs/config.sh +0 -5
- package/scripts/vcs/merged-style.sh +0 -5
- package/scripts/vcs/token-create-urls.sh +0 -5
- package/scripts/vcs/verify-token.sh +0 -5
- package/scripts/verify-project.sh +0 -140
- package/scripts/youtrack/api.sh +0 -5
- package/scripts/youtrack/config.sh +0 -5
- package/scripts/youtrack/greeting.sh +0 -5
- package/scripts/youtrack/parse-duration.sh +0 -5
- package/scripts/youtrack/token-create-url.sh +0 -5
- package/scripts/youtrack/verify-token.sh +0 -5
- package/scripts/youtrack/work-date-ms.sh +0 -5
- package/src/tools/docs-repo.ts +0 -42
- package/src/tools/flow.ts +0 -88
- package/src/tools/index.ts +0 -22
- package/src/tools/present.ts +0 -45
- package/src/tools/repo.ts +0 -357
- package/src/tools/rules.ts +0 -30
- package/src/tools/sdd.ts +0 -189
- package/src/tools/templates.ts +0 -27
- package/src/tools/youtrack.ts +0 -360
|
@@ -0,0 +1,1216 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { isDeepStrictEqual } from "node:util";
|
|
17
|
+
import {
|
|
18
|
+
configDir,
|
|
19
|
+
mergePreset,
|
|
20
|
+
readConfigFromDir,
|
|
21
|
+
resolveConfigDir,
|
|
22
|
+
type BranchPreset,
|
|
23
|
+
} from "./config";
|
|
24
|
+
import { detectBranchPolicy } from "./branch-policy";
|
|
25
|
+
import { readSetupState, type SetupState } from "./setup-state";
|
|
26
|
+
import {
|
|
27
|
+
loadWorkspacesFrom,
|
|
28
|
+
matchWorkspace,
|
|
29
|
+
readWorkspacesResult,
|
|
30
|
+
validateWorkspaceGlob,
|
|
31
|
+
type WorkspaceConfig,
|
|
32
|
+
} from "./workspaces";
|
|
33
|
+
import { GITIGNORE_ENTRIES } from "./gitignore";
|
|
34
|
+
import { planHygieneFiles } from "./hygiene";
|
|
35
|
+
import { packageRoot } from "./package-root";
|
|
36
|
+
import {
|
|
37
|
+
cursorMcpServerEntry,
|
|
38
|
+
mergeCursorMcp,
|
|
39
|
+
mergeCursorSettings,
|
|
40
|
+
mergeOpenCodeConfig,
|
|
41
|
+
} from "./registration";
|
|
42
|
+
import { runDoctor, type DoctorReport } from "./doctor";
|
|
43
|
+
import { writeFileExclusive } from "./safe-write";
|
|
44
|
+
|
|
45
|
+
// Setup preview + apply (WZ-04-WZ-09, WZ-13-WZ-15; CA-12, CA-14, CA-22, CA-23).
|
|
46
|
+
// buildSetupPreview is a pure reader: it classifies the current setup state and
|
|
47
|
+
// returns the exact mutations Apply would perform WITHOUT applying them, so the
|
|
48
|
+
// preview is authoritative and nothing touches the filesystem before Apply.
|
|
49
|
+
// applySetupPreview applies ONLY the reviewed mutations using package-native
|
|
50
|
+
// registration/assets, reports every platform/file independently, preserves
|
|
51
|
+
// unrelated user config byte-for-byte, and verifies the result with the shared
|
|
52
|
+
// offline doctor. Integrations are neutral and optional: youtrack is skipped
|
|
53
|
+
// when baseUrl is empty, vcs when the provider is "skip".
|
|
54
|
+
|
|
55
|
+
export const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
|
|
56
|
+
|
|
57
|
+
export type VcsProvider = "gitlab" | "github";
|
|
58
|
+
|
|
59
|
+
export function parseList(raw: string): string[] {
|
|
60
|
+
return raw
|
|
61
|
+
.split(",")
|
|
62
|
+
.map((s) => s.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type SetupPreviewInput = {
|
|
67
|
+
platforms?: string[];
|
|
68
|
+
locale: string;
|
|
69
|
+
timezone: string;
|
|
70
|
+
branchPreset: BranchPreset;
|
|
71
|
+
branchAllowed: string;
|
|
72
|
+
branchProtected: string;
|
|
73
|
+
baseUrl: string;
|
|
74
|
+
vcsProvider: VcsProvider | "skip";
|
|
75
|
+
workspaces: WorkspaceConfig[];
|
|
76
|
+
applyProject: boolean;
|
|
77
|
+
/** Deliberate token-path overrides. When they differ from an existing
|
|
78
|
+
* configured tokenFile, the replacement is planned as its own distinct
|
|
79
|
+
* reviewed mutation (AR-10). */
|
|
80
|
+
tokenPaths?: { youtrack?: string; gitlab?: string; github?: string };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type SetupMutation =
|
|
84
|
+
| { type: "create-file"; path: string; content: string; mode?: number }
|
|
85
|
+
| { type: "merge-json"; path: string; value: unknown }
|
|
86
|
+
| { type: "update-workspaces"; path: string; entries: WorkspaceConfig[] }
|
|
87
|
+
| { type: "append-gitignore"; path: string; entries: string[] }
|
|
88
|
+
// AR-09: host registration merges and the adapter package copy are planned
|
|
89
|
+
// during preview so the reviewed mutation set IS the Apply write set.
|
|
90
|
+
| { type: "register-platform"; platform: Platform; path: string }
|
|
91
|
+
| { type: "install-adapter"; platform: "cursor"; path: string }
|
|
92
|
+
// AR-10: changing a configured tokenFile path is never hidden inside a
|
|
93
|
+
// generic merge — it is its own reviewed mutation.
|
|
94
|
+
| { type: "set-token-path"; path: string; key: string; value: string };
|
|
95
|
+
|
|
96
|
+
export type SetupOverride = {
|
|
97
|
+
envKey: string;
|
|
98
|
+
affects: string;
|
|
99
|
+
value: string;
|
|
100
|
+
note: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type SetupPreview = {
|
|
104
|
+
ok: boolean;
|
|
105
|
+
/** Path-specific blocking diagnostics, set when any setup file is malformed. */
|
|
106
|
+
blocked: string[];
|
|
107
|
+
mutations: SetupMutation[];
|
|
108
|
+
/** Environment overrides active for the shell init/apply path (RL-06). */
|
|
109
|
+
overrides: SetupOverride[];
|
|
110
|
+
/** Credential files left byte-for-byte untouched. */
|
|
111
|
+
preserved: string[];
|
|
112
|
+
/** Selected platform adapters to register on Apply (WZ-09). */
|
|
113
|
+
platforms: string[];
|
|
114
|
+
state: SetupState;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const YT_OVERRIDES: { envKey: string; affects: string }[] = [
|
|
118
|
+
{ envKey: "WORKFLOW_YT_BASE_URL", affects: "youtrack.json baseUrl" },
|
|
119
|
+
{ envKey: "WORKFLOW_YT_TOKEN_FILE", affects: "youtrack.json tokenFile" },
|
|
120
|
+
{ envKey: "WORKFLOW_YT_TIMEZONE", affects: "youtrack.json timezone" },
|
|
121
|
+
{ envKey: "WORKFLOW_YT_MENTION", affects: "youtrack.json defaultMention" },
|
|
122
|
+
{ envKey: "WORKFLOW_YT_MEETING_ISSUE", affects: "youtrack.json meetingIssue" },
|
|
123
|
+
{ envKey: "WORKFLOW_YT_WEB_MEETING_ISSUE", affects: "youtrack.json web meeting issue" },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const VCS_OVERRIDES: { envKey: string; affects: string }[] = [
|
|
127
|
+
{ envKey: "WORKFLOW_VCS_PROVIDER", affects: "vcs.json provider" },
|
|
128
|
+
{ envKey: "WORKFLOW_VCS_TARGET_BRANCH", affects: "vcs.json defaultTargetBranch" },
|
|
129
|
+
{ envKey: "WORKFLOW_GITLAB_HOST", affects: "vcs.json gitlab.host" },
|
|
130
|
+
{ envKey: "WORKFLOW_GITLAB_API_URL", affects: "vcs.json gitlab.apiUrl" },
|
|
131
|
+
{ envKey: "WORKFLOW_GITHUB_HOST", affects: "vcs.json github.host" },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const SETUP_OVERRIDES = [...YT_OVERRIDES, ...VCS_OVERRIDES];
|
|
135
|
+
|
|
136
|
+
export const activeSetupOverrides = (env: NodeJS.ProcessEnv = process.env): SetupOverride[] => {
|
|
137
|
+
const overrides: SetupOverride[] = [];
|
|
138
|
+
for (const { envKey, affects } of SETUP_OVERRIDES) {
|
|
139
|
+
const value = env[envKey];
|
|
140
|
+
if (value === undefined || value === "") continue;
|
|
141
|
+
overrides.push({
|
|
142
|
+
envKey,
|
|
143
|
+
affects,
|
|
144
|
+
value,
|
|
145
|
+
note: "set in the environment; the interactive wizard does not apply it, but the shell init/apply path would use it",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return overrides;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Neutral youtrack.json draft: no organization names, issue IDs, greetings, or
|
|
152
|
+
// language defaults (WZ-04/CA-14). merge-json preserves unrelated keys on Apply.
|
|
153
|
+
// The tokenFile is the RESOLVED path (existing configured path reused, override
|
|
154
|
+
// honored) — a default path never silently replaces a custom one (AR-10).
|
|
155
|
+
function youtrackDraft(values: SetupPreviewInput, tokenPath: string): Record<string, unknown> {
|
|
156
|
+
return {
|
|
157
|
+
baseUrl: values.baseUrl.replace(/\/+$/, ""),
|
|
158
|
+
tokenFile: tokenPath,
|
|
159
|
+
timezone: values.timezone,
|
|
160
|
+
locale: values.locale,
|
|
161
|
+
tokenDefaults: {
|
|
162
|
+
name: "workit",
|
|
163
|
+
description: "OpenCode workit — /wk-issue-update and /wk-meetings",
|
|
164
|
+
scopes: ["YouTrack"],
|
|
165
|
+
profileTab: "account-security",
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function vcsDraft(
|
|
171
|
+
provider: VcsProvider,
|
|
172
|
+
gitlabToken: string,
|
|
173
|
+
githubToken: string,
|
|
174
|
+
): Record<string, unknown> {
|
|
175
|
+
return {
|
|
176
|
+
provider,
|
|
177
|
+
gitlab: {
|
|
178
|
+
host: "gitlab.com",
|
|
179
|
+
apiUrl: "https://gitlab.com/api/v4",
|
|
180
|
+
tokenFile: gitlabToken,
|
|
181
|
+
},
|
|
182
|
+
github: { host: "github.com", tokenFile: githubToken },
|
|
183
|
+
pr: { squashOnMerge: true, removeSourceBranch: true, pushBranch: true, confirmSkip: true },
|
|
184
|
+
tokenDefaults: {
|
|
185
|
+
name: "workit",
|
|
186
|
+
description: "OpenCode workit — /wk-pr and glab/gh",
|
|
187
|
+
gitlabScopes: ["api"],
|
|
188
|
+
githubPermissions: { pull_requests: "write", contents: "write", metadata: "read" },
|
|
189
|
+
githubClassicScopes: ["repo"],
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Resolve the token file for a service: an explicit override wins, then the
|
|
195
|
+
// configured tokenFile from an existing valid config, then the default path.
|
|
196
|
+
// Reuse means a custom credential location stays authoritative (AR-10).
|
|
197
|
+
const configuredTokenPath = (
|
|
198
|
+
config: Record<string, unknown> | null,
|
|
199
|
+
...keys: string[]
|
|
200
|
+
): string | null => {
|
|
201
|
+
let cursor: unknown = config;
|
|
202
|
+
for (const key of keys) {
|
|
203
|
+
if (!isRecord(cursor)) return null;
|
|
204
|
+
cursor = cursor[key];
|
|
205
|
+
}
|
|
206
|
+
if (typeof cursor === "string" && cursor.trim() !== "") return cursor;
|
|
207
|
+
return null;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const resolveTokenPath = (
|
|
211
|
+
configured: string | null,
|
|
212
|
+
fallback: string,
|
|
213
|
+
override?: string,
|
|
214
|
+
): string => {
|
|
215
|
+
if (override && override.trim() !== "") return override;
|
|
216
|
+
return configured ?? fallback;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Read a config file that readSetupState already classified as valid. Only
|
|
220
|
+
// called inside the blocked.length === 0 branch, so unreadable/malformed files
|
|
221
|
+
// never reach here; a missing file returns null.
|
|
222
|
+
const readConfigRecord = (file: string): Record<string, unknown> | null => {
|
|
223
|
+
try {
|
|
224
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
225
|
+
return isRecord(value) ? value : null;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const deleteAtPath = (obj: Record<string, unknown>, keyPath: string): void => {
|
|
232
|
+
const keys = keyPath.split(".");
|
|
233
|
+
let cursor = obj;
|
|
234
|
+
for (const key of keys.slice(0, -1)) {
|
|
235
|
+
const next = cursor[key];
|
|
236
|
+
if (!isRecord(next)) return;
|
|
237
|
+
cursor = next;
|
|
238
|
+
}
|
|
239
|
+
delete cursor[keys[keys.length - 1]];
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export function buildSetupPreview(
|
|
243
|
+
values: SetupPreviewInput,
|
|
244
|
+
opts: SetupPreviewOptions = {},
|
|
245
|
+
): SetupPreview {
|
|
246
|
+
const env = opts.env ?? process.env;
|
|
247
|
+
const state = readSetupState(opts.dir ?? resolveConfigDir());
|
|
248
|
+
const blocked: string[] = [];
|
|
249
|
+
const mutations: SetupMutation[] = [];
|
|
250
|
+
const preserved: string[] = [];
|
|
251
|
+
const overrides = activeSetupOverrides(env);
|
|
252
|
+
|
|
253
|
+
for (const entry of [state.config, state.youtrack, state.vcs, state.workspaces]) {
|
|
254
|
+
if (entry.status === "malformed") blocked.push(entry.error ?? entry.file);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (blocked.length === 0) {
|
|
258
|
+
const current = readConfigFromDir(state.configDir);
|
|
259
|
+
mutations.push({
|
|
260
|
+
type: "merge-json",
|
|
261
|
+
path: path.join(state.configDir, "config.json"),
|
|
262
|
+
value: {
|
|
263
|
+
locale: values.locale,
|
|
264
|
+
localeOptions: current.localeOptions,
|
|
265
|
+
timezone: values.timezone,
|
|
266
|
+
branchPolicy: mergePreset(
|
|
267
|
+
values.branchPreset,
|
|
268
|
+
{
|
|
269
|
+
allowed: values.branchPreset === "custom" ? parseList(values.branchAllowed) : undefined,
|
|
270
|
+
protectedNames:
|
|
271
|
+
values.branchPreset === "custom" ? parseList(values.branchProtected) : undefined,
|
|
272
|
+
},
|
|
273
|
+
current,
|
|
274
|
+
),
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// WZ-12 parity: the draft is authoritative but a no-op workspaces section
|
|
279
|
+
// (unchanged vs disk, including the initial seed) must not claim a rewrite.
|
|
280
|
+
// Removing every workspace differs from disk and therefore still writes [].
|
|
281
|
+
const diskWorkspaces = loadWorkspacesFrom(state.configDir);
|
|
282
|
+
if (!isDeepStrictEqual(values.workspaces, diskWorkspaces)) {
|
|
283
|
+
// RL-08: unsupported matcher grammar is rejected before any write —
|
|
284
|
+
// same gate the direct writeWorkspaces path and the wizard enforce.
|
|
285
|
+
for (const entry of values.workspaces) {
|
|
286
|
+
if (!entry || typeof entry.glob !== "string") continue;
|
|
287
|
+
const v = validateWorkspaceGlob(entry.glob);
|
|
288
|
+
if (!v.ok) {
|
|
289
|
+
blocked.push(v.error);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (blocked.length === 0) {
|
|
294
|
+
mutations.push({
|
|
295
|
+
type: "update-workspaces",
|
|
296
|
+
path: path.join(state.configDir, "workspaces.json"),
|
|
297
|
+
entries: values.workspaces,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (values.baseUrl.trim()) {
|
|
303
|
+
const ytPath = path.join(state.configDir, "youtrack.json");
|
|
304
|
+
const ytExisting = readConfigRecord(ytPath);
|
|
305
|
+
const ytConfigured = configuredTokenPath(ytExisting, "tokenFile");
|
|
306
|
+
const tokenPath = resolveTokenPath(
|
|
307
|
+
ytConfigured,
|
|
308
|
+
path.join(state.configDir, "youtrack.token"),
|
|
309
|
+
values.tokenPaths?.youtrack,
|
|
310
|
+
);
|
|
311
|
+
const draft = youtrackDraft(values, tokenPath);
|
|
312
|
+
if (ytConfigured !== null && tokenPath !== ytConfigured) {
|
|
313
|
+
// AR-10: a real tokenFile replacement is its own reviewed mutation —
|
|
314
|
+
// never hidden inside the generic config merge.
|
|
315
|
+
delete draft.tokenFile;
|
|
316
|
+
mutations.push({
|
|
317
|
+
type: "set-token-path",
|
|
318
|
+
path: ytPath,
|
|
319
|
+
key: "tokenFile",
|
|
320
|
+
value: tokenPath,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
mutations.push({
|
|
324
|
+
type: "merge-json",
|
|
325
|
+
path: ytPath,
|
|
326
|
+
value: draft,
|
|
327
|
+
});
|
|
328
|
+
if (existsSync(tokenPath)) preserved.push(tokenPath);
|
|
329
|
+
else
|
|
330
|
+
mutations.push({
|
|
331
|
+
type: "create-file",
|
|
332
|
+
path: tokenPath,
|
|
333
|
+
content: TOKEN_PLACEHOLDER + "\n",
|
|
334
|
+
mode: 0o600,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (values.vcsProvider !== "skip") {
|
|
339
|
+
const vcsPath = path.join(state.configDir, "vcs.json");
|
|
340
|
+
const vcsExisting = readConfigRecord(vcsPath);
|
|
341
|
+
const glConfigured = configuredTokenPath(vcsExisting, "gitlab", "tokenFile");
|
|
342
|
+
const ghConfigured = configuredTokenPath(vcsExisting, "github", "tokenFile");
|
|
343
|
+
const gitlabToken = resolveTokenPath(
|
|
344
|
+
glConfigured,
|
|
345
|
+
path.join(state.configDir, "gitlab.token"),
|
|
346
|
+
values.tokenPaths?.gitlab,
|
|
347
|
+
);
|
|
348
|
+
const githubToken = resolveTokenPath(
|
|
349
|
+
ghConfigured,
|
|
350
|
+
path.join(state.configDir, "github.token"),
|
|
351
|
+
values.tokenPaths?.github,
|
|
352
|
+
);
|
|
353
|
+
const draft = vcsDraft(values.vcsProvider, gitlabToken, githubToken);
|
|
354
|
+
for (const [configured, token, key] of [
|
|
355
|
+
[glConfigured, gitlabToken, "gitlab.tokenFile"],
|
|
356
|
+
[ghConfigured, githubToken, "github.tokenFile"],
|
|
357
|
+
] as const) {
|
|
358
|
+
if (configured !== null && token !== configured) {
|
|
359
|
+
deleteAtPath(draft, key);
|
|
360
|
+
mutations.push({
|
|
361
|
+
type: "set-token-path",
|
|
362
|
+
path: vcsPath,
|
|
363
|
+
key,
|
|
364
|
+
value: token,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
mutations.push({
|
|
369
|
+
type: "merge-json",
|
|
370
|
+
path: vcsPath,
|
|
371
|
+
value: draft,
|
|
372
|
+
});
|
|
373
|
+
const activeToken = values.vcsProvider === "gitlab" ? gitlabToken : githubToken;
|
|
374
|
+
if (existsSync(activeToken)) preserved.push(activeToken);
|
|
375
|
+
else
|
|
376
|
+
mutations.push({
|
|
377
|
+
type: "create-file",
|
|
378
|
+
path: activeToken,
|
|
379
|
+
content: TOKEN_PLACEHOLDER + "\n",
|
|
380
|
+
mode: 0o600,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// AR-09: host registrations and the adapter package copy are planned here
|
|
385
|
+
// with the exact target paths Apply will write, so the reviewed mutation
|
|
386
|
+
// set IS the Apply write set. Apply re-resolves the adapter SOURCE with
|
|
387
|
+
// its own options (dev/cwd) — a resolution failure reports Failed without
|
|
388
|
+
// writing, it never creates an unreviewed write.
|
|
389
|
+
const platforms = values.platforms ?? [];
|
|
390
|
+
if (platforms.length > 0) {
|
|
391
|
+
const paths = resolveSetupPaths(opts, state.configDir);
|
|
392
|
+
for (const platform of platforms) {
|
|
393
|
+
if (platform === "opencode") {
|
|
394
|
+
mutations.push({ type: "register-platform", platform, path: paths.opencodeConfig });
|
|
395
|
+
} else if (platform === "cursor") {
|
|
396
|
+
mutations.push({ type: "install-adapter", platform, path: paths.cursorPluginDir });
|
|
397
|
+
mutations.push({ type: "register-platform", platform, path: paths.cursorSettings });
|
|
398
|
+
mutations.push({ type: "register-platform", platform, path: paths.cursorMcp });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (values.applyProject) {
|
|
404
|
+
const root = path.resolve(opts.cwd ?? process.cwd());
|
|
405
|
+
const gitignorePath = path.join(root, ".gitignore");
|
|
406
|
+
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
|
|
407
|
+
const existingLines = new Set(
|
|
408
|
+
existing
|
|
409
|
+
.split("\n")
|
|
410
|
+
.map((l) => l.trim())
|
|
411
|
+
.filter(Boolean),
|
|
412
|
+
);
|
|
413
|
+
const entries = GITIGNORE_ENTRIES.filter(
|
|
414
|
+
(e) => e.trim() !== "" && !existingLines.has(e.trim()),
|
|
415
|
+
);
|
|
416
|
+
mutations.push({ type: "append-gitignore", path: gitignorePath, entries });
|
|
417
|
+
for (const planned of planHygieneFiles(root)) {
|
|
418
|
+
mutations.push({ type: "create-file", path: planned.path, content: planned.content });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
ok: blocked.length === 0,
|
|
425
|
+
blocked,
|
|
426
|
+
mutations,
|
|
427
|
+
overrides,
|
|
428
|
+
preserved,
|
|
429
|
+
platforms: values.platforms ?? [],
|
|
430
|
+
state,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// Apply (Task 14: WZ-09, WZ-10, WZ-13-WZ-15; CA-08, CA-13, CA-14, CA-31).
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
export type Platform = "opencode" | "cursor";
|
|
439
|
+
|
|
440
|
+
export type SetupResultStatus = "Installed" | "Configured" | "Skipped" | "Failed";
|
|
441
|
+
|
|
442
|
+
export type SetupResultEntry = {
|
|
443
|
+
platform: Platform | "core";
|
|
444
|
+
file: string;
|
|
445
|
+
status: SetupResultStatus;
|
|
446
|
+
detail?: string;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
export type SetupResult = {
|
|
450
|
+
ok: boolean;
|
|
451
|
+
exitCode: number;
|
|
452
|
+
entries: SetupResultEntry[];
|
|
453
|
+
/** Credential files left byte-for-byte untouched. */
|
|
454
|
+
preserved: string[];
|
|
455
|
+
/** Path-specific blocking diagnostics when the preview was malformed. */
|
|
456
|
+
blocked: string[];
|
|
457
|
+
/** Post-apply doctor reports, one per verified platform host. */
|
|
458
|
+
doctor: DoctorReport[];
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
export type SetupPreviewOptions = ApplySetupOptions & { dir?: string };
|
|
462
|
+
|
|
463
|
+
export type ApplySetupOptions = {
|
|
464
|
+
home?: string;
|
|
465
|
+
configDir?: string;
|
|
466
|
+
dev?: string;
|
|
467
|
+
cwd?: string;
|
|
468
|
+
env?: NodeJS.ProcessEnv;
|
|
469
|
+
opencodeConfig?: string;
|
|
470
|
+
cursorSettings?: string;
|
|
471
|
+
cursorMcp?: string;
|
|
472
|
+
cursorPluginDir?: string;
|
|
473
|
+
stateDir?: string;
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
type ResolvedApply = {
|
|
477
|
+
home: string;
|
|
478
|
+
configDir: string;
|
|
479
|
+
dev: string | null;
|
|
480
|
+
cwd: string;
|
|
481
|
+
env: NodeJS.ProcessEnv;
|
|
482
|
+
opencodeConfig: string;
|
|
483
|
+
cursorSettings: string;
|
|
484
|
+
cursorMcp: string;
|
|
485
|
+
cursorPluginDir: string;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// Shared path resolution for preview AND apply: the preview plans the platform
|
|
489
|
+
// write targets with the same resolution apply uses, so the mutation paths ARE
|
|
490
|
+
// the apply write paths (AR-09). Callers must build the preview and apply with
|
|
491
|
+
// the same options (defaulting both from env) — tests pass `home` explicitly.
|
|
492
|
+
const resolveSetupPaths = (
|
|
493
|
+
options: ApplySetupOptions,
|
|
494
|
+
configDir: string,
|
|
495
|
+
): Omit<ResolvedApply, "dev"> => {
|
|
496
|
+
const env = options.env ?? process.env;
|
|
497
|
+
const home = options.home ?? env.HOME ?? os.homedir();
|
|
498
|
+
return {
|
|
499
|
+
home,
|
|
500
|
+
configDir,
|
|
501
|
+
cwd: options.cwd ?? process.cwd(),
|
|
502
|
+
env,
|
|
503
|
+
opencodeConfig:
|
|
504
|
+
options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
|
|
505
|
+
cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
|
|
506
|
+
cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
|
|
507
|
+
cursorPluginDir:
|
|
508
|
+
options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workflow-toolkit"),
|
|
509
|
+
};
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
function resolveApply(preview: SetupPreview, options: ApplySetupOptions): ResolvedApply {
|
|
513
|
+
const paths = resolveSetupPaths(options, options.configDir ?? preview.state.configDir);
|
|
514
|
+
return {
|
|
515
|
+
...paths,
|
|
516
|
+
dev: options.dev ?? paths.env.WORKFLOW_TOOLKIT_DEV ?? null,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
521
|
+
v !== null && typeof v === "object" && !Array.isArray(v);
|
|
522
|
+
|
|
523
|
+
type Existing =
|
|
524
|
+
| { kind: "missing" }
|
|
525
|
+
| { kind: "malformed"; error: string }
|
|
526
|
+
| { kind: "record"; value: Record<string, unknown>; raw: string };
|
|
527
|
+
|
|
528
|
+
// Platform config (opencode.json/settings.json/mcp.json) is NOT covered by the
|
|
529
|
+
// WZ-06 preview scan (which gates only the workit config dir), so a malformed
|
|
530
|
+
// file here must not be silently replaced with a fresh config (CA-14). Callers
|
|
531
|
+
// turn { kind: "malformed" } into a Failed entry and leave the file untouched.
|
|
532
|
+
const readExisting = (p: string): Existing => {
|
|
533
|
+
let raw: string;
|
|
534
|
+
try {
|
|
535
|
+
raw = readFileSync(p, "utf8");
|
|
536
|
+
} catch {
|
|
537
|
+
// A read-permission error (EACCES) must not look like a missing file: a
|
|
538
|
+
// write attempt would throw after partially touching the filesystem.
|
|
539
|
+
// Classify it malformed so callers report Failed with the path untouched.
|
|
540
|
+
if (existsSync(p)) return { kind: "malformed", error: `${p} is not readable` };
|
|
541
|
+
return { kind: "missing" };
|
|
542
|
+
}
|
|
543
|
+
let value: unknown;
|
|
544
|
+
try {
|
|
545
|
+
value = JSON.parse(raw);
|
|
546
|
+
} catch {
|
|
547
|
+
return { kind: "malformed", error: `${p} is not valid JSON` };
|
|
548
|
+
}
|
|
549
|
+
if (isRecord(value)) return { kind: "record", value, raw };
|
|
550
|
+
return { kind: "malformed", error: `${p} is not a JSON object` };
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
// Deep-merge preserves every unrelated key/value; arrays and scalars are
|
|
554
|
+
// replaced wholesale by the patch (the review is authoritative on its fields).
|
|
555
|
+
function deepMerge(
|
|
556
|
+
base: Record<string, unknown>,
|
|
557
|
+
patch: Record<string, unknown>,
|
|
558
|
+
): Record<string, unknown> {
|
|
559
|
+
const out: Record<string, unknown> = { ...base };
|
|
560
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
561
|
+
const prev = out[key];
|
|
562
|
+
out[key] = isRecord(prev) && isRecord(value) ? deepMerge(prev, value) : value;
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const readFileSafe = (p: string): string | null => {
|
|
568
|
+
try {
|
|
569
|
+
return readFileSync(p, "utf8");
|
|
570
|
+
} catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// Core mutations are dispatched by applyMutation; platform mutations
|
|
576
|
+
// (register-platform / install-adapter) are handled by the dispatcher with
|
|
577
|
+
// adapter resolution (AR-09).
|
|
578
|
+
type CoreMutation = Exclude<
|
|
579
|
+
SetupMutation,
|
|
580
|
+
{ type: "register-platform" } | { type: "install-adapter" }
|
|
581
|
+
>;
|
|
582
|
+
|
|
583
|
+
function applyMutation(m: CoreMutation): SetupResultEntry {
|
|
584
|
+
const dir = path.dirname(m.path);
|
|
585
|
+
switch (m.type) {
|
|
586
|
+
case "create-file": {
|
|
587
|
+
mkdirSync(dir, { recursive: true });
|
|
588
|
+
const res = writeFileExclusive(m.path, m.content, m.mode);
|
|
589
|
+
return res === "created"
|
|
590
|
+
? { platform: "core", file: m.path, status: "Installed" }
|
|
591
|
+
: {
|
|
592
|
+
platform: "core",
|
|
593
|
+
file: m.path,
|
|
594
|
+
status: "Skipped",
|
|
595
|
+
detail: "file already exists — preserved",
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
case "merge-json": {
|
|
599
|
+
const existing = readExisting(m.path);
|
|
600
|
+
if (existing.kind === "malformed") {
|
|
601
|
+
return {
|
|
602
|
+
platform: "core",
|
|
603
|
+
file: m.path,
|
|
604
|
+
status: "Failed",
|
|
605
|
+
detail: `cannot merge into malformed config: ${existing.error}`,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const merged =
|
|
609
|
+
existing.kind === "record"
|
|
610
|
+
? deepMerge(existing.value, m.value as Record<string, unknown>)
|
|
611
|
+
: (m.value as Record<string, unknown>);
|
|
612
|
+
if (existing.kind === "record" && JSON.stringify(existing.value) === JSON.stringify(merged)) {
|
|
613
|
+
return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
|
|
614
|
+
}
|
|
615
|
+
mkdirSync(dir, { recursive: true });
|
|
616
|
+
writeFileSync(m.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
617
|
+
return existing.kind === "record"
|
|
618
|
+
? { platform: "core", file: m.path, status: "Configured" }
|
|
619
|
+
: { platform: "core", file: m.path, status: "Installed" };
|
|
620
|
+
}
|
|
621
|
+
case "update-workspaces": {
|
|
622
|
+
// RL-08: a mutation built by a non-wizard caller still cannot write an
|
|
623
|
+
// unsupported matcher pattern — reported Failed, never silently stored.
|
|
624
|
+
for (const entry of m.entries) {
|
|
625
|
+
if (!entry || typeof entry.glob !== "string") continue;
|
|
626
|
+
const v = validateWorkspaceGlob(entry.glob);
|
|
627
|
+
if (!v.ok) return { platform: "core", file: m.path, status: "Failed", detail: v.error };
|
|
628
|
+
}
|
|
629
|
+
const next = JSON.stringify({ workspaces: m.entries }, null, 2) + "\n";
|
|
630
|
+
const prev = readFileSafe(m.path);
|
|
631
|
+
if (prev === next)
|
|
632
|
+
return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
|
|
633
|
+
mkdirSync(dir, { recursive: true });
|
|
634
|
+
writeFileSync(m.path, next, "utf8");
|
|
635
|
+
return prev === null
|
|
636
|
+
? { platform: "core", file: m.path, status: "Installed" }
|
|
637
|
+
: { platform: "core", file: m.path, status: "Configured" };
|
|
638
|
+
}
|
|
639
|
+
case "append-gitignore": {
|
|
640
|
+
const existing = readFileSafe(m.path) ?? "";
|
|
641
|
+
const existingLines = new Set(
|
|
642
|
+
existing
|
|
643
|
+
.split("\n")
|
|
644
|
+
.map((l) => l.trim())
|
|
645
|
+
.filter(Boolean),
|
|
646
|
+
);
|
|
647
|
+
const add = m.entries.filter((e) => e.trim() !== "" && !existingLines.has(e.trim()));
|
|
648
|
+
if (add.length === 0) {
|
|
649
|
+
return {
|
|
650
|
+
platform: "core",
|
|
651
|
+
file: m.path,
|
|
652
|
+
status: "Skipped",
|
|
653
|
+
detail: "all entries already present",
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
mkdirSync(dir, { recursive: true });
|
|
657
|
+
const separator = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
658
|
+
writeFileSync(
|
|
659
|
+
m.path,
|
|
660
|
+
existing + separator + (existing ? "\n" : "") + add.join("\n") + "\n",
|
|
661
|
+
"utf8",
|
|
662
|
+
);
|
|
663
|
+
return {
|
|
664
|
+
platform: "core",
|
|
665
|
+
file: m.path,
|
|
666
|
+
status: "Configured",
|
|
667
|
+
detail: `appended ${add.length} entr${add.length === 1 ? "y" : "ies"}`,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
case "set-token-path": {
|
|
671
|
+
// AR-10: a reviewed tokenFile replacement. Writes ONLY the config key at
|
|
672
|
+
// the key path — the token file itself is a separate create-file or
|
|
673
|
+
// preserved entry, and any file at the old path is never touched.
|
|
674
|
+
const existing = readExisting(m.path);
|
|
675
|
+
if (existing.kind === "malformed") {
|
|
676
|
+
return {
|
|
677
|
+
platform: "core",
|
|
678
|
+
file: m.path,
|
|
679
|
+
status: "Failed",
|
|
680
|
+
detail: `cannot merge into malformed config: ${existing.error}`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const keys = m.key.split(".");
|
|
684
|
+
const patch: Record<string, unknown> = {};
|
|
685
|
+
let cursor = patch;
|
|
686
|
+
for (const key of keys.slice(0, -1)) {
|
|
687
|
+
const next: Record<string, unknown> = {};
|
|
688
|
+
cursor[key] = next;
|
|
689
|
+
cursor = next;
|
|
690
|
+
}
|
|
691
|
+
cursor[keys[keys.length - 1]] = m.value;
|
|
692
|
+
const merged = deepMerge(existing.kind === "record" ? existing.value : {}, patch);
|
|
693
|
+
if (existing.kind === "record" && JSON.stringify(existing.value) === JSON.stringify(merged)) {
|
|
694
|
+
return {
|
|
695
|
+
platform: "core",
|
|
696
|
+
file: m.path,
|
|
697
|
+
status: "Skipped",
|
|
698
|
+
detail: "already configured",
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
mkdirSync(path.dirname(m.path), { recursive: true });
|
|
702
|
+
writeFileSync(m.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
703
|
+
return existing.kind === "record"
|
|
704
|
+
? { platform: "core", file: m.path, status: "Configured" }
|
|
705
|
+
: { platform: "core", file: m.path, status: "Installed" };
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Package-native adapter resolution: the packaged CLI/core resolve the adapter
|
|
711
|
+
// packages from the same install (node_modules sibling), a dev checkout
|
|
712
|
+
// (WORKFLOW_TOOLKIT_DEV / packages/workit-<host> walking up from cwd), or the
|
|
713
|
+
// share clone (~/.local/share/workflow-toolkit) — mirroring the install scripts.
|
|
714
|
+
const isAdapter = (root: string, platform: Platform): boolean => {
|
|
715
|
+
try {
|
|
716
|
+
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as {
|
|
717
|
+
name?: string;
|
|
718
|
+
};
|
|
719
|
+
return pkg.name === `@brainervirus/workit-${platform}`;
|
|
720
|
+
} catch {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
function adapterRoot(platform: Platform, res: ResolvedApply): string | null {
|
|
726
|
+
const candidates: string[] = [];
|
|
727
|
+
if (res.dev) {
|
|
728
|
+
// A dev checkout is the complete source of truth for adapter packages: when
|
|
729
|
+
// WORKFLOW_TOOLKIT_DEV is set, a package missing from it is a real failure,
|
|
730
|
+
// not a reason to fall through to the running process's own install.
|
|
731
|
+
candidates.push(path.join(res.dev, "packages", `workit-${platform}`));
|
|
732
|
+
} else {
|
|
733
|
+
// node_modules sibling (packed install) or monorepo sibling (source)
|
|
734
|
+
candidates.push(path.join(packageRoot(), "..", `workit-${platform}`));
|
|
735
|
+
// dev checkout walking up from cwd
|
|
736
|
+
let dir = path.resolve(res.cwd);
|
|
737
|
+
while (true) {
|
|
738
|
+
candidates.push(path.join(dir, "packages", `workit-${platform}`));
|
|
739
|
+
const parent = path.dirname(dir);
|
|
740
|
+
if (parent === dir) break;
|
|
741
|
+
dir = parent;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
candidates.push(
|
|
745
|
+
path.join(res.home, ".local", "share", "workflow-toolkit", "packages", `workit-${platform}`),
|
|
746
|
+
);
|
|
747
|
+
for (const candidate of candidates) {
|
|
748
|
+
if (isAdapter(candidate, platform)) return candidate;
|
|
749
|
+
}
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const opencodePin = (root: string): string | null => {
|
|
754
|
+
for (const rel of ["src/plugin.ts", "dist/plugin.js"]) {
|
|
755
|
+
const entry = path.join(root, rel);
|
|
756
|
+
if (existsSync(entry)) return `file://${entry}`;
|
|
757
|
+
}
|
|
758
|
+
return null;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
|
|
762
|
+
const pin = opencodePin(root);
|
|
763
|
+
if (!pin) {
|
|
764
|
+
return {
|
|
765
|
+
platform: "opencode",
|
|
766
|
+
file: res.opencodeConfig,
|
|
767
|
+
status: "Failed",
|
|
768
|
+
detail:
|
|
769
|
+
"workit-opencode package has no loadable plugin entry (src/plugin.ts or dist/plugin.js)",
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
mkdirSync(path.dirname(res.opencodeConfig), { recursive: true });
|
|
773
|
+
const existing = readExisting(res.opencodeConfig);
|
|
774
|
+
if (existing.kind === "malformed") {
|
|
775
|
+
return {
|
|
776
|
+
platform: "opencode",
|
|
777
|
+
file: res.opencodeConfig,
|
|
778
|
+
status: "Failed",
|
|
779
|
+
detail: `cannot merge into malformed config: ${existing.error} — repair or remove the file`,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
const merged = mergeOpenCodeConfig(existing.kind === "record" ? existing.value : {}, pin);
|
|
783
|
+
if (merged.changed.length === 0) {
|
|
784
|
+
return {
|
|
785
|
+
platform: "opencode",
|
|
786
|
+
file: res.opencodeConfig,
|
|
787
|
+
status: "Skipped",
|
|
788
|
+
detail: "already registered",
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
writeFileSync(res.opencodeConfig, JSON.stringify(merged.config, null, 2) + "\n", "utf8");
|
|
792
|
+
return existing.kind === "record"
|
|
793
|
+
? {
|
|
794
|
+
platform: "opencode",
|
|
795
|
+
file: res.opencodeConfig,
|
|
796
|
+
status: "Configured",
|
|
797
|
+
detail: `pinned ${pin}`,
|
|
798
|
+
}
|
|
799
|
+
: {
|
|
800
|
+
platform: "opencode",
|
|
801
|
+
file: res.opencodeConfig,
|
|
802
|
+
status: "Installed",
|
|
803
|
+
detail: `pinned ${pin}`,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Mirror the sync-runtime plugin mirror: copy the package (minus node_modules),
|
|
808
|
+
// keep launchers executable, and write the same `.workflow-toolkit-root` marker
|
|
809
|
+
// so a re-sync from the same source is a truthful Skipped.
|
|
810
|
+
//
|
|
811
|
+
// The installed plugin's mcp.json is a derived artifact: the shipped manifest
|
|
812
|
+
// stays package-relative (PT-10), but Cursor spawns plugin MCP servers with
|
|
813
|
+
// the workspace as cwd, so the installed copy must carry an absolute entry.
|
|
814
|
+
const cursorMcpManifest = (dir: string): string =>
|
|
815
|
+
JSON.stringify({ mcpServers: { workit: cursorMcpServerEntry(dir) } }, null, 2) + "\n";
|
|
816
|
+
|
|
817
|
+
const samePluginContent = (src: string, dest: string, relative = ""): boolean => {
|
|
818
|
+
try {
|
|
819
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
820
|
+
if (entry.name === "node_modules") continue;
|
|
821
|
+
// mcp.json is derived at install time (cursorMcpManifest), not copied.
|
|
822
|
+
if (relative === "" && entry.name === "mcp.json") continue;
|
|
823
|
+
const source = path.join(src, entry.name);
|
|
824
|
+
const installed = path.join(dest, entry.name);
|
|
825
|
+
if (entry.isDirectory()) {
|
|
826
|
+
if (
|
|
827
|
+
!statSync(installed).isDirectory() ||
|
|
828
|
+
!samePluginContent(source, installed, path.join(relative, entry.name))
|
|
829
|
+
)
|
|
830
|
+
return false;
|
|
831
|
+
} else if (!readFileSync(source).equals(readFileSync(installed))) {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
for (const entry of readdirSync(dest, { withFileTypes: true })) {
|
|
836
|
+
if (existsSync(path.join(src, entry.name))) continue;
|
|
837
|
+
const rel = path.join(relative, entry.name);
|
|
838
|
+
if (rel === ".workflow-toolkit-root") continue;
|
|
839
|
+
if (relative === "rules" && entry.isFile() && entry.name.endsWith(".mdc")) continue;
|
|
840
|
+
if (relative === "" && entry.name === "mcp.json") continue;
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
return true;
|
|
844
|
+
} catch {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
const preservedCursorRules = (src: string, dest: string): Map<string, Buffer> => {
|
|
850
|
+
const preserved = new Map<string, Buffer>();
|
|
851
|
+
const sourceRules = path.join(src, "rules");
|
|
852
|
+
const installedRules = path.join(dest, "rules");
|
|
853
|
+
try {
|
|
854
|
+
for (const entry of readdirSync(installedRules, { withFileTypes: true })) {
|
|
855
|
+
if (!entry.isFile() || !entry.name.endsWith(".mdc")) continue;
|
|
856
|
+
if (existsSync(path.join(sourceRules, entry.name))) continue;
|
|
857
|
+
preserved.set(entry.name, readFileSync(path.join(installedRules, entry.name)));
|
|
858
|
+
}
|
|
859
|
+
} catch {
|
|
860
|
+
/* no compiled user rules */
|
|
861
|
+
}
|
|
862
|
+
return preserved;
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
function copyPluginDir(src: string, dest: string): SetupResultStatus {
|
|
866
|
+
const marker = path.join(dest, ".workflow-toolkit-root");
|
|
867
|
+
const synced =
|
|
868
|
+
readFileSafe(marker)?.trim() === src &&
|
|
869
|
+
samePluginContent(src, dest) &&
|
|
870
|
+
// The mcp.json equality makes the Skipped verdict truthful: the derived
|
|
871
|
+
// manifest on disk must be the one this install would write.
|
|
872
|
+
readFileSafe(path.join(dest, "mcp.json")) === cursorMcpManifest(dest);
|
|
873
|
+
if (synced) return "Skipped";
|
|
874
|
+
const hadDir = existsSync(dest);
|
|
875
|
+
const rules = preservedCursorRules(src, dest);
|
|
876
|
+
const parent = path.dirname(dest);
|
|
877
|
+
mkdirSync(parent, { recursive: true });
|
|
878
|
+
const swap = mkdtempSync(path.join(parent, `.${path.basename(dest)}.swap-`));
|
|
879
|
+
const stage = path.join(swap, "stage");
|
|
880
|
+
const backup = path.join(swap, "backup");
|
|
881
|
+
try {
|
|
882
|
+
cpSync(src, stage, {
|
|
883
|
+
recursive: true,
|
|
884
|
+
filter: (entry) => !path.relative(src, entry).split(path.sep).includes("node_modules"),
|
|
885
|
+
});
|
|
886
|
+
for (const [name, content] of rules) {
|
|
887
|
+
mkdirSync(path.join(stage, "rules"), { recursive: true });
|
|
888
|
+
writeFileSync(path.join(stage, "rules", name), content);
|
|
889
|
+
}
|
|
890
|
+
writeFileSync(path.join(stage, ".workflow-toolkit-root"), src + "\n", "utf8");
|
|
891
|
+
for (const rel of ["hooks/session-start", "mcp/run-server.sh"]) {
|
|
892
|
+
try {
|
|
893
|
+
chmodSync(path.join(stage, rel), 0o755);
|
|
894
|
+
} catch {
|
|
895
|
+
/* optional launcher */
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (!samePluginContent(src, stage)) throw new Error("staged adapter content is incomplete");
|
|
899
|
+
if (hadDir) renameSync(dest, backup);
|
|
900
|
+
try {
|
|
901
|
+
renameSync(stage, dest);
|
|
902
|
+
} catch (error) {
|
|
903
|
+
if (hadDir && !existsSync(dest)) renameSync(backup, dest);
|
|
904
|
+
throw error;
|
|
905
|
+
}
|
|
906
|
+
// Derive the installed manifest against the FINAL installed path: the
|
|
907
|
+
// stage path dies with the swap dir, and the entry's dist check needs the
|
|
908
|
+
// live plugin dir to exist.
|
|
909
|
+
writeFileSync(path.join(dest, "mcp.json"), cursorMcpManifest(dest), "utf8");
|
|
910
|
+
rmSync(backup, { recursive: true, force: true });
|
|
911
|
+
} finally {
|
|
912
|
+
rmSync(swap, { recursive: true, force: true });
|
|
913
|
+
}
|
|
914
|
+
return hadDir ? "Configured" : "Installed";
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// One reviewed mutation per Cursor write target (AR-09): the settings merge and
|
|
918
|
+
// the mcp merge are dispatched independently, exactly like the adapter copy.
|
|
919
|
+
function applyCursorSettings(root: string, res: ResolvedApply): SetupResultEntry {
|
|
920
|
+
mkdirSync(path.dirname(res.cursorSettings), { recursive: true });
|
|
921
|
+
const settingsExisting = readExisting(res.cursorSettings);
|
|
922
|
+
if (settingsExisting.kind === "malformed") {
|
|
923
|
+
return {
|
|
924
|
+
platform: "cursor",
|
|
925
|
+
file: res.cursorSettings,
|
|
926
|
+
status: "Failed",
|
|
927
|
+
detail: `cannot merge into malformed config: ${settingsExisting.error} — repair or remove the file`,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
const settings = mergeCursorSettings(
|
|
931
|
+
settingsExisting.kind === "record" ? settingsExisting.value : {},
|
|
932
|
+
res.cursorPluginDir,
|
|
933
|
+
);
|
|
934
|
+
if (settings.changed.length === 0) {
|
|
935
|
+
return {
|
|
936
|
+
platform: "cursor",
|
|
937
|
+
file: res.cursorSettings,
|
|
938
|
+
status: "Skipped",
|
|
939
|
+
detail: "already registered",
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
writeFileSync(res.cursorSettings, JSON.stringify(settings.config, null, 2) + "\n", "utf8");
|
|
943
|
+
return {
|
|
944
|
+
platform: "cursor",
|
|
945
|
+
file: res.cursorSettings,
|
|
946
|
+
status: settingsExisting.kind === "record" ? "Configured" : "Installed",
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function applyCursorMcp(root: string, res: ResolvedApply): SetupResultEntry {
|
|
951
|
+
mkdirSync(path.dirname(res.cursorMcp), { recursive: true });
|
|
952
|
+
const mcpExisting = readExisting(res.cursorMcp);
|
|
953
|
+
if (mcpExisting.kind === "malformed") {
|
|
954
|
+
return {
|
|
955
|
+
platform: "cursor",
|
|
956
|
+
file: res.cursorMcp,
|
|
957
|
+
status: "Failed",
|
|
958
|
+
detail: `cannot merge into malformed config: ${mcpExisting.error} — repair or remove the file`,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
const mcp = mergeCursorMcp(
|
|
962
|
+
mcpExisting.kind === "record" ? mcpExisting.value : {},
|
|
963
|
+
"workit",
|
|
964
|
+
cursorMcpServerEntry(res.cursorPluginDir),
|
|
965
|
+
);
|
|
966
|
+
if (mcp.changed.length === 0) {
|
|
967
|
+
return {
|
|
968
|
+
platform: "cursor",
|
|
969
|
+
file: res.cursorMcp,
|
|
970
|
+
status: "Skipped",
|
|
971
|
+
detail: "already registered",
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
writeFileSync(res.cursorMcp, JSON.stringify(mcp.config, null, 2) + "\n", "utf8");
|
|
975
|
+
return {
|
|
976
|
+
platform: "cursor",
|
|
977
|
+
file: res.cursorMcp,
|
|
978
|
+
status: mcpExisting.kind === "record" ? "Configured" : "Installed",
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function verifyPlatform(platform: Platform, res: ResolvedApply): DoctorReport | null {
|
|
983
|
+
if (platform === "opencode" && !existsSync(res.opencodeConfig)) return null;
|
|
984
|
+
if (platform === "cursor" && !existsSync(res.cursorSettings) && !existsSync(res.cursorMcp)) {
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
return runDoctor({
|
|
988
|
+
host: platform,
|
|
989
|
+
installer: true,
|
|
990
|
+
home: res.home,
|
|
991
|
+
configDir: res.configDir,
|
|
992
|
+
stateDir: res.env.WORKFLOW_TOOLKIT_STATE,
|
|
993
|
+
dev: res.dev ?? undefined,
|
|
994
|
+
cwd: res.cwd,
|
|
995
|
+
env: res.env,
|
|
996
|
+
opencodeConfig: res.opencodeConfig,
|
|
997
|
+
cursorSettings: res.cursorSettings,
|
|
998
|
+
cursorMcp: res.cursorMcp,
|
|
999
|
+
cursorPluginDir: res.cursorPluginDir,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const failedDetail = (report: DoctorReport): string =>
|
|
1004
|
+
report.checks
|
|
1005
|
+
.filter((c) => c.status === "fail")
|
|
1006
|
+
.map((c) => `${c.id}: ${c.detail}`)
|
|
1007
|
+
.join("; ");
|
|
1008
|
+
|
|
1009
|
+
export function applySetupPreview(
|
|
1010
|
+
preview: SetupPreview,
|
|
1011
|
+
options: ApplySetupOptions = {},
|
|
1012
|
+
): SetupResult {
|
|
1013
|
+
if (!preview.ok) {
|
|
1014
|
+
const malformed = [
|
|
1015
|
+
preview.state.config,
|
|
1016
|
+
preview.state.youtrack,
|
|
1017
|
+
preview.state.vcs,
|
|
1018
|
+
preview.state.workspaces,
|
|
1019
|
+
].filter((s) => s.status === "malformed");
|
|
1020
|
+
return {
|
|
1021
|
+
ok: false,
|
|
1022
|
+
exitCode: 1,
|
|
1023
|
+
entries: malformed.map((s) => ({
|
|
1024
|
+
platform: "core" as const,
|
|
1025
|
+
file: s.file,
|
|
1026
|
+
status: "Failed" as const,
|
|
1027
|
+
detail: s.error ?? "apply blocked by malformed configuration",
|
|
1028
|
+
})),
|
|
1029
|
+
preserved: [],
|
|
1030
|
+
blocked: preview.blocked,
|
|
1031
|
+
doctor: [],
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const res = resolveApply(preview, options);
|
|
1036
|
+
const entries: SetupResultEntry[] = [];
|
|
1037
|
+
// AR-09/AR-13: Apply dispatches ONLY the reviewed mutation union. The adapter
|
|
1038
|
+
// source is re-resolved with the apply options (dev/cwd); a resolution
|
|
1039
|
+
// failure reports Failed without writing — it never produces a write that
|
|
1040
|
+
// was not previewed. The reviewed path itself is code-enforced: a platform
|
|
1041
|
+
// mutation whose planned path does not exactly equal the apply-time resolved
|
|
1042
|
+
// path (a caller that resolved the preview with different options) fails
|
|
1043
|
+
// fast — Failed, no write — instead of silently writing an unreviewed path.
|
|
1044
|
+
const rootFor = new Map<Platform, string | null>();
|
|
1045
|
+
for (const mutation of preview.mutations) {
|
|
1046
|
+
if (mutation.type !== "register-platform" && mutation.type !== "install-adapter") {
|
|
1047
|
+
entries.push(applyMutation(mutation));
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
const platform = mutation.platform;
|
|
1051
|
+
const resolvedPath =
|
|
1052
|
+
mutation.type === "install-adapter"
|
|
1053
|
+
? res.cursorPluginDir
|
|
1054
|
+
: platform === "opencode"
|
|
1055
|
+
? res.opencodeConfig
|
|
1056
|
+
: mutation.path === res.cursorSettings
|
|
1057
|
+
? res.cursorSettings
|
|
1058
|
+
: res.cursorMcp;
|
|
1059
|
+
if (mutation.path !== resolvedPath) {
|
|
1060
|
+
entries.push({
|
|
1061
|
+
platform,
|
|
1062
|
+
file: mutation.path,
|
|
1063
|
+
status: "Failed",
|
|
1064
|
+
detail: `preview/apply path mismatch: apply resolved ${resolvedPath}, the previewed write was ${mutation.path} — rebuild the preview with the same options`,
|
|
1065
|
+
});
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
let root = rootFor.get(platform);
|
|
1069
|
+
if (root === undefined) {
|
|
1070
|
+
root = adapterRoot(platform, res);
|
|
1071
|
+
rootFor.set(platform, root);
|
|
1072
|
+
}
|
|
1073
|
+
if (root === null) {
|
|
1074
|
+
entries.push({
|
|
1075
|
+
platform,
|
|
1076
|
+
file: mutation.path,
|
|
1077
|
+
status: "Failed",
|
|
1078
|
+
detail: `@brainervirus/workit-${platform} package not found — set WORKFLOW_TOOLKIT_DEV to your checkout or install the package`,
|
|
1079
|
+
});
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
if (mutation.type === "install-adapter") {
|
|
1083
|
+
try {
|
|
1084
|
+
entries.push({ platform, file: mutation.path, status: copyPluginDir(root, mutation.path) });
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
entries.push({
|
|
1087
|
+
platform,
|
|
1088
|
+
file: mutation.path,
|
|
1089
|
+
status: "Failed",
|
|
1090
|
+
detail: `adapter install failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
} else if (platform === "opencode") {
|
|
1094
|
+
entries.push(applyOpenCode(root, res));
|
|
1095
|
+
} else {
|
|
1096
|
+
entries.push(
|
|
1097
|
+
mutation.path === res.cursorSettings
|
|
1098
|
+
? applyCursorSettings(root, res)
|
|
1099
|
+
: applyCursorMcp(root, res),
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
for (const preserved of preview.preserved) {
|
|
1104
|
+
entries.push({
|
|
1105
|
+
platform: "core",
|
|
1106
|
+
file: preserved,
|
|
1107
|
+
status: "Skipped",
|
|
1108
|
+
detail: "existing credential preserved",
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Post-apply verification per platform (read-only): a registered host whose
|
|
1113
|
+
// writes succeeded is verified by the shared offline doctor.
|
|
1114
|
+
const doctor: DoctorReport[] = [];
|
|
1115
|
+
for (const platform of preview.platforms) {
|
|
1116
|
+
if (platform !== "opencode" && platform !== "cursor") continue;
|
|
1117
|
+
const root = rootFor.get(platform);
|
|
1118
|
+
if (root === null || root === undefined) continue;
|
|
1119
|
+
if (!entries.some((e) => e.platform === platform && e.status !== "Failed")) continue;
|
|
1120
|
+
const report = verifyPlatform(platform, res);
|
|
1121
|
+
if (!report) continue;
|
|
1122
|
+
doctor.push(report);
|
|
1123
|
+
if (report.exitCode === 0) continue;
|
|
1124
|
+
if (platform === "opencode") {
|
|
1125
|
+
const entry = entries.find((e) => e.platform === "opencode" && e.file === res.opencodeConfig);
|
|
1126
|
+
if (entry) {
|
|
1127
|
+
entry.status = "Failed";
|
|
1128
|
+
entry.detail = `${entry.detail ? entry.detail + " — " : ""}doctor: ${failedDetail(report)}`;
|
|
1129
|
+
}
|
|
1130
|
+
} else {
|
|
1131
|
+
const primary = entries.find((e) => e.platform === "cursor" && e.file === res.cursorSettings);
|
|
1132
|
+
if (primary) {
|
|
1133
|
+
primary.status = "Failed";
|
|
1134
|
+
primary.detail = `${primary.detail ? primary.detail + " — " : ""}doctor: ${failedDetail(report)}`;
|
|
1135
|
+
} else {
|
|
1136
|
+
entries.push({
|
|
1137
|
+
platform: "cursor",
|
|
1138
|
+
file: res.cursorSettings,
|
|
1139
|
+
status: "Failed",
|
|
1140
|
+
detail: `doctor: ${failedDetail(report)}`,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const failed = entries.some((e) => e.status === "Failed");
|
|
1147
|
+
return {
|
|
1148
|
+
ok: !failed,
|
|
1149
|
+
exitCode: failed ? 1 : 0,
|
|
1150
|
+
entries,
|
|
1151
|
+
preserved: preview.preserved,
|
|
1152
|
+
blocked: [],
|
|
1153
|
+
doctor,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Completion guidance shown by the CLI after Apply (and on the non-TTY path):
|
|
1158
|
+
// the doctor command + /wk-status are the next steps for verifying the result.
|
|
1159
|
+
export const setupCompletionGuidance = (): string[] => [
|
|
1160
|
+
"Run `workit doctor` to verify your installation.",
|
|
1161
|
+
"Run /wk-status in OpenCode to review token setup steps.",
|
|
1162
|
+
];
|
|
1163
|
+
|
|
1164
|
+
// Shared per-workspace branch policy writer (CA-06): the initApplyData
|
|
1165
|
+
// branch_policy action (OpenCode/Cursor) and the Task 5 wizard both route
|
|
1166
|
+
// through this proposal→write path, so the written bytes are identical. The
|
|
1167
|
+
// write is idempotent: a matching entry is reported already-configured without
|
|
1168
|
+
// touching the file; an existing entry is updated; otherwise appended.
|
|
1169
|
+
export function applyWorkspaceBranchPolicy(opts: {
|
|
1170
|
+
workspace_root: string;
|
|
1171
|
+
env?: NodeJS.ProcessEnv;
|
|
1172
|
+
}): Record<string, any> {
|
|
1173
|
+
const { workspace_root, env = process.env } = opts;
|
|
1174
|
+
const dir = path.join(env.WORKFLOW_TOOLKIT_CONFIG ?? configDir());
|
|
1175
|
+
const { status, path: wsPath, entries } = readWorkspacesResult(dir);
|
|
1176
|
+
if (status === "malformed") return { ok: false, error: `malformed workspaces.json: ${wsPath}` };
|
|
1177
|
+
const detection = detectBranchPolicy(workspace_root);
|
|
1178
|
+
const name = String(env.WORKFLOW_BP_NAME ?? path.basename(workspace_root));
|
|
1179
|
+
const integration = (env.WORKFLOW_BP_INTEGRATION ?? detection.integration) as "pr" | "merge";
|
|
1180
|
+
const policy = {
|
|
1181
|
+
preset: detection.preset,
|
|
1182
|
+
developBranch: env.WORKFLOW_BP_DEVELOP ?? detection.developBranch ?? undefined,
|
|
1183
|
+
prefixes: detection.prefixes,
|
|
1184
|
+
allowed: detection.allowed,
|
|
1185
|
+
protected: detection.protected,
|
|
1186
|
+
integration,
|
|
1187
|
+
};
|
|
1188
|
+
const glob = `${workspace_root.replace(/[\\/]+$/, "")}/**`;
|
|
1189
|
+
if (!validateWorkspaceGlob(glob).ok)
|
|
1190
|
+
return { ok: false, error: `invalid workspace glob: ${glob}` };
|
|
1191
|
+
const idx = entries.findIndex((w) => matchWorkspace(w.glob, workspace_root));
|
|
1192
|
+
const existing = idx >= 0 ? entries[idx] : null;
|
|
1193
|
+
if (existing?.branchPolicy && isDeepStrictEqual(existing.branchPolicy, policy)) {
|
|
1194
|
+
return {
|
|
1195
|
+
ok: true,
|
|
1196
|
+
status: "already-configured",
|
|
1197
|
+
workspace: existing,
|
|
1198
|
+
policy,
|
|
1199
|
+
config_path: wsPath,
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
const next = existing
|
|
1203
|
+
? entries.map((w, i) => (i === idx ? { ...w, branchPolicy: policy } : w))
|
|
1204
|
+
: [...entries, { name, glob, branchPolicy: policy }];
|
|
1205
|
+
mkdirSync(path.dirname(wsPath), { recursive: true });
|
|
1206
|
+
writeFileSync(wsPath, JSON.stringify({ workspaces: next }, null, 2) + "\n", "utf8");
|
|
1207
|
+
return {
|
|
1208
|
+
ok: true,
|
|
1209
|
+
status: existing ? "updated" : "configured",
|
|
1210
|
+
workspace: existing
|
|
1211
|
+
? { ...existing, branchPolicy: policy }
|
|
1212
|
+
: { name, glob, branchPolicy: policy },
|
|
1213
|
+
policy,
|
|
1214
|
+
config_path: wsPath,
|
|
1215
|
+
};
|
|
1216
|
+
}
|