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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. 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
+ }