duediligence 0.3.0 → 0.3.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/bin/hash.mjs ADDED
@@ -0,0 +1,111 @@
1
+ // The commitment hash: one sha256 over a spec's hidden suite as the judge
2
+ // uses it, so a graded party can check the suite was fixed before they
3
+ // submitted. bin/prepare.mjs records it in prepared.json, bin/handout.mjs
4
+ // shows it in BRIEF.md and handout.json, bin/judge.mjs recomputes it and
5
+ // refuses on a mismatch, bin/promote.mjs prints it at reveal, and a seal
6
+ // (bin/seal.mjs) carries it in seal.json. bin/commitment.mjs records and
7
+ // checks it per spec; this module only computes it, from a folder or from
8
+ // files in memory, and reads nothing else (no DUEDILIGENCE_HOME, no HOME).
9
+ //
10
+ // What is hashed, from a folder laid out as specs/<slug>/ (or an unpacked
11
+ // seal): a manifest of lines in `sha256sum` format, `<hex> <path>\n`,
12
+ // - one per file under tests/private/, every depth, `.DS_Store` skipped,
13
+ // sorted by the UTF-8 bytes of the path: the hex the sha256 of its
14
+ // bytes, the path `tests/private/<rel>` with `/` separators;
15
+ // - then one line for the kept test ids: the hex the sha256 of
16
+ // JSON.stringify(calibration.kept sorted) (UTF-8), the path
17
+ // `calibration.kept`;
18
+ // - only for a spec at a black-box seam, a last line for what the suites
19
+ // call (toolchain.json `boundary`: the command, or the service's start,
20
+ // healthcheck and build), since changing it changes what the hidden
21
+ // suite tests: the hex the sha256 of its JSON with keys sorted at every
22
+ // level, the path `toolchain.boundary`. A spec without one hashes as
23
+ // before.
24
+ // hiddenSha256 is the sha256 of the manifest's UTF-8 bytes, lowercase hex.
25
+ // By hand:
26
+ // (cd specs/<slug> && find tests/private -type f ! -name .DS_Store | LC_ALL=C sort | xargs shasum -a 256
27
+ // && printf '%s calibration.kept\n' "$(jq -cj '.kept | sort' calibration.json | shasum -a 256 | cut -c1-64)"
28
+ // && printf '%s toolchain.boundary\n' "$(jq -cjS '.boundary' toolchain.json | shasum -a 256 | cut -c1-64)") | shasum -a 256
29
+ // (the last printf only when toolchain.json has a boundary)
30
+ // (jq sorts by code point and JS by UTF-16 unit; the two agree unless an id
31
+ // holds characters beyond the BMP.)
32
+
33
+ import { createHash } from "node:crypto";
34
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
35
+ import { join } from "node:path";
36
+
37
+ const sha = (data) => createHash("sha256").update(data).digest("hex");
38
+ const byBytes = (a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path));
39
+ /** A JSON value with every object's keys sorted, as `jq -S` writes it. */
40
+ const sorted = (value) =>
41
+ Array.isArray(value)
42
+ ? value.map(sorted)
43
+ : value !== null && typeof value === "object"
44
+ ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]))
45
+ : value;
46
+
47
+ /** Every file under `dir`, relative with `/`, sorted; none when it does not exist. */
48
+ function filesUnder(dir) {
49
+ const out = [];
50
+ const walk = (at, rel) => {
51
+ for (const entry of readdirSync(at, { withFileTypes: true })) {
52
+ const path = rel === "" ? entry.name : `${rel}/${entry.name}`;
53
+ if (entry.isDirectory()) walk(join(at, entry.name), path);
54
+ else out.push(path);
55
+ }
56
+ };
57
+ if (existsSync(dir)) walk(dir, "");
58
+ return out.sort();
59
+ }
60
+
61
+ /**
62
+ * The spec as hashed: its hidden files (`tests/private/<rel>` → bytes),
63
+ * calibration.json and toolchain.json parsed (or undefined), from a folder
64
+ * or from a file map of the same layout: a Map or object keyed by paths
65
+ * relative to the spec folder with `/`, each a string (UTF-8) or bytes.
66
+ */
67
+ function specOf(source) {
68
+ if (typeof source === "string") {
69
+ const json = (name) => (existsSync(join(source, name)) ? JSON.parse(readFileSync(join(source, name), "utf8")) : undefined);
70
+ const hidden = join(source, "tests", "private");
71
+ return {
72
+ hidden: filesUnder(hidden).map((rel) => ({ rel, bytes: () => readFileSync(join(hidden, ...rel.split("/"))) })),
73
+ calibration: json("calibration.json"),
74
+ toolchain: json("toolchain.json"),
75
+ };
76
+ }
77
+ if (source === null || typeof source !== "object")
78
+ throw new TypeError("hiddenManifest: pass a spec folder or a file map ({ 'tests/private/<file>': contents, ... })");
79
+ const entries = source instanceof Map ? [...source.entries()] : Object.entries(source);
80
+ const text = (value) => (typeof value === "string" ? value : Buffer.from(value).toString("utf8"));
81
+ const json = (name) => {
82
+ const found = entries.find(([path]) => path === name);
83
+ return found === undefined ? undefined : JSON.parse(text(found[1]));
84
+ };
85
+ return {
86
+ hidden: entries
87
+ .filter(([path]) => path.startsWith("tests/private/"))
88
+ .map(([path, value]) => ({ rel: path.slice("tests/private/".length), bytes: () => value })),
89
+ calibration: json("calibration.json"),
90
+ toolchain: json("toolchain.json"),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * The manifest hiddenSha256 is the hash of (see above), from a spec folder
96
+ * or a file map. `kept` defaults to calibration.json's.
97
+ */
98
+ export function hiddenManifest(source, kept) {
99
+ const spec = specOf(source);
100
+ const ids = kept ?? spec.calibration?.kept ?? [];
101
+ const lines = spec.hidden
102
+ .filter((file) => file.rel.split("/").at(-1) !== ".DS_Store")
103
+ .map((file) => ({ path: `tests/private/${file.rel}`, hex: sha(file.bytes()) }));
104
+ lines.sort(byBytes).push({ path: "calibration.kept", hex: sha(JSON.stringify([...ids].sort())) });
105
+ const boundary = spec.toolchain?.boundary;
106
+ if (boundary !== undefined) lines.push({ path: "toolchain.boundary", hex: sha(JSON.stringify(sorted(boundary))) });
107
+ return lines.map((line) => `${line.hex} ${line.path}\n`).join("");
108
+ }
109
+
110
+ /** The commitment hash of the hidden suite in a spec folder (specs/<slug>/ or an unpacked seal) or a file map. */
111
+ export const hiddenSha256 = (source, kept) => sha(hiddenManifest(source, kept));
package/bin/judge.mjs CHANGED
@@ -53,6 +53,7 @@
53
53
  import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
54
54
  import { join, resolve } from "node:path";
55
55
  import { spawnSync } from "node:child_process";
56
+ import { judgement } from "./rules.mjs";
56
57
  import { unseal } from "./seal.mjs";
57
58
 
58
59
  const sealed = openSeal(process.argv.slice(2));
@@ -62,21 +63,16 @@ const {
62
63
  args,
63
64
  audit,
64
65
  checkoutChanges,
65
- count,
66
66
  discard,
67
67
  judge,
68
68
  newestTranscript,
69
69
  pathsOf,
70
70
  promotedOf,
71
71
  readJson,
72
- regressionsOf,
73
- scenarioOf,
74
72
  scopeOf,
75
73
  sha256,
76
74
  specDir,
77
- testsOf,
78
75
  toolchainOf,
79
- verdict,
80
76
  workdir,
81
77
  writeJson,
82
78
  writeSnapshot,
@@ -128,23 +124,17 @@ if (!entries.some((path) => existsSync(join(src, path)))) {
128
124
  }
129
125
 
130
126
  const judged = judge(slug, realpathSync(src));
131
- const publicCount = count(judged.public, calibration.publicIds);
132
- const hiddenCount = count(judged.hidden, calibration.kept);
133
- const reported = new Map(testsOf(judged.hidden).map((test) => [test.id, test]));
134
- const failures = calibration.kept
135
- .filter((id) => reported.get(id)?.ok !== true)
136
- .map((id) => ({ id, message: reported.get(id)?.error?.message ?? "did not run" }));
137
- const result = verdict({ publicCount, hiddenCount });
127
+ // The rule itself: rules.mjs `judgement`, which bin/api.mjs exports as `verdict`.
128
+ const outcome = judgement({ public: judged.public, hidden: judged.hidden, publicIds: calibration.publicIds, kept: calibration.kept });
129
+ const { verdict: result, public: publicCount, hidden: hiddenCount, failures, failedScenarios: scenarios } = outcome;
138
130
 
139
131
  console.log(`${slug}: ${result}. Public ${publicCount.passed}/${publicCount.total}, hidden ${hiddenCount.passed}/${hiddenCount.total}.`);
140
132
  console.log(hashLine());
141
133
  const imported = readJson(join(specDir(slug), "imported.json"), null);
142
134
  if (imported !== null) console.log(`Imported from ${imported.from}: not held out (bin/import-archive.mjs).`);
143
135
  compileReport(judged);
144
- const scenarios = [...new Set(failures.map((item) => scenarioOf(item.id)).filter(Boolean))].sort();
145
136
  if (scenarios.length > 0) console.log(`Failing scenarios: ${scenarios.join(", ")}`);
146
- if (result === "incomplete")
147
- for (const test of testsOf(judged.public).filter((item) => !item.ok)) console.log(`public: ${test.id}`);
137
+ if (result === "incomplete") for (const id of outcome.publicFailing) console.log(`public: ${id}`);
148
138
  if (options.detail === true)
149
139
  for (const item of failures) console.log(`hidden: ${item.id}\n ${item.message.split("\n")[0]}`);
150
140
  writeRecord({
@@ -170,7 +160,18 @@ function judgeRepo() {
170
160
  const found = checkoutChanges(chain, checkout, ref);
171
161
  const scope = scopeOf(chain, found.items, found.read);
172
162
  const strict = options["strict-scope"] === true;
173
- const refused = [...scope.refused, ...(strict ? scope.outsideScope : [])];
163
+ // The rule (rules.mjs `judgement`) twice: on the scope alone, which says
164
+ // whether the tree is judged at all, then on the suites' results.
165
+ const facts = {
166
+ publicIds: calibration.publicIds,
167
+ kept: calibration.kept,
168
+ baseline,
169
+ refused: scope.refused,
170
+ changed: [...scope.allowed, ...scope.outsideScope],
171
+ allowedPaths: chain.allowedPaths ?? [],
172
+ strictScope: strict,
173
+ };
174
+ const { refused } = judgement(facts);
174
175
  const snapshot = workdir(`${slug}-snapshot`);
175
176
  let judged;
176
177
  try {
@@ -179,14 +180,8 @@ function judgeRepo() {
179
180
  } finally {
180
181
  discard(snapshot);
181
182
  }
182
- const publicCount = judged === null ? { passed: 0, total: calibration.publicIds.length } : count(judged.public, calibration.publicIds);
183
- const hiddenCount = judged === null ? { passed: 0, total: calibration.kept.length } : count(judged.hidden, calibration.kept);
184
- const regressions = judged === null ? [] : regressionsOf(baseline, judged.existing);
185
- const reported = new Map(judged === null ? [] : testsOf(judged.hidden).map((test) => [test.id, test]));
186
- const failures = judged === null ? [] : calibration.kept
187
- .filter((id) => reported.get(id)?.ok !== true)
188
- .map((id) => ({ id, message: reported.get(id)?.error?.message ?? "did not run" }));
189
- const result = verdict({ refused, publicCount, hiddenCount, regressions });
183
+ const outcome = judgement(judged === null ? facts : { ...facts, public: judged.public, hidden: judged.hidden, existing: judged.existing });
184
+ const { verdict: result, public: publicCount, hidden: hiddenCount, regressions, failures, failedScenarios: scenarios } = outcome;
190
185
  const handout = readJson(join(RUNS, slug, "handout", "handout.json"), null);
191
186
  let peeks = null;
192
187
  if (options.audit !== undefined) {
@@ -195,17 +190,15 @@ function judgeRepo() {
195
190
  }
196
191
 
197
192
  const where = ref === undefined ? `${found.root} (working tree)` : `${found.root} at ${ref}`;
198
- console.log(`${slug}: ${result}. Public ${publicCount.passed}/${publicCount.total}, hidden ${hiddenCount.passed}/${hiddenCount.total}, existing ${judged === null ? "-" : `${count(judged.existing, baseline.passing).passed}/${baseline.passing.length}`}; ${where}.`);
193
+ console.log(`${slug}: ${result}. Public ${publicCount.passed}/${publicCount.total}, hidden ${hiddenCount.passed}/${hiddenCount.total}, existing ${judged === null ? "-" : `${outcome.existing.passed}/${baseline.passing.length}`}; ${where}.`);
199
194
  console.log(hashLine());
200
195
  if (refused.length > 0) console.log(`Refused:\n${pathsOf(refused).map((line) => `- ${line}`).join("\n")}`);
201
196
  if (judged !== null) compileReport(judged);
202
197
  if (regressions.length > 0) console.log(`Regressions (passed on base plus stub, fail now):\n${regressions.map((id) => `- ${id}`).join("\n")}`);
203
198
  if (!strict && scope.outsideScope.length > 0) console.log(`Outside the allowed paths:\n${pathsOf(scope.outsideScope).map((line) => `- ${line}`).join("\n")}`);
204
199
  if (scope.ownTests.length > 0) console.log(`Own tests (not judged): ${scope.ownTests.map((item) => item.path).join(", ")}`);
205
- const scenarios = [...new Set(failures.map((item) => scenarioOf(item.id)).filter(Boolean))].sort();
206
200
  if (scenarios.length > 0) console.log(`Failing scenarios: ${scenarios.join(", ")}`);
207
- if (result === "incomplete")
208
- for (const test of testsOf(judged.public).filter((item) => !item.ok)) console.log(`public: ${test.id}`);
201
+ if (result === "incomplete") for (const id of outcome.publicFailing) console.log(`public: ${id}`);
209
202
  if (options.detail === true)
210
203
  for (const item of failures) console.log(`hidden: ${item.id}\n ${item.message.split("\n")[0]}`);
211
204
  if (peeks !== null)
@@ -215,7 +208,7 @@ function judgeRepo() {
215
208
  : `Audit of ${peeks.transcript}${peeks.since ? ` since the handout (${peeks.since})` : ""}: ${peeks.attempts.length === 0 ? "no attempt to open the hidden suite" : `${peeks.attempts.length} attempt(s):\n${peeks.attempts.map((item) => `- ${item.at ?? ""} ${item.tool}: ${item.match}: ${item.input}`).join("\n")}`}`,
216
209
  );
217
210
 
218
- const existingCount = judged === null ? null : count(judged.existing, baseline.passing);
211
+ const existingCount = judged === null ? null : outcome.existing;
219
212
  writeRecord({
220
213
  slug,
221
214
  verdict: result,
package/bin/lib.mjs CHANGED
@@ -52,6 +52,10 @@ import {
52
52
  which,
53
53
  } from "./platform.mjs";
54
54
  import { withBins } from "./runners/shared.mjs";
55
+ import { allowedIn, count, regressionsOf, scenarioOf, testsOf, verdict } from "./rules.mjs";
56
+
57
+ // The suite-result rules live in bin/rules.mjs (pure, shared with bin/api.mjs).
58
+ export { count, regressionsOf, scenarioOf, testsOf, verdict };
55
59
 
56
60
  /**
57
61
  * The package: bin/, prompts/, template/ and the bundled example spec. It
@@ -1296,12 +1300,6 @@ export function runSuite(dir, suite, chain = toolchainIn(dir)) {
1296
1300
  }
1297
1301
  }
1298
1302
 
1299
- /** Each test of a suite result, named `file :: test`. */
1300
- export const testsOf = (result) =>
1301
- result.files.flatMap((file) =>
1302
- file.tests.map((test) => ({ ...test, id: `${file.file} :: ${test.name}` })),
1303
- );
1304
-
1305
1303
  /**
1306
1304
  * A `src/` judged as grading judges it: copied onto a fresh template with
1307
1305
  * the public suite, then onto another with the hidden suite, so code that
@@ -1351,41 +1349,7 @@ export function baselineOf(result, stage) {
1351
1349
  };
1352
1350
  }
1353
1351
  /** Whether a repository path is one the implementer may change: in a folder of `allowedPaths` (ending in `/`) or one of its files. */
1354
- export const allowedPath = (chain, path) =>
1355
- (chain.allowedPaths ?? []).some((allowed) => (allowed.endsWith("/") ? path.startsWith(allowed) : path === allowed));
1356
- /** Tests the baseline passed that `result` (the existing suite after a change) does not. */
1357
- export function regressionsOf(baseline, result) {
1358
- const ok = new Set(testsOf(result).filter((test) => test.ok).map((test) => test.id));
1359
- return baseline.passing.filter((id) => !ok.has(id));
1360
- }
1361
-
1362
- /** How many of `ids` passed in a suite result; a test that never reported failed. */
1363
- export function count(result, ids) {
1364
- const ok = new Set(testsOf(result).filter((test) => test.ok && !test.skipped).map((test) => test.id));
1365
- return { passed: ids.filter((id) => ok.has(id)).length, total: ids.length };
1366
- }
1367
-
1368
- /**
1369
- * The spec scenario a test is keyed to, from the `[kind-n]` its name starts
1370
- * with (prompts/public-tests.md, prompts/hidden-tests.md), or null.
1371
- */
1372
- export const scenarioOf = (id) =>
1373
- /^\[([a-z]+-\d+)\] /.exec(id.split(" :: ").at(-1))?.[1] ?? null;
1374
-
1375
- /**
1376
- * The verdict: `refused` when the submission changed anything outside what
1377
- * the agent may write, `incomplete` when a public test fails, `regressed`
1378
- * when a test of the repository's existing suite that passed before the
1379
- * change fails after it (never on the bundled template, which has no
1380
- * existing suite), `passed` when every kept hidden test passes too,
1381
- * `overfit` when one does not.
1382
- */
1383
- export function verdict({ refused = [], publicCount, hiddenCount, regressions = [] }) {
1384
- if (refused.length > 0) return "refused";
1385
- if (publicCount.passed !== publicCount.total) return "incomplete";
1386
- if (regressions.length > 0) return "regressed";
1387
- return hiddenCount.total > 0 && hiddenCount.passed === hiddenCount.total ? "passed" : "overfit";
1388
- }
1352
+ export const allowedPath = (chain, path) => allowedIn(chain.allowedPaths, path);
1389
1353
 
1390
1354
  /**
1391
1355
  * The claude CLI, or null: CLAUDE_BIN, claude on PATH, the newest VS Code
package/bin/login.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // `duediligence login`: see account.mjs.
3
+ import { readFileSync } from "node:fs";
4
+ import { run } from "./account.mjs";
5
+
6
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
7
+ process.exitCode = await run("login", process.argv.slice(2), { client: "duediligence", version });
package/bin/logout.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // `duediligence logout`: see account.mjs.
3
+ import { readFileSync } from "node:fs";
4
+ import { run } from "./account.mjs";
5
+
6
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
7
+ process.exitCode = await run("logout", process.argv.slice(2), { client: "duediligence", version });
@@ -0,0 +1,175 @@
1
+ // The hosted platform (sandbox-factory, platform.lunox.work) as this CLI
2
+ // talks to it: the base URL, the shared credentials file, and one request
3
+ // function on `fetch`, so duediligence keeps no runtime dependency. Nothing
4
+ // here runs unless a platform command (`login`, `whoami`, `logout`) is
5
+ // called; every other command stays local. Not named platform.mjs, which is
6
+ // the host-OS layer.
7
+ //
8
+ // Credentials: `~/.lunox/credentials` (LUNOX_CREDENTIALS moves it), JSON,
9
+ // mode 600 in a 700 folder, written whole through a rename. carveout shares
10
+ // the file, so each CLI keeps its own entry under the platform's URL:
11
+ //
12
+ // { "https://platform.lunox.work": {
13
+ // "duediligence": { token, organizationId, organizationHandle, client, createdAt, expiresAt },
14
+ // "carveout": { ... } } }
15
+ //
16
+ // A token is minted for one client with that client's scopes, so the two
17
+ // CLIs never share one; writing an entry keeps every other.
18
+
19
+ import { randomBytes } from "node:crypto";
20
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { dirname, join } from "node:path";
23
+
24
+ export const DEFAULT_PLATFORM = "https://platform.lunox.work";
25
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
26
+
27
+ export class PlatformError extends Error {
28
+ constructor(message, { status, code } = {}) {
29
+ super(message);
30
+ this.name = "PlatformError";
31
+ this.status = status;
32
+ this.code = code;
33
+ }
34
+ }
35
+
36
+ /** `--platform`, else LUNOX_PLATFORM_URL, else production; no trailing slash. HTTPS, or plain HTTP to this machine only. */
37
+ export function platformUrl(given, env = process.env) {
38
+ const raw = given ?? env.LUNOX_PLATFORM_URL ?? DEFAULT_PLATFORM;
39
+ let url;
40
+ try {
41
+ url = new URL(raw);
42
+ } catch {
43
+ throw new PlatformError(`${raw} is not a URL.`);
44
+ }
45
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK.has(url.hostname))) {
46
+ throw new PlatformError(`${raw}: the platform must be https:// (plain http only on localhost).`);
47
+ }
48
+ url.search = "";
49
+ url.hash = "";
50
+ return url.href.replace(/\/+$/, "");
51
+ }
52
+
53
+ export function credentialsPath(env = process.env) {
54
+ return env.LUNOX_CREDENTIALS ?? join(homedir(), ".lunox", "credentials");
55
+ }
56
+
57
+ /** The whole file, `{}` when there is none. A file that is not a JSON object is refused, never overwritten. */
58
+ export function readCredentials(path) {
59
+ if (!existsSync(path)) return {};
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(readFileSync(path, "utf8"));
63
+ } catch (error) {
64
+ throw new PlatformError(`${path} is not valid JSON (${error.message}); fix or remove it.`);
65
+ }
66
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
67
+ throw new PlatformError(`${path} does not hold a JSON object; fix or remove it.`);
68
+ }
69
+ return parsed;
70
+ }
71
+
72
+ /** Writes the file whole: a 600 temp file beside it, renamed over it. Removes it when nothing is left. */
73
+ export function writeCredentials(path, all) {
74
+ const dir = dirname(path);
75
+ if (!existsSync(dir)) {
76
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
77
+ chmodSync(dir, 0o700);
78
+ }
79
+ if (Object.keys(all).length === 0) {
80
+ rmSync(path, { force: true });
81
+ return;
82
+ }
83
+ const temp = join(dir, `.credentials-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
84
+ try {
85
+ writeFileSync(temp, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600, flag: "wx" });
86
+ chmodSync(temp, 0o600);
87
+ renameSync(temp, path);
88
+ } catch (error) {
89
+ rmSync(temp, { force: true });
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ /** This client's entry for a platform, or undefined. */
95
+ export function credentialFor(path, base, client) {
96
+ const entry = readCredentials(path)[base]?.[client];
97
+ return entry && typeof entry.token === "string" ? entry : undefined;
98
+ }
99
+
100
+ /** Sets (or, with `entry` undefined, removes) this client's entry, keeping every other. */
101
+ export function saveCredential(path, base, client, entry) {
102
+ const all = readCredentials(path);
103
+ const forBase = { ...(all[base] ?? {}) };
104
+ if (entry === undefined) delete forBase[client];
105
+ else forBase[client] = entry;
106
+ if (Object.keys(forBase).length === 0) delete all[base];
107
+ else all[base] = forBase;
108
+ writeCredentials(path, all);
109
+ }
110
+
111
+ /** The minimum version a 426 names, wherever the body puts it. */
112
+ function minimumOf(body) {
113
+ const named = body?.minimumVersion ?? body?.minimum_version ?? body?.minimum;
114
+ if (typeof named === "string") return named;
115
+ return /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(body?.error ?? "")?.[0];
116
+ }
117
+
118
+ /**
119
+ * One call to the platform's API. Sends the token as a bearer, the client
120
+ * and its version as `User-Agent` and `Lunox-Client-Version`
121
+ * (`<client>/<version>`), and JSON. Answers the parsed body; throws a
122
+ * PlatformError whose message says what to do: 401 → sign in again, 426 →
123
+ * upgrade, otherwise the API's own `{ error, code }`. `session` marks the
124
+ * token as a device sign-in's session rather than a saved API token.
125
+ */
126
+ export async function request({ base, token, session = false, method = "GET", path, body, client, version, fetchImpl = fetch }) {
127
+ const headers = {
128
+ Accept: "application/json",
129
+ "User-Agent": `${client}/${version}`,
130
+ "Lunox-Client-Version": `${client}/${version}`,
131
+ };
132
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
133
+ if (body !== undefined) headers["Content-Type"] = "application/json";
134
+ let response;
135
+ try {
136
+ response = await fetchImpl(`${base}${path}`, {
137
+ method,
138
+ headers,
139
+ body: body === undefined ? undefined : JSON.stringify(body),
140
+ redirect: "error",
141
+ signal: AbortSignal.timeout(30_000),
142
+ });
143
+ } catch (error) {
144
+ throw new PlatformError(`Could not reach ${base}: ${error.cause?.message ?? error.message}`);
145
+ }
146
+ const text = await response.text();
147
+ let parsed;
148
+ try {
149
+ parsed = text === "" ? undefined : JSON.parse(text);
150
+ } catch {
151
+ parsed = undefined;
152
+ }
153
+ if (response.ok) return parsed ?? {};
154
+ const status = response.status;
155
+ const code = typeof parsed?.code === "string" ? parsed.code : typeof parsed?.error === "string" && !parsed.error.includes(" ") ? parsed.error : undefined;
156
+ if (status === 426) {
157
+ const minimum = minimumOf(parsed);
158
+ throw new PlatformError(
159
+ `${base} needs a newer ${client}${minimum ? `: upgrade to at least ${minimum}` : ""} (this is ${version}). npm i -g ${client}@latest`,
160
+ { status, code },
161
+ );
162
+ }
163
+ if (status === 401 && token !== undefined) {
164
+ const what = session ? "The browser sign-in" : "The saved sign-in";
165
+ throw new PlatformError(`${what} for ${base} is no longer valid (expired or revoked). Run \`${client} login\` again.`, {
166
+ status,
167
+ code,
168
+ });
169
+ }
170
+ const said = parsed?.error_description ?? parsed?.error ?? parsed?.message;
171
+ throw new PlatformError(
172
+ typeof said === "string" ? `${said}${code && code !== said ? ` (${code})` : ""}` : `${method} ${path}: HTTP ${status}`,
173
+ { status, code },
174
+ );
175
+ }