@brainervirus/workit-core 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/detect-hosts.ts +24 -22
- package/src/core/doctor.ts +11 -4
- package/src/core/host-install.ts +458 -0
- package/src/core/registration.ts +3 -4
- package/src/core/setup.ts +203 -11
- package/src/core/vcs-config.ts +2 -4
- package/src/core/workspaces.ts +17 -15
package/package.json
CHANGED
package/src/core/detect-hosts.ts
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
|
-
// Host presence detection
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// never disagree; codex/pi are presence-only (their setup runs via cutover).
|
|
6
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
1
|
+
// Host presence detection uses actual CLI executables or supported desktop app
|
|
2
|
+
// locations; configuration directories alone never count as installation.
|
|
3
|
+
// Existing registrations reuse uninstall planning so the two paths agree.
|
|
4
|
+
import { readdirSync } from "node:fs";
|
|
7
5
|
import os from "node:os";
|
|
8
6
|
import path from "node:path";
|
|
9
7
|
import { commandOnPath } from "./doctor";
|
|
10
8
|
import { planUninstall, type UninstallPaths } from "./uninstall";
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
import {
|
|
10
|
+
findHostExecutable,
|
|
11
|
+
installedHostApp,
|
|
12
|
+
isCodexWorkitInstalled,
|
|
13
|
+
isPiWorkitInstalled,
|
|
14
|
+
type HostId,
|
|
15
|
+
} from "./host-install";
|
|
16
|
+
export type { HostId } from "./host-install";
|
|
13
17
|
|
|
14
18
|
export type HostDetection = {
|
|
15
|
-
/**
|
|
19
|
+
/** Host executable or supported desktop application is present. */
|
|
16
20
|
detected: boolean;
|
|
17
|
-
/** A
|
|
21
|
+
/** A Workit registration is present for this host. */
|
|
18
22
|
configured: boolean;
|
|
19
23
|
};
|
|
20
24
|
|
|
@@ -22,15 +26,8 @@ export type DetectHostsOptions = UninstallPaths;
|
|
|
22
26
|
|
|
23
27
|
const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi"];
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
cursor: [".cursor"],
|
|
28
|
-
codex: [".codex"],
|
|
29
|
-
pi: [".pi"],
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/** Hosts the wizard Apply path registers (codex/pi set up via cutover). */
|
|
33
|
-
export const WIZARD_HOSTS: HostId[] = ["opencode", "cursor"];
|
|
29
|
+
/** Hosts the setup wizard configures through their native install paths. */
|
|
30
|
+
export const WIZARD_HOSTS: HostId[] = [...HOSTS];
|
|
34
31
|
|
|
35
32
|
/** Detected hosts the wizard can preselect (presence ∩ wizard-managed). */
|
|
36
33
|
export function preselectedPlatforms(detection: Record<HostId, HostDetection>): string[] {
|
|
@@ -51,15 +48,20 @@ export function detectHosts(options: DetectHostsOptions = {}): Record<HostId, Ho
|
|
|
51
48
|
// Same chain as uninstall path resolution: explicit home > env.HOME > homedir.
|
|
52
49
|
const home = options.home ?? env.HOME ?? os.homedir();
|
|
53
50
|
// Pure reader: classifies the installed state, never writes.
|
|
54
|
-
const plan = planUninstall(options);
|
|
51
|
+
const plan = planUninstall({ ...options, home, env });
|
|
55
52
|
const configuredByHost = new Map(plan.hosts.map((h) => [h.host, h.installed]));
|
|
56
53
|
const found = emptyDetection();
|
|
57
54
|
for (const host of HOSTS) {
|
|
58
55
|
const detected =
|
|
59
|
-
|
|
56
|
+
findHostExecutable(host, { home, env }) !== null || installedHostApp(host, home, env);
|
|
60
57
|
found[host] = {
|
|
61
58
|
detected,
|
|
62
|
-
configured:
|
|
59
|
+
configured:
|
|
60
|
+
host === "codex"
|
|
61
|
+
? isCodexWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
|
|
62
|
+
: host === "pi"
|
|
63
|
+
? isPiWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
|
|
64
|
+
: (configuredByHost.get(host) ?? false),
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
return found;
|
package/src/core/doctor.ts
CHANGED
|
@@ -217,11 +217,18 @@ const parsesAsConfigObject = (p: string): boolean => {
|
|
|
217
217
|
}
|
|
218
218
|
};
|
|
219
219
|
|
|
220
|
-
//
|
|
220
|
+
// V2 uses plugins; V1 accepts plugin as a string or array. Inspect both.
|
|
221
221
|
const pluginEntries = (cfg: Record<string, any> | null): string[] => {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
const entries = [cfg?.plugins, cfg?.plugin].flatMap((value) =>
|
|
223
|
+
Array.isArray(value) ? value : typeof value === "string" ? [value] : [],
|
|
224
|
+
);
|
|
225
|
+
return [
|
|
226
|
+
...new Set(
|
|
227
|
+
entries.filter(
|
|
228
|
+
(entry): entry is string => typeof entry === "string" && isWorkitPlugin(entry),
|
|
229
|
+
),
|
|
230
|
+
),
|
|
231
|
+
];
|
|
225
232
|
};
|
|
226
233
|
|
|
227
234
|
export const commandOnPath = (name: string, env: NodeJS.ProcessEnv): boolean => {
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export type HostId = "opencode" | "cursor" | "codex" | "pi";
|
|
7
|
+
|
|
8
|
+
/** A reviewed argv vector. Arguments are never composed into a shell string. */
|
|
9
|
+
export type HostInstallCommand = {
|
|
10
|
+
command: string;
|
|
11
|
+
args: string[];
|
|
12
|
+
cwd?: string;
|
|
13
|
+
purpose: string;
|
|
14
|
+
/** Optional managed package tree to stage, for Cursor's local plugin copy. */
|
|
15
|
+
packageRoot?: string;
|
|
16
|
+
packageTarget?: string;
|
|
17
|
+
skipWhenOutputIncludes?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type HostCommandResult = {
|
|
21
|
+
exitCode: number;
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type HostCommandRunner = (step: HostInstallCommand) => HostCommandResult;
|
|
27
|
+
export type HostCommandContext = { home?: string; env?: NodeJS.ProcessEnv };
|
|
28
|
+
|
|
29
|
+
export function hostCommand(
|
|
30
|
+
name: string,
|
|
31
|
+
args: string[],
|
|
32
|
+
options: HostInstallOptions = {},
|
|
33
|
+
): HostInstallCommand {
|
|
34
|
+
const executable = findHostExecutable(name, options);
|
|
35
|
+
if (!executable)
|
|
36
|
+
throw new Error(`${name} executable was not found on PATH or supported user bin directories`);
|
|
37
|
+
if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(executable)) {
|
|
38
|
+
const node = findHostExecutable("node", options);
|
|
39
|
+
if (!node) throw new Error(`Node.js is required to run the ${name} Windows shim`);
|
|
40
|
+
const script = resolveWindowsShimScript(executable);
|
|
41
|
+
if (!script)
|
|
42
|
+
throw new Error(`could not resolve the ${name} Windows shim to a JavaScript entrypoint`);
|
|
43
|
+
return { command: node, args: [script, ...args], cwd: options.cwd, purpose: `${name} CLI` };
|
|
44
|
+
}
|
|
45
|
+
return { command: executable, args, cwd: options.cwd, purpose: `${name} CLI` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveWindowsShimEntry(
|
|
49
|
+
contents: string,
|
|
50
|
+
shim: string,
|
|
51
|
+
exists: (candidate: string) => boolean = existsSync,
|
|
52
|
+
): string | null {
|
|
53
|
+
const root = path.win32.dirname(shim);
|
|
54
|
+
const matches = contents.matchAll(/(["']?)([^\r\n"']*node_modules[\\/][^\r\n"']+?\.js)\1/gi);
|
|
55
|
+
for (const match of matches) {
|
|
56
|
+
const raw = match[2].replace(/%~?dp0%?/gi, root);
|
|
57
|
+
const candidate = path.win32.isAbsolute(raw) ? raw : path.win32.resolve(root, raw);
|
|
58
|
+
if (exists(candidate)) return candidate;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const resolveWindowsShimScript = (shim: string): string | null => {
|
|
64
|
+
try {
|
|
65
|
+
const contents = readFileSync(shim, "utf8");
|
|
66
|
+
return resolveWindowsShimEntry(contents, shim);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type HostInstallOptions = {
|
|
73
|
+
home?: string;
|
|
74
|
+
cwd?: string;
|
|
75
|
+
env?: NodeJS.ProcessEnv;
|
|
76
|
+
packageVersion?: string;
|
|
77
|
+
alreadyInstalled?: boolean;
|
|
78
|
+
targetDir?: string;
|
|
79
|
+
mode?: "install" | "upgrade";
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const MARKETPLACE = "https://github.com/BrainerVirus/workit.git";
|
|
83
|
+
const CODEX_MARKETPLACE_NAME = "workflow-toolkit";
|
|
84
|
+
const validPackageVersion = (value: string): boolean =>
|
|
85
|
+
value === "latest" || /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
|
|
86
|
+
|
|
87
|
+
const nodePrerequisite = (options: HostInstallOptions): HostInstallCommand => {
|
|
88
|
+
const node = findHostExecutable("node", options);
|
|
89
|
+
if (!node) throw new Error("Workit host packages require Node.js 24+; install Node.js and retry");
|
|
90
|
+
return {
|
|
91
|
+
command: node,
|
|
92
|
+
args: [
|
|
93
|
+
"-e",
|
|
94
|
+
'const v=Number(process.versions.node.split(".")[0]);if(v<24){console.error(`Workit host packages require Node.js 24+ (found ${process.versions.node})`);process.exit(1)}',
|
|
95
|
+
],
|
|
96
|
+
cwd: options.cwd,
|
|
97
|
+
purpose: "Check the Node.js 24+ package runtime requirement",
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Resolve host executables through PATH plus common user-level Node managers. */
|
|
102
|
+
export function findHostExecutable(
|
|
103
|
+
name: string,
|
|
104
|
+
options: { home?: string; env?: NodeJS.ProcessEnv } = {},
|
|
105
|
+
): string | null {
|
|
106
|
+
const env = options.env ?? process.env;
|
|
107
|
+
const home = options.home ?? env.HOME ?? os.homedir();
|
|
108
|
+
const suffixes = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
|
|
109
|
+
const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
110
|
+
dirs.push(path.join(home, ".local", "bin"), path.join(home, ".asdf", "shims"));
|
|
111
|
+
for (const [base, tail] of [
|
|
112
|
+
[path.join(home, ".local", "share", "fnm", "node-versions"), path.join("installation", "bin")],
|
|
113
|
+
[path.join(home, ".nvm", "versions", "node"), "bin"],
|
|
114
|
+
] as const) {
|
|
115
|
+
try {
|
|
116
|
+
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
117
|
+
if (entry.isDirectory()) dirs.push(path.join(base, entry.name, tail));
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// A missing version-manager directory is normal.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const dir of dirs) {
|
|
124
|
+
for (const suffix of suffixes) {
|
|
125
|
+
const candidate = path.join(dir, `${name}${suffix}`);
|
|
126
|
+
try {
|
|
127
|
+
if (statSync(candidate).isFile()) {
|
|
128
|
+
if (process.platform === "win32") return candidate;
|
|
129
|
+
accessSync(candidate, constants.X_OK);
|
|
130
|
+
return candidate;
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Continue through PATH entries and platform shims.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const isPiWorkitInstalled = (
|
|
141
|
+
home: string,
|
|
142
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
143
|
+
): boolean => {
|
|
144
|
+
const settings = path.join(
|
|
145
|
+
env.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent"),
|
|
146
|
+
"settings.json",
|
|
147
|
+
);
|
|
148
|
+
try {
|
|
149
|
+
const document = JSON.parse(readFileSync(settings, "utf8")) as { packages?: unknown[] };
|
|
150
|
+
return (document.packages ?? []).some((entry) => {
|
|
151
|
+
const source =
|
|
152
|
+
entry && typeof entry === "object" && !Array.isArray(entry)
|
|
153
|
+
? (entry as { source?: unknown }).source
|
|
154
|
+
: entry;
|
|
155
|
+
const value = String(source).replaceAll("\\", "/").replace(/\/+$/, "");
|
|
156
|
+
return (
|
|
157
|
+
/^npm:@brainervirus\/workit-pi(?:@[^/]+)?$/.test(value) ||
|
|
158
|
+
value === "@brainervirus/workit-pi" ||
|
|
159
|
+
/(?:^|\/)packages\/workit-pi$/.test(value)
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const isCanonicalCodexPackage = (root: string): boolean => {
|
|
168
|
+
for (const file of [
|
|
169
|
+
path.join(root, ".codex-plugin", "plugin.json"),
|
|
170
|
+
path.join(root, "package.json"),
|
|
171
|
+
]) {
|
|
172
|
+
try {
|
|
173
|
+
const manifest = JSON.parse(readFileSync(file, "utf8")) as {
|
|
174
|
+
name?: string;
|
|
175
|
+
repository?: string | { url?: string };
|
|
176
|
+
};
|
|
177
|
+
const repository =
|
|
178
|
+
typeof manifest.repository === "string" ? manifest.repository : manifest.repository?.url;
|
|
179
|
+
if (
|
|
180
|
+
(manifest.name === "workit" || manifest.name === "@brainervirus/workit-codex") &&
|
|
181
|
+
repository?.replace(/\.git$/, "").toLowerCase() === "https://github.com/brainervirus/workit"
|
|
182
|
+
)
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
// Try the other manifest path.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export const isCodexWorkitInstalled = (
|
|
192
|
+
home: string,
|
|
193
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
194
|
+
): boolean => {
|
|
195
|
+
const codexHome = env.CODEX_HOME ?? path.join(home, ".codex");
|
|
196
|
+
const isPackageOrVersionDirectory = (root: string): boolean => {
|
|
197
|
+
if (isCanonicalCodexPackage(root)) return true;
|
|
198
|
+
try {
|
|
199
|
+
return readdirSync(root, { withFileTypes: true }).some(
|
|
200
|
+
(entry) => entry.isDirectory() && isCanonicalCodexPackage(path.join(root, entry.name)),
|
|
201
|
+
);
|
|
202
|
+
} catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
if (isPackageOrVersionDirectory(path.join(codexHome, "plugins", "workit"))) return true;
|
|
207
|
+
const root = path.join(codexHome, "plugins", "cache");
|
|
208
|
+
try {
|
|
209
|
+
for (const marketplace of readdirSync(root, { withFileTypes: true })) {
|
|
210
|
+
if (!marketplace.isDirectory()) continue;
|
|
211
|
+
if (isPackageOrVersionDirectory(path.join(root, marketplace.name, "workit"))) return true;
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export function installedHostApp(
|
|
220
|
+
host: HostId,
|
|
221
|
+
home: string,
|
|
222
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
223
|
+
platform: NodeJS.Platform = process.platform,
|
|
224
|
+
exists: (candidate: string) => boolean = existsSync,
|
|
225
|
+
): boolean {
|
|
226
|
+
const candidates: string[] = [];
|
|
227
|
+
const pathApi = platform === "win32" ? path.win32 : path;
|
|
228
|
+
if (platform === "darwin") {
|
|
229
|
+
const app =
|
|
230
|
+
host === "cursor"
|
|
231
|
+
? "Cursor.app"
|
|
232
|
+
: host === "codex"
|
|
233
|
+
? "Codex.app"
|
|
234
|
+
: host === "opencode"
|
|
235
|
+
? "OpenCode.app"
|
|
236
|
+
: "";
|
|
237
|
+
if (app) candidates.push(`/Applications/${app}`, pathApi.join(home, "Applications", app));
|
|
238
|
+
} else if (platform === "win32") {
|
|
239
|
+
const local = env.LOCALAPPDATA ?? pathApi.join(home, "AppData", "Local");
|
|
240
|
+
if (host === "cursor") candidates.push(pathApi.join(local, "Programs", "Cursor", "Cursor.exe"));
|
|
241
|
+
if (host === "codex") candidates.push(pathApi.join(local, "Programs", "Codex", "Codex.exe"));
|
|
242
|
+
if (host === "opencode")
|
|
243
|
+
candidates.push(pathApi.join(local, "Programs", "OpenCode", "OpenCode.exe"));
|
|
244
|
+
}
|
|
245
|
+
return candidates.some(exists);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Plan the host-native registration commands used by setup and upgrade. */
|
|
249
|
+
export function planHostInstall(
|
|
250
|
+
host: HostId,
|
|
251
|
+
options: HostInstallOptions = {},
|
|
252
|
+
): HostInstallCommand[] {
|
|
253
|
+
const env = options.env ?? process.env;
|
|
254
|
+
const home = options.home ?? env.HOME ?? os.homedir();
|
|
255
|
+
const cwd = options.cwd ?? process.cwd();
|
|
256
|
+
const version = options.packageVersion ?? "latest";
|
|
257
|
+
if (!validPackageVersion(version))
|
|
258
|
+
throw new Error(`unsupported package version selector: ${version}`);
|
|
259
|
+
if (options.alreadyInstalled && options.mode !== "upgrade") return [];
|
|
260
|
+
const upgrading = options.mode === "upgrade";
|
|
261
|
+
if (host === "codex") {
|
|
262
|
+
if (!upgrading && isCodexWorkitInstalled(home, env)) return [];
|
|
263
|
+
const base = hostCommand("codex", [], { home, cwd, env });
|
|
264
|
+
return [
|
|
265
|
+
nodePrerequisite({ home, cwd, env }),
|
|
266
|
+
{
|
|
267
|
+
...base,
|
|
268
|
+
args: [...base.args, "plugin", "marketplace", "list"],
|
|
269
|
+
purpose: "Check whether the Workit marketplace is already registered",
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
...base,
|
|
273
|
+
args: [
|
|
274
|
+
...base.args,
|
|
275
|
+
"plugin",
|
|
276
|
+
"marketplace",
|
|
277
|
+
upgrading ? "upgrade" : "add",
|
|
278
|
+
...(upgrading ? [CODEX_MARKETPLACE_NAME] : [MARKETPLACE]),
|
|
279
|
+
],
|
|
280
|
+
purpose: upgrading
|
|
281
|
+
? "Refresh the Workit Codex marketplace"
|
|
282
|
+
: "Register the Workit Codex marketplace",
|
|
283
|
+
...(upgrading ? {} : { skipWhenOutputIncludes: CODEX_MARKETPLACE_NAME }),
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
...base,
|
|
287
|
+
args: [...base.args, "plugin", "add", `workit@${CODEX_MARKETPLACE_NAME}`],
|
|
288
|
+
purpose: upgrading ? "Refresh the Workit Codex plugin" : "Install the Workit Codex plugin",
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
if (host === "pi") {
|
|
293
|
+
if (!upgrading && isPiWorkitInstalled(home, env)) return [];
|
|
294
|
+
const base = hostCommand("pi", [], { home, cwd, env });
|
|
295
|
+
return [
|
|
296
|
+
nodePrerequisite({ home, cwd, env }),
|
|
297
|
+
{
|
|
298
|
+
...base,
|
|
299
|
+
args: upgrading
|
|
300
|
+
? [...base.args, "update", "--extension", "npm:@brainervirus/workit-pi"]
|
|
301
|
+
: [...base.args, "install", `npm:@brainervirus/workit-pi@${version}`],
|
|
302
|
+
purpose: upgrading
|
|
303
|
+
? "Update the Workit Pi package"
|
|
304
|
+
: "Install the Workit Pi package in personal Pi settings",
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
if (host === "cursor") {
|
|
309
|
+
const node = findHostExecutable("node", { home, env });
|
|
310
|
+
if (!node)
|
|
311
|
+
throw new Error("Workit host packages require Node.js 24+; install Node.js and retry");
|
|
312
|
+
const npm = findHostExecutable("npm", { home, env });
|
|
313
|
+
const npmCli = npm ? findNpmCli(npm) : null;
|
|
314
|
+
if (!npmCli)
|
|
315
|
+
throw new Error("npm CLI could not be resolved; install Node.js 24+ and npm, then retry");
|
|
316
|
+
const stage = path.join(home, ".cursor", "plugins", "local", ".workit-package-bootstrap");
|
|
317
|
+
const target = options.targetDir ?? path.join(home, ".cursor", "plugins", "local", "workit");
|
|
318
|
+
return [
|
|
319
|
+
nodePrerequisite({ home, cwd, env }),
|
|
320
|
+
{
|
|
321
|
+
command: node,
|
|
322
|
+
args: [
|
|
323
|
+
npmCli,
|
|
324
|
+
"install",
|
|
325
|
+
"--prefix",
|
|
326
|
+
stage,
|
|
327
|
+
"--no-save",
|
|
328
|
+
"--package-lock=false",
|
|
329
|
+
`@brainervirus/workit-cursor@${version}`,
|
|
330
|
+
],
|
|
331
|
+
cwd,
|
|
332
|
+
purpose: upgrading
|
|
333
|
+
? "Download the current Workit Cursor adapter for the managed local plugin directory"
|
|
334
|
+
: "Download the Workit Cursor adapter package for the managed local plugin directory",
|
|
335
|
+
packageRoot: path.join(stage, "node_modules", "@brainervirus", "workit-cursor"),
|
|
336
|
+
packageTarget: target,
|
|
337
|
+
},
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
if (host === "opencode") {
|
|
341
|
+
if (upgrading)
|
|
342
|
+
throw new Error(
|
|
343
|
+
"OpenCode CLI cannot target the Workit server plugin for a scoped update; upgrade is unsupported and the current registration was preserved",
|
|
344
|
+
);
|
|
345
|
+
// The normal setup path merges this package pin into OpenCode's config;
|
|
346
|
+
// OpenCode's native resolver installs and refreshes npm plugin packages.
|
|
347
|
+
return [];
|
|
348
|
+
}
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Execute a reviewed plan serially and stop immediately on the first failure. */
|
|
353
|
+
export function runHostInstall(
|
|
354
|
+
commands: HostInstallCommand[],
|
|
355
|
+
runner?: HostCommandRunner,
|
|
356
|
+
context: HostCommandContext = {},
|
|
357
|
+
): {
|
|
358
|
+
ok: boolean;
|
|
359
|
+
results: Array<{ command: HostInstallCommand; result: HostCommandResult }>;
|
|
360
|
+
packageRoot?: string;
|
|
361
|
+
} {
|
|
362
|
+
const results: Array<{ command: HostInstallCommand; result: HostCommandResult }> = [];
|
|
363
|
+
let packageRoot: string | undefined;
|
|
364
|
+
let outputHistory = "";
|
|
365
|
+
for (const command of commands) {
|
|
366
|
+
if (command.skipWhenOutputIncludes && outputHistory.includes(command.skipWhenOutputIncludes)) {
|
|
367
|
+
results.push({
|
|
368
|
+
command,
|
|
369
|
+
result: { exitCode: 0, stdout: "", stderr: "already registered; skipped" },
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const result = runner ? runner(command) : runHostCommand(command, context);
|
|
374
|
+
results.push({ command, result });
|
|
375
|
+
if (result.exitCode !== 0) return { ok: false, results };
|
|
376
|
+
outputHistory += `\n${result.stdout}\n${result.stderr}`;
|
|
377
|
+
if (command.packageRoot && command.packageTarget) {
|
|
378
|
+
try {
|
|
379
|
+
if (!existsSync(command.packageRoot)) {
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
results: [
|
|
383
|
+
{
|
|
384
|
+
command,
|
|
385
|
+
result: {
|
|
386
|
+
...result,
|
|
387
|
+
exitCode: 1,
|
|
388
|
+
stderr: "npm completed without producing the expected Cursor package",
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
};
|
|
393
|
+
} else {
|
|
394
|
+
packageRoot = command.packageRoot;
|
|
395
|
+
}
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return {
|
|
398
|
+
ok: false,
|
|
399
|
+
results: [
|
|
400
|
+
{
|
|
401
|
+
command,
|
|
402
|
+
result: {
|
|
403
|
+
...result,
|
|
404
|
+
exitCode: 1,
|
|
405
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { ok: true, results, packageRoot };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Synchronous, bounded command runner for the synchronous setup Apply API. */
|
|
417
|
+
export function runHostCommand(
|
|
418
|
+
step: HostInstallCommand,
|
|
419
|
+
context: HostCommandContext = {},
|
|
420
|
+
): HostCommandResult {
|
|
421
|
+
if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(step.command)) {
|
|
422
|
+
return {
|
|
423
|
+
exitCode: 1,
|
|
424
|
+
stdout: "",
|
|
425
|
+
stderr: `Cannot safely execute batch shim ${step.command}; resolve its Node.js CLI entrypoint and retry`,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
const result = spawnSync(step.command, step.args, {
|
|
429
|
+
cwd: step.cwd,
|
|
430
|
+
env: (() => {
|
|
431
|
+
const home = context.home ?? context.env?.HOME ?? os.homedir();
|
|
432
|
+
const env: NodeJS.ProcessEnv = { ...process.env, ...context.env, HOME: home };
|
|
433
|
+
env.CODEX_HOME = context.env?.CODEX_HOME ?? path.join(home, ".codex");
|
|
434
|
+
env.PI_CODING_AGENT_DIR = context.env?.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent");
|
|
435
|
+
env.XDG_CONFIG_HOME = context.env?.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
436
|
+
return env;
|
|
437
|
+
})(),
|
|
438
|
+
encoding: "utf8",
|
|
439
|
+
timeout: 60_000,
|
|
440
|
+
windowsHide: true,
|
|
441
|
+
shell: false,
|
|
442
|
+
});
|
|
443
|
+
return {
|
|
444
|
+
exitCode: result.status ?? (result.error ? 1 : 1),
|
|
445
|
+
stdout: result.stdout ?? "",
|
|
446
|
+
stderr: result.stderr ?? result.error?.message ?? "",
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const findNpmCli = (npm: string): string | null => {
|
|
451
|
+
const dir = path.dirname(npm);
|
|
452
|
+
const candidates = [
|
|
453
|
+
path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"),
|
|
454
|
+
path.join(dir, "..", "node_modules", "npm", "bin", "npm-cli.js"),
|
|
455
|
+
path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
|
|
456
|
+
];
|
|
457
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
458
|
+
};
|
package/src/core/registration.ts
CHANGED
|
@@ -31,10 +31,9 @@ export function isWorkitPlugin(value: unknown): boolean {
|
|
|
31
31
|
const s = String(value).replaceAll("\\", "/");
|
|
32
32
|
const url = s.startsWith("file://") || s.startsWith("git+file://");
|
|
33
33
|
const pkgPath =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
s.includes("/node_modules/@brainervirus/workit-cursor/");
|
|
34
|
+
/\/(?:packages\/workit-(?:opencode|cursor)|node_modules\/@brainervirus\/workit-(?:opencode|cursor))(?:\/|$)/.test(
|
|
35
|
+
s,
|
|
36
|
+
);
|
|
38
37
|
return (
|
|
39
38
|
named(s, "workflow-toolkit") ||
|
|
40
39
|
named(s, "workflow-toolkit-opencode") ||
|
package/src/core/setup.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
readConfigFromDir,
|
|
23
23
|
resolveConfigDir,
|
|
24
24
|
type BranchPreset,
|
|
25
|
+
type ToolkitConfig,
|
|
25
26
|
} from "./config";
|
|
26
27
|
import { detectBranchPolicy } from "./branch-policy";
|
|
27
28
|
import { readSetupState, type SetupState } from "./setup-state";
|
|
@@ -38,13 +39,26 @@ import { planHygieneFiles } from "./hygiene";
|
|
|
38
39
|
import { packageRoot } from "./package-root";
|
|
39
40
|
import {
|
|
40
41
|
cursorMcpServerEntry,
|
|
42
|
+
CURSOR_RUNTIME_PACKAGE,
|
|
43
|
+
isWorkitPlugin,
|
|
41
44
|
mergeCursorMcp,
|
|
45
|
+
mergeOpenCodePlugins,
|
|
42
46
|
mergeCursorSettings,
|
|
43
47
|
mergeOpenCodeConfig,
|
|
44
48
|
OPENCODE_NPM_PIN,
|
|
45
49
|
} from "./registration";
|
|
46
50
|
import { runDoctor, type DoctorReport } from "./doctor";
|
|
47
51
|
import { writeFileExclusive } from "./safe-write";
|
|
52
|
+
import {
|
|
53
|
+
isCodexWorkitInstalled,
|
|
54
|
+
isPiWorkitInstalled,
|
|
55
|
+
planHostInstall,
|
|
56
|
+
runHostInstall,
|
|
57
|
+
type HostCommandRunner,
|
|
58
|
+
type HostId,
|
|
59
|
+
type HostInstallCommand,
|
|
60
|
+
runHostCommand,
|
|
61
|
+
} from "./host-install";
|
|
48
62
|
|
|
49
63
|
// Setup preview + apply (WZ-04-WZ-09, WZ-13-WZ-15; CA-12, CA-14, CA-22, CA-23).
|
|
50
64
|
// buildSetupPreview is a pure reader: it classifies the current setup state and
|
|
@@ -82,6 +96,8 @@ export type SetupPreviewInput = {
|
|
|
82
96
|
* configured tokenFile, the replacement is planned as its own distinct
|
|
83
97
|
* reviewed mutation (AR-10). */
|
|
84
98
|
tokenPaths?: { youtrack?: string; gitlab?: string; github?: string };
|
|
99
|
+
/** Optional global commit policy used as the base for repository overrides. */
|
|
100
|
+
commitPolicy?: ToolkitConfig["commitPolicy"];
|
|
85
101
|
};
|
|
86
102
|
|
|
87
103
|
export type SetupMutation =
|
|
@@ -98,6 +114,7 @@ export type SetupMutation =
|
|
|
98
114
|
// during preview so the reviewed mutation set IS the Apply write set.
|
|
99
115
|
| { type: "register-platform"; platform: Platform; path: string }
|
|
100
116
|
| { type: "install-adapter"; platform: "cursor"; path: string }
|
|
117
|
+
| { type: "install-host"; platform: HostId; path: string; commands: HostInstallCommand[] }
|
|
101
118
|
// AR-10: changing a configured tokenFile path is never hidden inside a
|
|
102
119
|
// generic merge — it is its own reviewed mutation.
|
|
103
120
|
| { type: "set-token-path"; path: string; key: string; value: string };
|
|
@@ -249,6 +266,7 @@ export function buildSetupPreview(
|
|
|
249
266
|
locale: values.locale,
|
|
250
267
|
localeOptions: current.localeOptions,
|
|
251
268
|
timezone: values.timezone,
|
|
269
|
+
...(values.commitPolicy === undefined ? {} : { commitPolicy: values.commitPolicy }),
|
|
252
270
|
branchPolicy: mergePreset(
|
|
253
271
|
values.branchPreset,
|
|
254
272
|
{
|
|
@@ -344,9 +362,56 @@ export function buildSetupPreview(
|
|
|
344
362
|
if (platform === "opencode") {
|
|
345
363
|
mutations.push({ type: "register-platform", platform, path: paths.opencodeConfig });
|
|
346
364
|
} else if (platform === "cursor") {
|
|
365
|
+
const cursorRoot = adapterRoot("cursor", {
|
|
366
|
+
...paths,
|
|
367
|
+
dev: opts.dev ?? env.WORKFLOW_TOOLKIT_DEV ?? null,
|
|
368
|
+
});
|
|
369
|
+
if (cursorRoot === null) {
|
|
370
|
+
const commands = planHostInstall("cursor", {
|
|
371
|
+
home: paths.home,
|
|
372
|
+
cwd: paths.cwd,
|
|
373
|
+
env,
|
|
374
|
+
packageVersion: "latest",
|
|
375
|
+
targetDir: paths.cursorPluginDir,
|
|
376
|
+
});
|
|
377
|
+
mutations.push({
|
|
378
|
+
type: "install-host",
|
|
379
|
+
platform: "cursor",
|
|
380
|
+
path: path.join(
|
|
381
|
+
paths.home,
|
|
382
|
+
".cursor",
|
|
383
|
+
"plugins",
|
|
384
|
+
"local",
|
|
385
|
+
".workit-package-bootstrap",
|
|
386
|
+
),
|
|
387
|
+
commands,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
347
390
|
mutations.push({ type: "install-adapter", platform, path: paths.cursorPluginDir });
|
|
348
391
|
mutations.push({ type: "register-platform", platform, path: paths.cursorSettings });
|
|
349
392
|
mutations.push({ type: "register-platform", platform, path: paths.cursorMcp });
|
|
393
|
+
} else if (platform === "codex" || platform === "pi") {
|
|
394
|
+
const commands = planHostInstall(platform, { home: paths.home, cwd: paths.cwd, env });
|
|
395
|
+
if (commands.length > 0) {
|
|
396
|
+
mutations.push({
|
|
397
|
+
type: "install-host",
|
|
398
|
+
platform,
|
|
399
|
+
path:
|
|
400
|
+
platform === "codex"
|
|
401
|
+
? path.join(
|
|
402
|
+
env.CODEX_HOME ?? path.join(paths.home, ".codex"),
|
|
403
|
+
"plugins",
|
|
404
|
+
"cache",
|
|
405
|
+
"workflow-toolkit",
|
|
406
|
+
"workit",
|
|
407
|
+
)
|
|
408
|
+
: path.join(
|
|
409
|
+
env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"),
|
|
410
|
+
"settings.json",
|
|
411
|
+
),
|
|
412
|
+
commands,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
350
415
|
}
|
|
351
416
|
}
|
|
352
417
|
}
|
|
@@ -391,7 +456,7 @@ export type Platform = "opencode" | "cursor";
|
|
|
391
456
|
export type SetupResultStatus = "Installed" | "Configured" | "Skipped" | "Failed";
|
|
392
457
|
|
|
393
458
|
export type SetupResultEntry = {
|
|
394
|
-
platform:
|
|
459
|
+
platform: HostId | "core";
|
|
395
460
|
file: string;
|
|
396
461
|
status: SetupResultStatus;
|
|
397
462
|
detail?: string;
|
|
@@ -422,6 +487,10 @@ export type ApplySetupOptions = {
|
|
|
422
487
|
cursorMcp?: string;
|
|
423
488
|
cursorPluginDir?: string;
|
|
424
489
|
stateDir?: string;
|
|
490
|
+
/** Injectable synchronous host runner; defaults to a bounded no-shell spawn. */
|
|
491
|
+
runHostCommand?: HostCommandRunner;
|
|
492
|
+
/** Prefer staged npm adapter assets, used by the explicit upgrade flow. */
|
|
493
|
+
preferInstalledAdapter?: boolean;
|
|
425
494
|
};
|
|
426
495
|
|
|
427
496
|
type ResolvedApply = {
|
|
@@ -528,7 +597,7 @@ const readFileSafe = (p: string): string | null => {
|
|
|
528
597
|
// adapter resolution (AR-09).
|
|
529
598
|
type CoreMutation = Exclude<
|
|
530
599
|
SetupMutation,
|
|
531
|
-
{ type: "register-platform" } | { type: "install-adapter" }
|
|
600
|
+
{ type: "register-platform" } | { type: "install-adapter" } | { type: "install-host" }
|
|
532
601
|
>;
|
|
533
602
|
|
|
534
603
|
const workspaceEntriesAlreadyMatch = (
|
|
@@ -735,8 +804,23 @@ const isAdapter = (root: string, platform: Platform): boolean => {
|
|
|
735
804
|
}
|
|
736
805
|
};
|
|
737
806
|
|
|
738
|
-
function adapterRoot(
|
|
807
|
+
function adapterRoot(
|
|
808
|
+
platform: Platform,
|
|
809
|
+
res: ResolvedApply,
|
|
810
|
+
preferInstalledAdapter = false,
|
|
811
|
+
): string | null {
|
|
739
812
|
const candidates: string[] = [];
|
|
813
|
+
const stagedCursor = path.join(
|
|
814
|
+
res.home,
|
|
815
|
+
".cursor",
|
|
816
|
+
"plugins",
|
|
817
|
+
"local",
|
|
818
|
+
".workit-package-bootstrap",
|
|
819
|
+
"node_modules",
|
|
820
|
+
"@brainervirus",
|
|
821
|
+
"workit-cursor",
|
|
822
|
+
);
|
|
823
|
+
if (platform === "cursor" && preferInstalledAdapter) candidates.push(stagedCursor);
|
|
740
824
|
if (res.dev) {
|
|
741
825
|
// A dev checkout is the complete source of truth for adapter packages: when
|
|
742
826
|
// WORKFLOW_TOOLKIT_DEV is set, a package missing from it is a real failure,
|
|
@@ -757,6 +841,7 @@ function adapterRoot(platform: Platform, res: ResolvedApply): string | null {
|
|
|
757
841
|
candidates.push(
|
|
758
842
|
path.join(res.home, ".local", "share", "workit", "packages", `workit-${platform}`),
|
|
759
843
|
);
|
|
844
|
+
if (platform === "cursor") candidates.push(stagedCursor);
|
|
760
845
|
for (const candidate of candidates) {
|
|
761
846
|
if (isAdapter(candidate, platform)) return candidate;
|
|
762
847
|
}
|
|
@@ -787,8 +872,8 @@ const opencodePin = (root: string, res: ResolvedApply): string | null =>
|
|
|
787
872
|
resolveOpenCodePin(root, { dev: res.dev });
|
|
788
873
|
|
|
789
874
|
function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
|
|
790
|
-
const
|
|
791
|
-
if (!
|
|
875
|
+
const resolvedPin = opencodePin(root, res);
|
|
876
|
+
if (!resolvedPin) {
|
|
792
877
|
return {
|
|
793
878
|
platform: "opencode",
|
|
794
879
|
file: res.opencodeConfig,
|
|
@@ -807,7 +892,33 @@ function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
|
|
|
807
892
|
detail: `cannot merge into malformed config: ${existing.error} — repair or remove the file`,
|
|
808
893
|
};
|
|
809
894
|
}
|
|
810
|
-
const
|
|
895
|
+
const configuredPlugins =
|
|
896
|
+
existing.kind === "record"
|
|
897
|
+
? Array.isArray(existing.value.plugins)
|
|
898
|
+
? existing.value.plugins
|
|
899
|
+
: typeof existing.value.plugins === "string"
|
|
900
|
+
? [existing.value.plugins]
|
|
901
|
+
: typeof existing.value.plugin === "string"
|
|
902
|
+
? [existing.value.plugin]
|
|
903
|
+
: Array.isArray(existing.value.plugin)
|
|
904
|
+
? existing.value.plugin
|
|
905
|
+
: []
|
|
906
|
+
: [];
|
|
907
|
+
const configuredPin = configuredPlugins.find(
|
|
908
|
+
(entry): entry is string => typeof entry === "string" && isWorkitPlugin(entry),
|
|
909
|
+
);
|
|
910
|
+
const pin = configuredPin ?? resolvedPin;
|
|
911
|
+
const base = existing.kind === "record" ? existing.value : {};
|
|
912
|
+
const merged =
|
|
913
|
+
existing.kind === "record" && Object.hasOwn(existing.value, "plugins")
|
|
914
|
+
? (() => {
|
|
915
|
+
const plugins = mergeOpenCodePlugins(existing.value.plugins, pin);
|
|
916
|
+
return {
|
|
917
|
+
config: { ...existing.value, plugins: plugins.config },
|
|
918
|
+
changed: plugins.changed.length > 0 ? ["plugins"] : [],
|
|
919
|
+
};
|
|
920
|
+
})()
|
|
921
|
+
: mergeOpenCodeConfig(base, pin);
|
|
811
922
|
if (merged.changed.length === 0) {
|
|
812
923
|
return {
|
|
813
924
|
platform: "opencode",
|
|
@@ -996,11 +1107,21 @@ function applyCursorMcp(root: string, res: ResolvedApply): SetupResultEntry {
|
|
|
996
1107
|
detail: `cannot merge into malformed config: ${mcpExisting.error} — repair or remove the file`,
|
|
997
1108
|
};
|
|
998
1109
|
}
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1110
|
+
const mcpBase = mcpExisting.kind === "record" ? mcpExisting.value : {};
|
|
1111
|
+
const server = cursorMcpServerEntry(res.cursorPluginDir);
|
|
1112
|
+
const servers = isRecord(mcpBase.mcpServers) ? mcpBase.mcpServers : {};
|
|
1113
|
+
const current = isRecord(servers.workit) ? servers.workit : {};
|
|
1114
|
+
const previousArgs = Array.isArray(current.args) ? current.args.map(String) : [];
|
|
1115
|
+
const previousPackage = previousArgs.find(
|
|
1116
|
+
(arg) =>
|
|
1117
|
+
arg.startsWith("--package=@brainervirus/workit-cursor@") &&
|
|
1118
|
+
arg !== `--package=${CURSOR_RUNTIME_PACKAGE}`,
|
|
1003
1119
|
);
|
|
1120
|
+
if (previousPackage) {
|
|
1121
|
+
const index = server.args.findIndex((arg) => arg.startsWith("--package="));
|
|
1122
|
+
if (index >= 0) server.args[index] = previousPackage;
|
|
1123
|
+
}
|
|
1124
|
+
const mcp = mergeCursorMcp(mcpBase, "workit", server);
|
|
1004
1125
|
if (mcp.changed.length === 0) {
|
|
1005
1126
|
return {
|
|
1006
1127
|
platform: "cursor",
|
|
@@ -1117,6 +1238,77 @@ export function applySetupPreview(
|
|
|
1117
1238
|
// legacy identity or its registration.
|
|
1118
1239
|
let cursorCopyOk = false;
|
|
1119
1240
|
for (const mutation of preview.mutations) {
|
|
1241
|
+
if (mutation.type === "install-host") {
|
|
1242
|
+
const expectedPath =
|
|
1243
|
+
mutation.platform === "cursor"
|
|
1244
|
+
? path.join(res.home, ".cursor", "plugins", "local", ".workit-package-bootstrap")
|
|
1245
|
+
: mutation.platform === "codex"
|
|
1246
|
+
? path.join(
|
|
1247
|
+
res.env.CODEX_HOME ?? path.join(res.home, ".codex"),
|
|
1248
|
+
"plugins",
|
|
1249
|
+
"cache",
|
|
1250
|
+
"workflow-toolkit",
|
|
1251
|
+
"workit",
|
|
1252
|
+
)
|
|
1253
|
+
: mutation.platform === "pi"
|
|
1254
|
+
? path.join(
|
|
1255
|
+
res.env.PI_CODING_AGENT_DIR ?? path.join(res.home, ".pi", "agent"),
|
|
1256
|
+
"settings.json",
|
|
1257
|
+
)
|
|
1258
|
+
: "";
|
|
1259
|
+
if (mutation.path !== expectedPath) {
|
|
1260
|
+
entries.push({
|
|
1261
|
+
platform: mutation.platform,
|
|
1262
|
+
file: mutation.path,
|
|
1263
|
+
status: "Failed",
|
|
1264
|
+
detail: `preview/apply path mismatch: apply resolved ${expectedPath}, the previewed host install was ${mutation.path} — rebuild the preview with the same options`,
|
|
1265
|
+
});
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
const run =
|
|
1269
|
+
options.runHostCommand ??
|
|
1270
|
+
((step: HostInstallCommand) => runHostCommand(step, { home: res.home, env: res.env }));
|
|
1271
|
+
const installed = runHostInstall(mutation.commands, run);
|
|
1272
|
+
for (const { command, result } of installed.results) {
|
|
1273
|
+
entries.push({
|
|
1274
|
+
platform: mutation.platform,
|
|
1275
|
+
file: command.command,
|
|
1276
|
+
status: result.exitCode === 0 ? "Installed" : "Failed",
|
|
1277
|
+
detail:
|
|
1278
|
+
result.exitCode === 0
|
|
1279
|
+
? `${command.purpose} (${command.args.join(" ")})`
|
|
1280
|
+
: `${command.purpose} failed (${command.args.join(" ")}): ${result.stderr || `exit ${result.exitCode}`}`,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
if (!installed.ok && installed.results.length === 0) {
|
|
1284
|
+
entries.push({
|
|
1285
|
+
platform: mutation.platform,
|
|
1286
|
+
file: mutation.platform,
|
|
1287
|
+
status: "Failed",
|
|
1288
|
+
detail: "host installation did not run",
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
if (!installed.ok) continue;
|
|
1292
|
+
if (mutation.platform === "cursor" && installed.packageRoot) {
|
|
1293
|
+
rootFor.set("cursor", installed.packageRoot);
|
|
1294
|
+
}
|
|
1295
|
+
const verified =
|
|
1296
|
+
mutation.platform === "codex"
|
|
1297
|
+
? isCodexWorkitInstalled(res.home, res.env)
|
|
1298
|
+
: mutation.platform === "pi"
|
|
1299
|
+
? isPiWorkitInstalled(res.home, res.env)
|
|
1300
|
+
: true;
|
|
1301
|
+
if (!verified) {
|
|
1302
|
+
entries.push({
|
|
1303
|
+
platform: mutation.platform,
|
|
1304
|
+
file: mutation.platform,
|
|
1305
|
+
status: "Failed",
|
|
1306
|
+
detail:
|
|
1307
|
+
"the host command succeeded, but Workit registration was not found; inspect the host's native package list and retry",
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1120
1312
|
if (mutation.type !== "register-platform" && mutation.type !== "install-adapter") {
|
|
1121
1313
|
entries.push(applyMutation(mutation));
|
|
1122
1314
|
continue;
|
|
@@ -1141,7 +1333,7 @@ export function applySetupPreview(
|
|
|
1141
1333
|
}
|
|
1142
1334
|
let root = rootFor.get(platform);
|
|
1143
1335
|
if (root === undefined) {
|
|
1144
|
-
root = adapterRoot(platform, res);
|
|
1336
|
+
root = adapterRoot(platform, res, options.preferInstalledAdapter);
|
|
1145
1337
|
rootFor.set(platform, root);
|
|
1146
1338
|
}
|
|
1147
1339
|
if (root === null) {
|
package/src/core/vcs-config.ts
CHANGED
|
@@ -191,10 +191,8 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
191
191
|
(hasWorkspacePolicy ? policyDefault : (cfg.defaultTargetBranch ?? policyDefault)) ??
|
|
192
192
|
"develop",
|
|
193
193
|
);
|
|
194
|
-
const linkIssues =
|
|
195
|
-
|
|
196
|
-
const youtrackBaseUrl =
|
|
197
|
-
provider === "gitlab" && typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
|
|
194
|
+
const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
|
|
195
|
+
const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
|
|
198
196
|
// github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
|
|
199
197
|
let issuesProvider: string | null = null;
|
|
200
198
|
let linkOnPr: boolean | null = null;
|
package/src/core/workspaces.ts
CHANGED
|
@@ -134,13 +134,6 @@ const workspaceConfigSchema = z
|
|
|
134
134
|
})
|
|
135
135
|
.passthrough()
|
|
136
136
|
.superRefine((workspace, context) => {
|
|
137
|
-
if (workspace.youtrack && workspace.vcs?.provider !== "gitlab") {
|
|
138
|
-
context.addIssue({
|
|
139
|
-
code: "custom",
|
|
140
|
-
path: ["youtrack"],
|
|
141
|
-
message: `YouTrack issue linking requires the gitlab provider, got ${workspace.vcs?.provider ?? "unset"}`,
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
137
|
if (workspace.issues && workspace.vcs?.provider !== "github") {
|
|
145
138
|
context.addIssue({
|
|
146
139
|
code: "custom",
|
|
@@ -446,23 +439,21 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
|
|
|
446
439
|
return mostSpecific[0];
|
|
447
440
|
};
|
|
448
441
|
|
|
449
|
-
/**
|
|
450
|
-
export const
|
|
442
|
+
/** Shared pure workspace matcher for draft previews and disk-backed resolution. */
|
|
443
|
+
export const resolveWorkspaceFromEntries = <T extends { name: string; glob: string }>(
|
|
451
444
|
cwd: string,
|
|
452
|
-
|
|
445
|
+
entries: readonly T[],
|
|
453
446
|
workspaceName?: string,
|
|
454
|
-
):
|
|
447
|
+
): T | null => {
|
|
455
448
|
// macOS/Windows tmpdir symlinks (/var -> /private/var): git's
|
|
456
449
|
// --show-toplevel returns the realpath while config globs are usually
|
|
457
450
|
// written with the logical path, so a workspace would silently stop
|
|
458
451
|
// matching on macOS. Match both forms on each side — same class as the
|
|
459
452
|
// docs-migration escape-guard realpath comparison; on Linux both forms
|
|
460
453
|
// are identical so behavior is unchanged.
|
|
461
|
-
const result = readWorkspacesResult(dir);
|
|
462
|
-
if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
|
|
463
454
|
const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
|
|
464
|
-
const matches:
|
|
465
|
-
for (const ws of
|
|
455
|
+
const matches: T[] = [];
|
|
456
|
+
for (const ws of entries) {
|
|
466
457
|
const glob = ws.glob.replaceAll("\\", "/");
|
|
467
458
|
const canonical = canonicalGlob(glob);
|
|
468
459
|
let matched = false;
|
|
@@ -480,6 +471,17 @@ export const resolveWorkspaceFrom = (
|
|
|
480
471
|
return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
|
|
481
472
|
};
|
|
482
473
|
|
|
474
|
+
/** Match a cwd against the workspaces.json under an explicit config dir. */
|
|
475
|
+
export const resolveWorkspaceFrom = (
|
|
476
|
+
cwd: string,
|
|
477
|
+
dir: string,
|
|
478
|
+
workspaceName?: string,
|
|
479
|
+
): WorkspaceConfig | null => {
|
|
480
|
+
const result = readWorkspacesResult(dir);
|
|
481
|
+
if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
|
|
482
|
+
return resolveWorkspaceFromEntries(cwd, result.entries, workspaceName);
|
|
483
|
+
};
|
|
484
|
+
|
|
483
485
|
export const resolveWorkspace = (
|
|
484
486
|
cwd: string,
|
|
485
487
|
workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined,
|