@brainervirus/workit-core 2.0.2 → 2.1.1
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/README.md +2 -2
- package/package.json +2 -3
- package/scripts/sync-runtime.sh +9 -5
- 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/init.ts +1 -256
- package/src/core/policy-resolver.ts +0 -45
- package/src/core/registration.ts +3 -4
- package/src/core/repo-context.ts +1 -1
- package/src/core/rules.ts +1 -61
- package/src/core/setup.ts +203 -11
- package/src/core/templates.ts +1 -26
- package/src/core/vcs-config.ts +2 -8
- package/src/core/workspaces.ts +18 -17
- package/src/core/config-guard.ts +0 -27
- package/src/core/doc-render.ts +0 -14
- package/src/core/docs-layout.ts +0 -279
- package/src/core/docs-migration.ts +0 -639
- package/src/core/docs-repo.ts +0 -233
- package/src/core/docs-validate.ts +0 -393
- package/src/core/parse-sections.ts +0 -24
- package/src/core/ports/init-apply.ts +0 -15
- package/src/core/ports/init-status.ts +0 -4
- package/src/core/ports/init-toolkit-status.ts +0 -4
- package/src/core/ports/pr-create.ts +0 -30
- package/src/core/ports/present-ascii.ts +0 -10
- package/src/core/ports/present-flow.ts +0 -10
- package/src/core/ports/vcs-config.ts +0 -14
- package/src/core/ports/vcs-merged-style.ts +0 -5
- package/src/core/ports/vcs-verify-token.ts +0 -4
- package/src/core/ports/youtrack-api.ts +0 -19
- package/src/core/ports/youtrack-config.ts +0 -21
- package/src/core/ports/youtrack-greeting.ts +0 -10
- package/src/core/ports/youtrack-parse-duration.ts +0 -14
- package/src/core/ports/youtrack-token-create-url.ts +0 -4
- package/src/core/ports/youtrack-verify-token.ts +0 -12
- package/src/core/ports/youtrack-work-date-ms.ts +0 -10
- package/src/core/present.ts +0 -109
- package/src/core/repo-tool.ts +0 -12
- package/src/core/repo-tools.ts +0 -22
- package/src/core/sync-runtime.ts +0 -375
- package/src/core/verify-parse.ts +0 -29
- package/src/core/verify-project.ts +0 -181
package/README.md
CHANGED
|
@@ -13,13 +13,13 @@ End users do **not** install this package directly — install `@brainervirus/wo
|
|
|
13
13
|
You consume `@brainervirus/workit-core` directly only when:
|
|
14
14
|
|
|
15
15
|
- Building a new host adapter (e.g. a future `codex/` adapter) that maps a host's native surfaces to the shared `src/core/` logic.
|
|
16
|
-
- Reading the canonical source of workflow behavior:
|
|
16
|
+
- Reading the canonical source of workflow behavior: task and policy engine, approval flow, commit/PR, changelog, YouTrack, doctor, and diagnostics.
|
|
17
17
|
|
|
18
18
|
## Layout and ownership
|
|
19
19
|
|
|
20
20
|
| Path | Contents |
|
|
21
21
|
| --- | --- |
|
|
22
|
-
| `src/core/` | Shared TS logic (setup, registration, doctor, logger, branch, PR, changelog, YouTrack,
|
|
22
|
+
| `src/core/` | Shared TS logic (setup, registration, doctor, logger, branch, PR, changelog, YouTrack, task/policy engine, support matrix). |
|
|
23
23
|
| `src/core.ts` | Package entry; adapters import `@brainervirus/workit-core/src/*`. |
|
|
24
24
|
| `skills/` | 14 `workit-*` method skills (single source; adapters ship byte-identical copies). |
|
|
25
25
|
| `scripts/` | Shared shell installers/launchers and the release-time workspace-dep rewrite. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brainervirus/workit-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
|
|
6
6
|
"keywords": [
|
|
@@ -31,8 +31,7 @@
|
|
|
31
31
|
"skills/",
|
|
32
32
|
"templates/",
|
|
33
33
|
"README.md",
|
|
34
|
-
"LICENSE"
|
|
35
|
-
"!scripts/verify-project.sh"
|
|
34
|
+
"LICENSE"
|
|
36
35
|
],
|
|
37
36
|
"type": "module",
|
|
38
37
|
"main": "./src/core.ts",
|
package/scripts/sync-runtime.sh
CHANGED
|
@@ -124,11 +124,15 @@ fi
|
|
|
124
124
|
# Canonical user rules -> Cursor .mdc (compiled by the shared core)
|
|
125
125
|
CONFIG_RULES_DIR="$(resolve_config_dir)/rules"
|
|
126
126
|
if [ -d "$CONFIG_RULES_DIR" ]; then
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
});
|
|
131
|
-
|
|
127
|
+
# rules.ts is loaded only here (scripts/check-reachability.ts allowlists it):
|
|
128
|
+
# a failure must surface, never leave the Cursor rules silently stale.
|
|
129
|
+
if ! "$BUN_BIN" -e "
|
|
130
|
+
const { writeCompiledCursorRules } = await import('${SHARE}/packages/workit-core/src/core/rules.ts');
|
|
131
|
+
writeCompiledCursorRules('${PLUGIN_DIR}/rules');
|
|
132
|
+
" >/dev/null; then
|
|
133
|
+
echo "FATAL: Cursor rule compilation failed: $CONFIG_RULES_DIR -> $PLUGIN_DIR/rules" >&2
|
|
134
|
+
exit 1
|
|
135
|
+
fi
|
|
132
136
|
fi
|
|
133
137
|
printf '%s\n' "$SHARE/packages/workit-core" >"$PLUGIN_DIR/.workit-root"
|
|
134
138
|
|
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
|
+
};
|