duediligence 0.3.0 → 0.4.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/README.md +58 -11
- package/bin/account.mjs +262 -0
- package/bin/api.mjs +73 -0
- package/bin/calibrate.mjs +11 -38
- package/bin/cli.mjs +5 -0
- package/bin/commitment.mjs +7 -50
- package/bin/hash.mjs +111 -0
- package/bin/judge.mjs +22 -29
- package/bin/lib.mjs +5 -41
- package/bin/login.mjs +7 -0
- package/bin/logout.mjs +7 -0
- package/bin/platform-client.mjs +175 -0
- package/bin/rules.mjs +178 -0
- package/bin/whoami.mjs +7 -0
- package/package.json +15 -4
- package/{SKILL.md → skills/duediligence/SKILL.md} +87 -53
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
|
-
|
|
132
|
-
const
|
|
133
|
-
const
|
|
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
|
-
|
|
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
|
|
183
|
-
const
|
|
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 ? "-" : `${
|
|
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 :
|
|
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
|
+
}
|