faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { accessSync, chmodSync, constants, existsSync, lstatSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
import { normalizeZcodeResult, parseVersion } from "../protocol.mjs";
|
|
5
|
+
|
|
6
|
+
/** Default Z.ai Anthropic-compatible endpoint serving GLM models. */
|
|
7
|
+
const ZCODE_DEFAULT_BASE_URL = "https://api.z.ai/api/anthropic";
|
|
8
|
+
|
|
9
|
+
/** The command name the adapter resolves and the shim is installed under. */
|
|
10
|
+
const ZCODE_BIN_NAME = "zcode";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* macOS install layout: ZCode ships as an Electron app with the CLI bundled
|
|
14
|
+
* inside it (`zcode.cjs`) and no CLI installer of its own, so a machine can run
|
|
15
|
+
* the desktop app for months without ever having a runnable `zcode` command.
|
|
16
|
+
* The `glm` directory name is the app's own, not this skill's provider id.
|
|
17
|
+
*/
|
|
18
|
+
const ZCODE_MACOS_BUNDLE = Object.freeze({
|
|
19
|
+
electron: "/Applications/ZCode.app/Contents/MacOS/ZCode",
|
|
20
|
+
cli: "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Provider id in the ZCODE_MODEL target; it also derives the auth env var name. */
|
|
24
|
+
const ZCODE_DEFAULT_PROVIDER = "glm";
|
|
25
|
+
|
|
26
|
+
/** Default environment variable holding the Z.ai API token. */
|
|
27
|
+
const ZCODE_DEFAULT_AUTH_TOKEN_ENV = "ZAI_API_KEY";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* ZCode harness: drives Z.ai's own harness CLI headlessly (`--prompt --json`),
|
|
31
|
+
* so a contract can route GLM 5.x nodes through the native ZCode protocol
|
|
32
|
+
* instead of a Claude-Code-compatible shim. The CLI 0.16.5 headless surface is
|
|
33
|
+
* `--prompt`, `--json`, `--mode`, `--resume`, and `--no-color`; model and
|
|
34
|
+
* endpoint travel as `ZCODE_MODEL` (`provider/model`) and `ZCODE_BASE_URL`,
|
|
35
|
+
* and the token is read at invocation time from the environment variable named
|
|
36
|
+
* by `config["auth_token.env_key"]` (default `ZAI_API_KEY`, falling back to
|
|
37
|
+
* `ANTHROPIC_AUTH_TOKEN`) into the provider-derived `${PROVIDER}_API_KEY`
|
|
38
|
+
* variable the CLI resolves. Values never travel in the contract.
|
|
39
|
+
*
|
|
40
|
+
* The harness has no schema flag, and this harness sends no tool policy, so
|
|
41
|
+
* `structuredOutput` and `toolPolicy` stay `false`: a judge's schema travels
|
|
42
|
+
* inside the prompt text (enforcement remains parseJudge at the review
|
|
43
|
+
* boundary), and the CLI's `--settings`/hooks surface stays unwired.
|
|
44
|
+
*
|
|
45
|
+
* The vendor's own CLI reference is checked in at
|
|
46
|
+
* `docs/faberun/ZCODE-CLI.md`; it is the authority this adapter is
|
|
47
|
+
* written against, and the place to look before trusting any of the surface
|
|
48
|
+
* facts above. `ensureZcodeAvailable` below implements what that document
|
|
49
|
+
* describes as the install story: the CLI lives inside the app bundle and has
|
|
50
|
+
* to be reached through a shim on PATH.
|
|
51
|
+
*
|
|
52
|
+
* @type {import("../index.mjs").HarnessAdapter}
|
|
53
|
+
*/
|
|
54
|
+
export const zcodeHarness = {
|
|
55
|
+
capabilities: {
|
|
56
|
+
structuredOutput: false,
|
|
57
|
+
promptTransport: "argv",
|
|
58
|
+
maxArgvPromptBytes: 128 * 1024,
|
|
59
|
+
sandbox: false,
|
|
60
|
+
permissions: false,
|
|
61
|
+
continuation: true,
|
|
62
|
+
tokenBudget: false,
|
|
63
|
+
costBudget: false,
|
|
64
|
+
usage: true,
|
|
65
|
+
cost: false,
|
|
66
|
+
toolPolicy: false,
|
|
67
|
+
// `--json` (no streaming flag exists) buffers the whole turn and dumps it
|
|
68
|
+
// once at exit: a live worker node was killed at 420s stall_timeout with
|
|
69
|
+
// its stdout/stderr at zero bytes, while a completed 1m26s invocation's
|
|
70
|
+
// log held its full 26 lines only once the process exited. Stall
|
|
71
|
+
// detection must not watch this harness's stdout/stderr mtime.
|
|
72
|
+
streamsOutput: false,
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// build/edit/plan do not execute commands; command() defaults to yolo.
|
|
76
|
+
permissionExecution: { field: "permissionMode", executingModes: ["yolo"], defaultMode: "yolo" },
|
|
77
|
+
|
|
78
|
+
/** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
|
|
79
|
+
executable(runtime) {
|
|
80
|
+
const declared = process.env.FABERUN_ZCODE_BIN ?? runtime.executable;
|
|
81
|
+
if (declared) return declared;
|
|
82
|
+
// Repair the host, then keep naming the command `zcode`. The name is part
|
|
83
|
+
// of the runtime fingerprint (node.mjs hashes `{runtime, executable}`) and
|
|
84
|
+
// is compared against snapshots persisted by an earlier process, where a
|
|
85
|
+
// mismatch silently rotates the session and drops `--resume`. An absolute
|
|
86
|
+
// path would make that identity depend on whichever PATH the process
|
|
87
|
+
// happened to inherit.
|
|
88
|
+
ensureZcodeAvailable();
|
|
89
|
+
return ZCODE_BIN_NAME;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string[]} */
|
|
93
|
+
versionArgs(runtime) {
|
|
94
|
+
return runtime.versionArgs ?? ["--version"];
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
parseVersion,
|
|
98
|
+
|
|
99
|
+
/** @param {import("../index.mjs").HarnessRuntime} runtime @param {string} prompt @param {import("../index.mjs").CommandOptions} options @returns {import("../index.mjs").HarnessCommand} */
|
|
100
|
+
command(runtime, prompt, options) {
|
|
101
|
+
const continuationId = options.continuationId ?? null;
|
|
102
|
+
const provider = typeof runtime.config?.provider === "string" && runtime.config.provider
|
|
103
|
+
? runtime.config.provider
|
|
104
|
+
: ZCODE_DEFAULT_PROVIDER;
|
|
105
|
+
// No schema flag exists, so the schema travels inside the prompt: the
|
|
106
|
+
// judge prompt names "the output schema" but only carries its text when
|
|
107
|
+
// the harness puts it there.
|
|
108
|
+
const fullPrompt = options.schema
|
|
109
|
+
? `${prompt}\n\nThe output schema (return exactly one JSON object matching it, as the only content of your final message):\n${JSON.stringify(options.schema)}`
|
|
110
|
+
: prompt;
|
|
111
|
+
const args = [
|
|
112
|
+
"--json",
|
|
113
|
+
"--no-color",
|
|
114
|
+
"--mode",
|
|
115
|
+
runtime.permissionMode ?? "yolo",
|
|
116
|
+
...(continuationId ? ["--resume", continuationId] : []),
|
|
117
|
+
"--prompt",
|
|
118
|
+
fullPrompt,
|
|
119
|
+
];
|
|
120
|
+
const model = runtime.model.replace(/\[1m\]$/iu, "");
|
|
121
|
+
/** @type {Record<string, string|null>} */
|
|
122
|
+
const env = {
|
|
123
|
+
ZCODE_MODEL: `${provider}/${model}`,
|
|
124
|
+
ZCODE_BASE_URL: /** @type {string} */ (runtime.config?.base_url) ?? ZCODE_DEFAULT_BASE_URL,
|
|
125
|
+
// An ambient Anthropic key must not shadow the provider-derived token:
|
|
126
|
+
// the CLI checks it first for anthropic-kind providers.
|
|
127
|
+
ANTHROPIC_API_KEY: null,
|
|
128
|
+
};
|
|
129
|
+
const token = authToken(runtime);
|
|
130
|
+
// The token travels under the provider-derived variable name the CLI
|
|
131
|
+
// resolves (e.g. GLM_API_KEY); an unresolved token is omitted, not blanked.
|
|
132
|
+
const apiKeyVar = providerApiKeyVar(provider);
|
|
133
|
+
if (token !== null && apiKeyVar !== null) env[apiKeyVar] = token;
|
|
134
|
+
return { executable: this.executable(runtime), args, promptTransport: "argv", input: null, env };
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
normalize: normalizeZcodeResult,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The variable the harness reads a provider's token from: the CLI folds every
|
|
142
|
+
* run of non-alphanumerics in the provider id into `_` before appending
|
|
143
|
+
* `_API_KEY` (`z-ai` → `Z_AI_API_KEY`), so the id carried verbatim in
|
|
144
|
+
* `ZCODE_MODEL` has to be folded the same way. An id with no alphanumerics
|
|
145
|
+
* names no variable at all.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} provider
|
|
148
|
+
* @returns {string|null}
|
|
149
|
+
*/
|
|
150
|
+
function providerApiKeyVar(provider) {
|
|
151
|
+
const stem = provider.trim().replace(/[^a-zA-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase();
|
|
152
|
+
return stem ? `${stem}_API_KEY` : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {import("../index.mjs").HarnessRuntime} runtime
|
|
157
|
+
* @returns {string|null}
|
|
158
|
+
*/
|
|
159
|
+
function authToken(runtime) {
|
|
160
|
+
const declared = /** @type {unknown} */ (runtime.config?.["auth_token.env_key"]);
|
|
161
|
+
const name = typeof declared === "string" && declared ? declared : ZCODE_DEFAULT_AUTH_TOKEN_ENV;
|
|
162
|
+
const resolved = process.env[name] ?? process.env.ANTHROPIC_AUTH_TOKEN;
|
|
163
|
+
return typeof resolved === "string" && resolved ? resolved : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Make the ZCode CLI reachable as `zcode`, for this process and every later
|
|
168
|
+
* shell: when the name resolves to nothing on PATH and the app bundle is
|
|
169
|
+
* installed, write the shim that reaches the bundled CLI through Electron's own
|
|
170
|
+
* node. The install dir has to be on PATH already — a shim somewhere the shell
|
|
171
|
+
* does not look would fix the harness and not the user, which is the half of the
|
|
172
|
+
* request that matters here.
|
|
173
|
+
*
|
|
174
|
+
* Total by design: `executable()` is called by surfaces that have no error path
|
|
175
|
+
* around it (`models` reports every registered harness, runtime discovery probes
|
|
176
|
+
* each one, `doctor` checks binaries), so a permissions or disk failure has to
|
|
177
|
+
* degrade into "not found" — never into an aborted run or a crashed report.
|
|
178
|
+
*
|
|
179
|
+
* @param {{env?: Record<string, string|undefined>, pathDirs?: string[], home?: string, bundle?: {electron: string, cli: string}}} [options]
|
|
180
|
+
* @returns {void}
|
|
181
|
+
*/
|
|
182
|
+
export function ensureZcodeAvailable(options = {}) {
|
|
183
|
+
try {
|
|
184
|
+
const env = options.env ?? process.env;
|
|
185
|
+
const pathDirs = options.pathDirs ?? (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
186
|
+
if (resolvesOnPath(pathDirs, ZCODE_BIN_NAME)) return;
|
|
187
|
+
const bundle = options.bundle ?? ZCODE_MACOS_BUNDLE;
|
|
188
|
+
if (!existsSync(bundle.electron) || !existsSync(bundle.cli)) return;
|
|
189
|
+
const body = zcodeShim(bundle);
|
|
190
|
+
for (const dir of shimDirs(options.home ?? homedir())) {
|
|
191
|
+
if (!pathDirs.includes(dir)) continue;
|
|
192
|
+
if (settleShim(join(dir, ZCODE_BIN_NAME), body)) return;
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Unreachable host: the spawn fails and the adapter classifies `not_found`,
|
|
196
|
+
// which is the same answer a machine without the app gets.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The shim body. It runs the bundle through the app's own Electron binary as
|
|
202
|
+
* node because the CLI mis-handles its response path under a system node
|
|
203
|
+
* (detached ArrayBuffer on node 24 x64), and it `exec`s so no wrapper process
|
|
204
|
+
* outlives it.
|
|
205
|
+
*
|
|
206
|
+
* @param {{electron: string, cli: string}} bundle
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
function zcodeShim(bundle) {
|
|
210
|
+
return `#!/usr/bin/env bash
|
|
211
|
+
set -euo pipefail
|
|
212
|
+
# Written by the faberun zcode harness; the ZCode app owns both paths.
|
|
213
|
+
ELECTRON_RUN_AS_NODE=1 exec ${bundle.electron} \\
|
|
214
|
+
${bundle.cli} "$@"
|
|
215
|
+
`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Install dirs, in preference order. `~/.local/bin` is the convention this
|
|
220
|
+
* machine already uses for provider CLIs, `~/bin` is the older habit, and
|
|
221
|
+
* `/usr/local/bin` is the last resort — all three are only eligible while they
|
|
222
|
+
* are on PATH (see the caller).
|
|
223
|
+
*
|
|
224
|
+
* @param {string} home
|
|
225
|
+
* @returns {string[]}
|
|
226
|
+
*/
|
|
227
|
+
function shimDirs(home) {
|
|
228
|
+
return [join(home, ".local", "bin"), join(home, "bin"), "/usr/local/bin"];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @param {string[]} pathDirs
|
|
233
|
+
* @param {string} name
|
|
234
|
+
* @returns {boolean} whether `name` is a runnable command on this PATH.
|
|
235
|
+
*/
|
|
236
|
+
function resolvesOnPath(pathDirs, name) {
|
|
237
|
+
return pathDirs.some((dir) => {
|
|
238
|
+
try {
|
|
239
|
+
accessSync(join(dir, name), constants.X_OK);
|
|
240
|
+
return true;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Write the shim into `target`, or decide it is already settled. Returns false
|
|
249
|
+
* only to let the caller try the next install dir.
|
|
250
|
+
*
|
|
251
|
+
* The symlink refusal is the important one: writing a path that is a symlink
|
|
252
|
+
* writes through it, onto whatever it points at — and on a machine where the
|
|
253
|
+
* user already hand-installed `zcode -> zcode-shim`, that would be their file.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} target
|
|
256
|
+
* @param {string} body
|
|
257
|
+
* @returns {boolean}
|
|
258
|
+
*/
|
|
259
|
+
function settleShim(target, body) {
|
|
260
|
+
const existing = lstatSync(target, { throwIfNoEntry: false });
|
|
261
|
+
if (existing?.isSymbolicLink()) return true;
|
|
262
|
+
if (existing?.isFile() && readFileSync(target, "utf8") === body) return true;
|
|
263
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
264
|
+
try {
|
|
265
|
+
writeFileSync(temporary, body);
|
|
266
|
+
// A created file's mode is masked by umask, and the shim has to be runnable.
|
|
267
|
+
chmodSync(temporary, 0o755);
|
|
268
|
+
renameSync(temporary, target);
|
|
269
|
+
} finally {
|
|
270
|
+
rmSync(temporary, { force: true });
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export const harness = zcodeHarness;
|
|
276
|
+
export default zcodeHarness;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user config at `$FABERUN_HOME/config.json`: which harnesses `setup`
|
|
3
|
+
* enabled and which runtime is the default worker and judge.
|
|
4
|
+
*
|
|
5
|
+
* It is separate from `host/home.mjs`, which owns the path, because reading a
|
|
6
|
+
* config is a validation problem and writing it is an atomic-write problem;
|
|
7
|
+
* neither belongs in the path table. `engine/runtime-discovery.mjs` types its
|
|
8
|
+
* `options.config` against this module and `cli/setup.mjs` writes it.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
import { configPath, faberunHome } from "./home.mjs";
|
|
13
|
+
|
|
14
|
+
/** @typedef {{schemaVersion: 1, harnesses: string[], worker?: string, judge?: string, updatedAt: string}} UserConfig */
|
|
15
|
+
|
|
16
|
+
/** Paths already reported malformed, so a process that reads twice warns once. */
|
|
17
|
+
const warned = new Set();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The user config, or null when the file is absent or malformed. A malformed
|
|
21
|
+
* file is written off as a single `[warn]` line on stderr and then ignored:
|
|
22
|
+
* the run falls back to the discovery law rather than refusing to start.
|
|
23
|
+
*
|
|
24
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
25
|
+
* @returns {UserConfig|null}
|
|
26
|
+
*/
|
|
27
|
+
export function readUserConfig(env = process.env) {
|
|
28
|
+
const path = configPath(faberunHome(env));
|
|
29
|
+
let text;
|
|
30
|
+
try {
|
|
31
|
+
text = readFileSync(path, "utf8");
|
|
32
|
+
} catch {
|
|
33
|
+
// An unreadable file is indistinguishable from an absent one here; there
|
|
34
|
+
// is nothing to act on either way.
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(text);
|
|
40
|
+
} catch {
|
|
41
|
+
return warnMalformed(path);
|
|
42
|
+
}
|
|
43
|
+
return isUserConfig(parsed) ? parsed : warnMalformed(path);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Write the config atomically: a sibling temporary file then a rename, so a
|
|
48
|
+
* concurrent reader never observes a half-written record. The home directory
|
|
49
|
+
* is created on the way.
|
|
50
|
+
*
|
|
51
|
+
* @param {NodeJS.ProcessEnv} env
|
|
52
|
+
* @param {UserConfig} config
|
|
53
|
+
* @returns {void}
|
|
54
|
+
*/
|
|
55
|
+
export function writeUserConfig(env, config) {
|
|
56
|
+
const path = configPath(faberunHome(env));
|
|
57
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
58
|
+
const temporary = `${path}.tmp`;
|
|
59
|
+
writeFileSync(temporary, `${JSON.stringify(config)}\n`);
|
|
60
|
+
renameSync(temporary, path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} path
|
|
65
|
+
* @returns {null}
|
|
66
|
+
*/
|
|
67
|
+
function warnMalformed(path) {
|
|
68
|
+
if (!warned.has(path)) {
|
|
69
|
+
warned.add(path);
|
|
70
|
+
process.stderr.write(`[warn] config · ${path} is not valid; ignoring it\n`);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {unknown} value
|
|
77
|
+
* @returns {value is UserConfig}
|
|
78
|
+
*/
|
|
79
|
+
function isUserConfig(value) {
|
|
80
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
81
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
82
|
+
if (record.schemaVersion !== 1) return false;
|
|
83
|
+
if (!Array.isArray(record.harnesses) || !record.harnesses.every((harness) => typeof harness === "string")) return false;
|
|
84
|
+
if (record.worker !== undefined && typeof record.worker !== "string") return false;
|
|
85
|
+
if (record.judge !== undefined && typeof record.judge !== "string") return false;
|
|
86
|
+
return typeof record.updatedAt === "string";
|
|
87
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The install layout under `$FABERUN_HOME` (default `~/.faberun`).
|
|
3
|
+
*
|
|
4
|
+
* One module owns every path the installer, the self-updater and the banner
|
|
5
|
+
* must agree on: `versions/<v>/`, the `current` symlink, `config.json`,
|
|
6
|
+
* `update-check.json` and `tmp/`. install.sh is owned by another node; if it
|
|
7
|
+
* and the updater each spelled the layout out, they would drift until
|
|
8
|
+
* `current` pointed at a directory one of them did not mean.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** @typedef {{checkedAt: string, current: string, latest: string}} UpdateCheck */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The install root: `$FABERUN_HOME`, or `~/.faberun` when it is unset or empty.
|
|
18
|
+
*
|
|
19
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function faberunHome(env = process.env) {
|
|
23
|
+
const configured = env.FABERUN_HOME;
|
|
24
|
+
if (typeof configured === "string" && configured) return configured;
|
|
25
|
+
return join(homedir(), ".faberun");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {string} home @returns {string} */
|
|
29
|
+
export function versionsDir(home) {
|
|
30
|
+
return join(home, "versions");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {string} home @returns {string} */
|
|
34
|
+
export function currentLink(home) {
|
|
35
|
+
return join(home, "current");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {string} home @returns {string} */
|
|
39
|
+
export function configPath(home) {
|
|
40
|
+
return join(home, "config.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {string} home @returns {string} */
|
|
44
|
+
export function updateCheckPath(home) {
|
|
45
|
+
return join(home, "update-check.json");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {string} home @returns {string} */
|
|
49
|
+
export function tmpDir(home) {
|
|
50
|
+
return join(home, "tmp");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The version directory an entry path was launched from, or null when the path
|
|
55
|
+
* does not name a location inside `versions/`.
|
|
56
|
+
*
|
|
57
|
+
* Containment is judged on the literal path first: a `bin/faberun.mjs` that is
|
|
58
|
+
* itself a symlink still counts because its *location* is what identifies the
|
|
59
|
+
* installed version. When the literal path does not match, the path is resolved
|
|
60
|
+
* so an entry reached through the install's `current` link (or a `$FABERUN_BIN_DIR`
|
|
61
|
+
* symlink to it) reports the version it ultimately points at.
|
|
62
|
+
*
|
|
63
|
+
* @param {string|undefined} entryPath
|
|
64
|
+
* @param {string} home
|
|
65
|
+
* @returns {string|null}
|
|
66
|
+
*/
|
|
67
|
+
export function installedVersionDir(entryPath, home) {
|
|
68
|
+
if (typeof entryPath !== "string" || !entryPath) return null;
|
|
69
|
+
const root = resolve(versionsDir(home));
|
|
70
|
+
/** @type {string[]} */
|
|
71
|
+
const candidates = [resolve(entryPath)];
|
|
72
|
+
try {
|
|
73
|
+
candidates.push(realpathSync(entryPath));
|
|
74
|
+
} catch {
|
|
75
|
+
// The path does not exist: only its literal location can identify a version.
|
|
76
|
+
}
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
const within = relative(root, candidate);
|
|
79
|
+
if (!within || within.startsWith("..") || isAbsolute(within)) continue;
|
|
80
|
+
const [name] = within.split(sep);
|
|
81
|
+
if (name) return join(root, name);
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The cached update check, or null when it is missing or malformed. The banner
|
|
88
|
+
* reads this and nothing else: it never reaches the network.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} home
|
|
91
|
+
* @returns {UpdateCheck|null}
|
|
92
|
+
*/
|
|
93
|
+
export function readUpdateCheck(home) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(readFileSync(updateCheckPath(home), "utf8"));
|
|
96
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
97
|
+
if (typeof parsed.checkedAt !== "string" || typeof parsed.current !== "string" || typeof parsed.latest !== "string") return null;
|
|
98
|
+
return { checkedAt: parsed.checkedAt, current: parsed.current, latest: parsed.latest };
|
|
99
|
+
} catch {
|
|
100
|
+
// No readable cache is simply no cached fact, not an error.
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Write the cache atomically: a sibling temporary file then a rename, so a
|
|
107
|
+
* reader never observes a half-written record.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} home
|
|
110
|
+
* @param {UpdateCheck} record
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
export function writeUpdateCheck(home, record) {
|
|
114
|
+
const path = updateCheckPath(home);
|
|
115
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
116
|
+
const temporary = `${path}.tmp`;
|
|
117
|
+
writeFileSync(temporary, `${JSON.stringify(record)}\n`);
|
|
118
|
+
renameSync(temporary, path);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Compare two `X.Y.Z` versions numerically on their three parts. Prerelease
|
|
123
|
+
* suffixes are out of scope for this command: a version that does not match the
|
|
124
|
+
* three-part grammar compares equal to nothing, so no hint is shown.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} left
|
|
127
|
+
* @param {string} right
|
|
128
|
+
* @returns {-1|0|1}
|
|
129
|
+
*/
|
|
130
|
+
export function compareVersions(left, right) {
|
|
131
|
+
const a = parseVersion(left);
|
|
132
|
+
const b = parseVersion(right);
|
|
133
|
+
if (!a || !b) return 0;
|
|
134
|
+
for (let index = 0; index < 3; index += 1) {
|
|
135
|
+
if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
|
|
136
|
+
}
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} text
|
|
142
|
+
* @returns {number[]|null}
|
|
143
|
+
*/
|
|
144
|
+
function parseVersion(text) {
|
|
145
|
+
if (typeof text !== "string") return null;
|
|
146
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(text);
|
|
147
|
+
if (!match) return null;
|
|
148
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
149
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The installed package's own name and version, read once from `package.json`.
|
|
3
|
+
*
|
|
4
|
+
* It is separate from the contract protocol version: `CONTRACT_VERSION` is the
|
|
5
|
+
* schema `src/` evolves on its own, while this is the release number
|
|
6
|
+
* release-please moves. `--version` and the banner both need the latter from
|
|
7
|
+
* one place, so no source file hard-codes a number the package can outgrow.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
/** @type {{name: string, version: string}} */
|
|
13
|
+
const PACKAGE = JSON.parse(readFileSync(fileURLToPath(new URL("../../package.json", import.meta.url)), "utf8"));
|
|
14
|
+
|
|
15
|
+
/** @returns {string} */
|
|
16
|
+
export function packageName() {
|
|
17
|
+
return PACKAGE.name;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @returns {string} */
|
|
21
|
+
export function packageVersion() {
|
|
22
|
+
return PACKAGE.version;
|
|
23
|
+
}
|