@brainervirus/workit-claude-code 8.45.4 → 8.45.5
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/.claude-plugin/plugin.json +1 -1
- package/bin/node-resolve.mjs +257 -0
- package/bin/workit +54 -2
- package/bin/workit-hook.mjs +31 -20
- package/dist/workit.js +959 -778
- package/package.json +3 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workit",
|
|
3
3
|
"displayName": "Workit",
|
|
4
|
-
"version": "8.45.
|
|
4
|
+
"version": "8.45.5",
|
|
5
5
|
"description": "Workflow rails for agentic coding: task context on every session, branch policy on shell commands, and the workit method skills",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "BrainerVirus"
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Finds a Node that can run a Workit hook when the PATH node is below the
|
|
2
|
+
// package's engines floor (a repo pinned to Node 16/18/22 by a version
|
|
3
|
+
// manager). Plain JavaScript with node: builtins only and no syntax newer than
|
|
4
|
+
// Node 16 supports: the host launchers import it (or carry a byte-equal copy,
|
|
5
|
+
// a test holds them equal) and must start on the very Node they are replacing.
|
|
6
|
+
// Side-effect free: importing it runs nothing; no process is spawned here.
|
|
7
|
+
//
|
|
8
|
+
// Order: (1) $WORKIT_NODE; (2) the node `workit setup`/`upgrade` recorded in
|
|
9
|
+
// <state dir>/node.json; (3) a scan of version-manager installs (fnm, nvm,
|
|
10
|
+
// volta, mise, asdf and the Windows layouts), highest version >= the floor.
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
/** The Node major the hooks need; a test holds it equal to every package's engines.node. */
|
|
17
|
+
export const HOOK_NODE_FLOOR = [24, 0, 0];
|
|
18
|
+
|
|
19
|
+
/** Set on the re-executed child so a launcher never re-executes twice. */
|
|
20
|
+
export const HOOK_NODE_REEXEC_ENV = "WORKIT_HOOK_REEXEC";
|
|
21
|
+
|
|
22
|
+
const MAX_ENTRIES_PER_ROOT = 200;
|
|
23
|
+
|
|
24
|
+
/** @param {unknown} text @returns {number[] | null} */
|
|
25
|
+
export const parseNodeVersion = (text) => {
|
|
26
|
+
const found = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(text).trim());
|
|
27
|
+
return found ? [Number(found[1]), Number(found[2] || 0), Number(found[3] || 0)] : null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** @param {number[]} a @param {number[]} b negative when a < b */
|
|
31
|
+
export const compareVersions = (a, b) => {
|
|
32
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
33
|
+
return 0;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The `>=X[.Y[.Z]]` minimum of an engines.node range, or null. @param {unknown} range */
|
|
37
|
+
export const floorOfRange = (range) => {
|
|
38
|
+
const min = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(range === undefined ? "" : range));
|
|
39
|
+
return min ? [Number(min[1]), Number(min[2] || 0), Number(min[3] || 0)] : null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** @param {NodeJS.ProcessEnv} env */
|
|
43
|
+
export const hookStateDir = (env) => {
|
|
44
|
+
if (env.WORKFLOW_TOOLKIT_STATE) return env.WORKFLOW_TOOLKIT_STATE;
|
|
45
|
+
if (env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, "workit");
|
|
46
|
+
const home = env.HOME || os.homedir();
|
|
47
|
+
if (process.platform === "darwin")
|
|
48
|
+
return path.join(home, "Library", "Application Support", "workit");
|
|
49
|
+
if (process.platform === "win32")
|
|
50
|
+
return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "workit");
|
|
51
|
+
return path.join(home, ".local", "state", "workit");
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** @param {string} file */
|
|
55
|
+
const isFile = (file) => {
|
|
56
|
+
try {
|
|
57
|
+
return statSync(file).isFile();
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** @param {string} file */
|
|
64
|
+
const readJson = (file) => {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The version-manager directories that hold one node per child directory:
|
|
74
|
+
* `{ dir, bin }` where the node is `<dir>/<child>/<bin>`.
|
|
75
|
+
* @param {NodeJS.ProcessEnv} env @param {string} platform
|
|
76
|
+
*/
|
|
77
|
+
const scanRoots = (env, platform) => {
|
|
78
|
+
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
79
|
+
const win = platform === "win32";
|
|
80
|
+
const exe = win ? "node.exe" : "node";
|
|
81
|
+
const bin = win ? exe : path.join("bin", exe);
|
|
82
|
+
/** @type {Array<{ dir: string, bin: string }>} */
|
|
83
|
+
const roots = [];
|
|
84
|
+
const add = (dir, rel) => {
|
|
85
|
+
if (dir) roots.push({ dir, bin: rel });
|
|
86
|
+
};
|
|
87
|
+
// fnm: <dir>/node-versions/<v>/installation/bin/node
|
|
88
|
+
const fnmDirs = [
|
|
89
|
+
env.FNM_DIR,
|
|
90
|
+
win ? path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "fnm") : "",
|
|
91
|
+
win ? path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "fnm") : "",
|
|
92
|
+
env.XDG_DATA_HOME ? path.join(env.XDG_DATA_HOME, "fnm") : "",
|
|
93
|
+
path.join(home, ".local", "share", "fnm"),
|
|
94
|
+
path.join(home, "Library", "Application Support", "fnm"),
|
|
95
|
+
path.join(home, ".fnm"),
|
|
96
|
+
];
|
|
97
|
+
for (const dir of fnmDirs)
|
|
98
|
+
if (dir) add(path.join(dir, "node-versions"), path.join("installation", bin));
|
|
99
|
+
// nvm: <dir>/versions/node/<v>/bin/node; nvm-windows: <NVM_HOME>/<v>/node.exe
|
|
100
|
+
for (const dir of [env.NVM_DIR, path.join(home, ".nvm")])
|
|
101
|
+
if (dir) add(path.join(dir, "versions", "node"), bin);
|
|
102
|
+
if (env.NVM_HOME) add(env.NVM_HOME, exe);
|
|
103
|
+
// volta: <dir>/tools/image/node/<v>/bin/node
|
|
104
|
+
const volta = env.VOLTA_HOME || (win ? "" : path.join(home, ".volta"));
|
|
105
|
+
if (volta) add(path.join(volta, "tools", "image", "node"), bin);
|
|
106
|
+
if (win)
|
|
107
|
+
add(
|
|
108
|
+
path.join(
|
|
109
|
+
env.LOCALAPPDATA || path.join(home, "AppData", "Local"),
|
|
110
|
+
"Volta",
|
|
111
|
+
"tools",
|
|
112
|
+
"image",
|
|
113
|
+
"node",
|
|
114
|
+
),
|
|
115
|
+
bin,
|
|
116
|
+
);
|
|
117
|
+
// mise and asdf: <dir>/installs/node[js]/<v>/bin/node
|
|
118
|
+
const mise = env.MISE_DATA_DIR || path.join(home, ".local", "share", "mise");
|
|
119
|
+
add(path.join(mise, "installs", "node"), bin);
|
|
120
|
+
const asdf = env.ASDF_DATA_DIR || path.join(home, ".asdf");
|
|
121
|
+
add(path.join(asdf, "installs", "nodejs"), bin);
|
|
122
|
+
return roots;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every installed node a version manager lists, with its version: directory
|
|
127
|
+
* listings only, at most MAX_ENTRIES_PER_ROOT per root.
|
|
128
|
+
* @param {NodeJS.ProcessEnv} env @param {string} [platform]
|
|
129
|
+
* @returns {Array<{ path: string, version: number[], source: string }>}
|
|
130
|
+
*/
|
|
131
|
+
export const scanInstalledNodes = (env, platform = process.platform) => {
|
|
132
|
+
/** @type {Array<{ path: string, version: number[], source: string }>} */
|
|
133
|
+
const found = [];
|
|
134
|
+
for (const root of scanRoots(env, platform)) {
|
|
135
|
+
let names;
|
|
136
|
+
try {
|
|
137
|
+
names = readdirSync(root.dir).slice(0, MAX_ENTRIES_PER_ROOT);
|
|
138
|
+
} catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
const version = parseNodeVersion(name);
|
|
143
|
+
if (!version || !/^v?\d/.test(name)) continue;
|
|
144
|
+
// volta's layout nests nothing; fnm's, nvm's, mise's and asdf's differ only in `bin`.
|
|
145
|
+
const candidate = path.join(root.dir, name, root.bin);
|
|
146
|
+
if (isFile(candidate)) found.push({ path: candidate, version, source: root.dir });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return found;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The node the hooks should run on, or null. `floor` is [major, minor, patch].
|
|
154
|
+
* @param {{ env: NodeJS.ProcessEnv, floor: number[], platform?: string }} options
|
|
155
|
+
* @returns {{ path: string, version: string | null, source: "env" | "recorded" | "scan" } | null}
|
|
156
|
+
*/
|
|
157
|
+
export const resolveHookNode = ({ env, floor, platform = process.platform }) => {
|
|
158
|
+
if (env.WORKIT_NODE && isFile(env.WORKIT_NODE))
|
|
159
|
+
return { path: env.WORKIT_NODE, version: null, source: "env" };
|
|
160
|
+
const recorded = readJson(path.join(hookStateDir(env), "node.json"));
|
|
161
|
+
if (recorded && typeof recorded.path === "string" && isFile(recorded.path)) {
|
|
162
|
+
const version = parseNodeVersion(recorded.version);
|
|
163
|
+
if (version && compareVersions(version, floor) >= 0)
|
|
164
|
+
return { path: recorded.path, version: String(recorded.version), source: "recorded" };
|
|
165
|
+
}
|
|
166
|
+
let best = null;
|
|
167
|
+
for (const entry of scanInstalledNodes(env, platform))
|
|
168
|
+
if (
|
|
169
|
+
compareVersions(entry.version, floor) >= 0 &&
|
|
170
|
+
(!best || compareVersions(entry.version, best.version) > 0)
|
|
171
|
+
)
|
|
172
|
+
best = entry;
|
|
173
|
+
return best ? { path: best.path, version: best.version.join("."), source: "scan" } : null;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The fail-open line for a node below the floor: null when `found` is at or
|
|
178
|
+
* above `floor`, or unparseable (an unreadable version never blocks).
|
|
179
|
+
* @param {string} found @param {number[] | null} floor @param {string} range
|
|
180
|
+
*/
|
|
181
|
+
export const floorMessage = (found, floor, range) => {
|
|
182
|
+
if (!floor) return null;
|
|
183
|
+
const have = parseNodeVersion(found);
|
|
184
|
+
if (!have || compareVersions(have, floor) >= 0) return null;
|
|
185
|
+
return `needs Node ${range}, found ${found}`;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** What the agent is told once, at SessionStart, when no Node >= the floor resolves. */
|
|
189
|
+
export const nodeFloorNotice = (found, range) =>
|
|
190
|
+
`Workit hooks are off: the Node on PATH is ${String(found).replace(/^v/, "")}, below the required ${range}, and no Node 24+ was found. ` +
|
|
191
|
+
"Install Node 24 (fnm install 24, nvm install 24) or run `workit setup` from a Node 24 shell so Workit can record it; " +
|
|
192
|
+
"`workit doctor` (check hook_node) shows what the hooks run on.";
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Where setup and upgrade record the Node they run on. A no-op for a non-node
|
|
196
|
+
* binary (bun) or a Node below the floor.
|
|
197
|
+
* @param {{ env: NodeJS.ProcessEnv, execPath: string, version: string, floor: number[] }} options
|
|
198
|
+
* @returns {string | null} the file written
|
|
199
|
+
*/
|
|
200
|
+
export const nodeRecord = ({ env, execPath, version, floor }) => {
|
|
201
|
+
const parsed = parseNodeVersion(version);
|
|
202
|
+
if (!parsed || compareVersions(parsed, floor) < 0) return null;
|
|
203
|
+
if (!/^node(?:\.exe)?$/i.test(path.basename(execPath))) return null;
|
|
204
|
+
return path.join(hookStateDir(env), "node.json");
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run `script args…` on `nodePath` with this process's stdin, stdout and
|
|
209
|
+
* stderr passed through. The child carries the re-exec guard and a real
|
|
210
|
+
* version (WORKIT_HOOK_NODE_VERSION, a test seam, is dropped).
|
|
211
|
+
* @returns {number | null} the child's exit code, or null when it could not start
|
|
212
|
+
*/
|
|
213
|
+
export const reexecOnNode = (nodePath, script, args, env) => {
|
|
214
|
+
const child = Object.assign({}, env, { [HOOK_NODE_REEXEC_ENV]: "1" });
|
|
215
|
+
delete child.WORKIT_HOOK_NODE_VERSION;
|
|
216
|
+
const run = spawnSync(nodePath, [script].concat(args), {
|
|
217
|
+
stdio: "inherit",
|
|
218
|
+
env: child,
|
|
219
|
+
windowsHide: true,
|
|
220
|
+
});
|
|
221
|
+
if (run.error) return null;
|
|
222
|
+
return run.status === null ? 1 : run.status;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* What a launcher does on a Node below its floor: re-run itself on a resolved
|
|
227
|
+
* Node (the child's exit code is returned), else fail open: one stderr line,
|
|
228
|
+
* and `{}` on stdout, except that a start event answers with `notice(text)` so
|
|
229
|
+
* the agent is told the hooks are off. Resolves to the exit code.
|
|
230
|
+
* @param {{ host: string, range: string, floor: number[], found: string, script: string,
|
|
231
|
+
* argv: string[], env: NodeJS.ProcessEnv, isStart: (event: unknown) => boolean,
|
|
232
|
+
* notice: (text: string) => unknown }} options
|
|
233
|
+
*/
|
|
234
|
+
export const handleBelowFloor = async (options) => {
|
|
235
|
+
if (!options.env[HOOK_NODE_REEXEC_ENV]) {
|
|
236
|
+
const hit = resolveHookNode({ env: options.env, floor: options.floor });
|
|
237
|
+
const code = hit ? reexecOnNode(hit.path, options.script, options.argv, options.env) : null;
|
|
238
|
+
if (code !== null) return code;
|
|
239
|
+
}
|
|
240
|
+
process.stderr.write(
|
|
241
|
+
`[workit] ${options.host} hook unavailable: needs Node ${options.range}, found ${options.found}\n`,
|
|
242
|
+
);
|
|
243
|
+
let early = "";
|
|
244
|
+
for await (const chunk of process.stdin) early += String(chunk);
|
|
245
|
+
let event;
|
|
246
|
+
try {
|
|
247
|
+
event = JSON.parse(early).hook_event_name;
|
|
248
|
+
} catch {
|
|
249
|
+
event = undefined;
|
|
250
|
+
}
|
|
251
|
+
process.stdout.write(
|
|
252
|
+
options.isStart(event)
|
|
253
|
+
? `${JSON.stringify(options.notice(nodeFloorNotice(options.found, options.range)))}\n`
|
|
254
|
+
: "{}\n",
|
|
255
|
+
);
|
|
256
|
+
return 0;
|
|
257
|
+
};
|
package/bin/workit
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
# package: run the bundled dist/workit.js with node.
|
|
5
5
|
# WORKIT_CLAUDE_RUNTIME=source|dist forces one; WORKIT_SHIM_TRACE=1 prints
|
|
6
6
|
# the resolved entry on stderr.
|
|
7
|
+
# When the PATH node is below the floor (24), the dist runs on a Node that is:
|
|
8
|
+
# $WORKIT_NODE, the node `workit setup` recorded in <state dir>/node.json, then
|
|
9
|
+
# the highest fnm/nvm/volta/mise/asdf install (same order as hooks, see
|
|
10
|
+
# bin/node-resolve.mjs; a test holds the floor equal).
|
|
7
11
|
set -e
|
|
8
12
|
here=$(dirname "$0")
|
|
9
13
|
root=$(cd "$here/.." && pwd)
|
|
@@ -20,5 +24,53 @@ if [ -n "$entry" ]; then
|
|
|
20
24
|
[ "${WORKIT_SHIM_TRACE:-}" = "1" ] && echo "workit-shim: source $entry" >&2
|
|
21
25
|
exec bun "$entry" "$@"
|
|
22
26
|
fi
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
node_floor=24
|
|
28
|
+
node_bin=node
|
|
29
|
+
node_major=$(node -v 2>/dev/null | sed 's/^v//; s/\..*$//')
|
|
30
|
+
case "$node_major" in
|
|
31
|
+
''|*[!0-9]*) node_major=0 ;;
|
|
32
|
+
esac
|
|
33
|
+
if [ "$node_major" -lt "$node_floor" ]; then
|
|
34
|
+
found=""
|
|
35
|
+
if [ -n "${WORKIT_NODE:-}" ] && [ -x "$WORKIT_NODE" ]; then
|
|
36
|
+
found="$WORKIT_NODE"
|
|
37
|
+
fi
|
|
38
|
+
if [ -z "$found" ]; then
|
|
39
|
+
if [ -n "${WORKFLOW_TOOLKIT_STATE:-}" ]; then state="$WORKFLOW_TOOLKIT_STATE"
|
|
40
|
+
elif [ -n "${XDG_STATE_HOME:-}" ]; then state="$XDG_STATE_HOME/workit"
|
|
41
|
+
elif [ "$(uname -s 2>/dev/null)" = "Darwin" ]; then state="$HOME/Library/Application Support/workit"
|
|
42
|
+
else state="$HOME/.local/state/workit"; fi
|
|
43
|
+
recorded=$(sed -n 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$state/node.json" 2>/dev/null | head -n 1)
|
|
44
|
+
rv=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"v\{0,1\}\([0-9]*\).*/\1/p' "$state/node.json" 2>/dev/null | head -n 1)
|
|
45
|
+
if [ -n "$recorded" ] && [ -x "$recorded" ] && [ "${rv:-0}" -ge "$node_floor" ]; then
|
|
46
|
+
found="$recorded"
|
|
47
|
+
fi
|
|
48
|
+
fi
|
|
49
|
+
if [ -z "$found" ]; then
|
|
50
|
+
candidates=""
|
|
51
|
+
for spec in \
|
|
52
|
+
"${FNM_DIR:-$HOME/.local/share/fnm}/node-versions|installation/bin/node" \
|
|
53
|
+
"$HOME/Library/Application Support/fnm/node-versions|installation/bin/node" \
|
|
54
|
+
"${NVM_DIR:-$HOME/.nvm}/versions/node|bin/node" \
|
|
55
|
+
"${VOLTA_HOME:-$HOME/.volta}/tools/image/node|bin/node" \
|
|
56
|
+
"${MISE_DATA_DIR:-$HOME/.local/share/mise}/installs/node|bin/node" \
|
|
57
|
+
"${ASDF_DATA_DIR:-$HOME/.asdf}/installs/nodejs|bin/node"; do
|
|
58
|
+
base=${spec%%|*}
|
|
59
|
+
rel=${spec#*|}
|
|
60
|
+
for dir in "$base"/*; do
|
|
61
|
+
name=${dir##*/}
|
|
62
|
+
v=${name#v}
|
|
63
|
+
major=${v%%.*}
|
|
64
|
+
case "$major" in ''|*[!0-9]*) continue ;; esac
|
|
65
|
+
if [ "$major" -ge "$node_floor" ] && [ -x "$dir/$rel" ]; then
|
|
66
|
+
candidates="$candidates
|
|
67
|
+
$v $dir/$rel"
|
|
68
|
+
fi
|
|
69
|
+
done
|
|
70
|
+
done
|
|
71
|
+
found=$(printf '%s\n' "$candidates" | sed '/^$/d' | sort -t. -k1,1n -k2,2n -k3,3n | tail -n 1 | sed 's/^[^ ]* //')
|
|
72
|
+
fi
|
|
73
|
+
if [ -n "$found" ]; then node_bin="$found"; fi
|
|
74
|
+
fi
|
|
75
|
+
[ "${WORKIT_SHIM_TRACE:-}" = "1" ] && echo "workit-shim: dist $root/dist/workit.js (node $node_bin)" >&2
|
|
76
|
+
exec "$node_bin" "$root/dist/workit.js" "$@"
|
package/bin/workit-hook.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync }
|
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
import { floorMessage, floorOfRange, handleBelowFloor } from "./node-resolve.mjs";
|
|
20
21
|
|
|
21
22
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
23
|
const source = path.join(root, "src", "run.ts");
|
|
@@ -33,32 +34,42 @@ const failOpen = (reason) => {
|
|
|
33
34
|
process.stdout.write("{}\n");
|
|
34
35
|
};
|
|
35
36
|
|
|
36
|
-
// A Node below this package's own engines minimum cannot load dist
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
37
|
+
// A Node below this package's own engines minimum cannot load dist. The launcher
|
|
38
|
+
// then looks for a Node that can (bin/node-resolve.mjs: $WORKIT_NODE, the node
|
|
39
|
+
// `workit setup` recorded, a version-manager scan) and re-runs itself on it with
|
|
40
|
+
// stdin, stdout, stderr and the exit code passed through; WORKIT_HOOK_REEXEC
|
|
41
|
+
// stops a second hop. With none found it fails open with one clear line, and a
|
|
42
|
+
// SessionStart tells the agent once that the hooks are off.
|
|
43
|
+
// WORKIT_HOOK_NODE_VERSION stands in for process.versions.node (tests). An
|
|
44
|
+
// unreadable minimum never blocks.
|
|
45
|
+
const nodeFloor = () => {
|
|
40
46
|
try {
|
|
41
|
-
const found = process.env.WORKIT_HOOK_NODE_VERSION || process.versions.node;
|
|
42
47
|
const range = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).engines?.node;
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const need = [min[1], min[2] ?? 0, min[3] ?? 0].map(Number);
|
|
46
|
-
const have = String(found).replace(/^v/, "").split(".").map(Number);
|
|
47
|
-
for (let i = 0; i < 3; i++) {
|
|
48
|
-
const a = have[i] ?? 0;
|
|
49
|
-
if (Number.isNaN(a)) return null;
|
|
50
|
-
if (a !== need[i]) return a < need[i] ? `needs Node ${range}, found ${found}` : null;
|
|
51
|
-
}
|
|
52
|
-
return null;
|
|
48
|
+
const floor = floorOfRange(range);
|
|
49
|
+
return floor ? { range, floor } : null;
|
|
53
50
|
} catch {
|
|
54
51
|
return null;
|
|
55
52
|
}
|
|
56
53
|
};
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
const hookNode = nodeFloor();
|
|
55
|
+
const foundNode = process.env.WORKIT_HOOK_NODE_VERSION || process.versions.node;
|
|
56
|
+
const tooOld = hookNode ? floorMessage(foundNode, hookNode.floor, hookNode.range) : null;
|
|
57
|
+
if (tooOld && hookNode)
|
|
58
|
+
process.exit(
|
|
59
|
+
await handleBelowFloor({
|
|
60
|
+
host: "Claude Code",
|
|
61
|
+
range: hookNode.range,
|
|
62
|
+
floor: hookNode.floor,
|
|
63
|
+
found: foundNode,
|
|
64
|
+
script: fileURLToPath(import.meta.url),
|
|
65
|
+
argv: process.argv.slice(2),
|
|
66
|
+
env: process.env,
|
|
67
|
+
isStart: (event) => event === "SessionStart",
|
|
68
|
+
notice: (additionalContext) => ({
|
|
69
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext },
|
|
70
|
+
}),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
62
73
|
|
|
63
74
|
// Every Bash/PowerShell call and every file edit reaches this hook (branch
|
|
64
75
|
// policy, raw git/forge steering and the before-write gate). Fast paths
|