create-evda 0.1.10 → 0.1.11
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/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/bin/evda.mjs +7 -1
- package/create-evda.mjs +3 -8
- package/lib/adopt.mjs +43 -15
- package/lib/attested-host.mjs +25 -7
- package/lib/attested-wrap.mjs +147 -0
- package/lib/checkout-freshness.mjs +88 -0
- package/lib/circles.mjs +4 -2
- package/lib/day-writer.mjs +31 -4
- package/lib/doctor.mjs +28 -2
- package/lib/folders.mjs +2 -1
- package/lib/gather.mjs +4 -3
- package/lib/keep-hook.mjs +23 -4
- package/lib/person-folders.mjs +2 -1
- package/lib/presets.mjs +2 -1
- package/lib/prompt-hook.mjs +115 -4
- package/lib/readme.mjs +145 -0
- package/lib/record-walk.mjs +9 -0
- package/lib/tdx-attestation.mjs +28 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ Plain-English notes on how Iris behaves differently after each version. Not a co
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
On a machine that does not write the day (`EVDA_DAY_WRITER` set), the Claude Code prompt hook no
|
|
8
|
+
longer appends to the local day file. It sends each message to the writer through the command in
|
|
9
|
+
`EVDA_DAY_WRITER_SEND` (evda_write's arguments as JSON on stdin), and a message the writer cannot take
|
|
10
|
+
waits in this machine's own outbox (`EVDA_PROMPT_OUTBOX`, default `~/.evda/outbox`) and goes with the
|
|
11
|
+
next one, filed once. The keep-record hook's reminder there names evda_write. The day-writer guard
|
|
12
|
+
now sees a day file named through a variable the same command set, `$HOME` or `~`. `evda doctor`
|
|
13
|
+
warns when evda.json says `"filing": "everything"` and no prompt hook is installed (HQ-2817).
|
|
14
|
+
|
|
15
|
+
An EVÐA made with `evda adopt` now has the steps for turning training off. Iris gives those steps
|
|
16
|
+
only from the "Turning training off" section of `README.md`, and an adopted folder kept its own
|
|
17
|
+
README, which never had that section, or the ones on what each AI company says and running a model
|
|
18
|
+
on your own computer. The adopt now adds those three sections after your README as one block marked
|
|
19
|
+
as EVÐA's, leaving your text exactly as it was, or writes the whole README when you had none.
|
|
20
|
+
`evda adopt --revert` takes the block back out, and `evda upgrade` keeps it current. `evda doctor`
|
|
21
|
+
fails a record whose `AGENTS.md` sends Iris to a `README.md` section that is not there, and says to
|
|
22
|
+
run `evda upgrade`, which adds it.
|
|
23
|
+
|
|
24
|
+
`evda doctor` run from a git checkout now says, just before its verdict, when that checkout is behind
|
|
25
|
+
origin/main or is not main, so a pass from an old branch no longer reads as main's pass. It fetches
|
|
26
|
+
main first with a five-second limit; offline, with no origin, or from an installed copy it still runs
|
|
27
|
+
every check and says it could not compare. On current main it says nothing extra, and the exit code is
|
|
28
|
+
unchanged. `EVDA_DOCTOR_FETCH=0` skips the fetch.
|
|
29
|
+
|
|
7
30
|
When a filing names pages your EVÐA already has (a person, a project, a topic) or open tickets on your
|
|
8
31
|
board, and the conversation adds to none of them, Iris is now asked once, naming them, whether what you said adds to one; adding
|
|
9
32
|
nothing is a fine answer. In Claude Code the record's Stop hook asks before the session stops, and in
|
package/README.md
CHANGED
|
@@ -178,7 +178,7 @@ Writes:
|
|
|
178
178
|
|
|
179
179
|
The training instructions in the scaffolded README and in setup come from one table, `lib/setup.mjs`, with a link to each provider's page.
|
|
180
180
|
|
|
181
|
-
The tree above is what the scaffolder creates and not a requirement. `evda.json` carries a `mapping` from roles to paths, and every tool reads roles. An existing folder of notes becomes an EVÐA by adding `evda.json` with a mapping that names where things already are, and nothing moves. `evda adopt <dir>` writes that mapping: an Iris Vault (`00_Inbox` through `06_Metadata`) maps without a question, any other folder is placed by its name or named back to the owner to answer with `--map <folder>=<role>`, and it adds `AGENTS.md`, `.evda/`, the Claude Code hooks
|
|
181
|
+
The tree above is what the scaffolder creates and not a requirement. `evda.json` carries a `mapping` from roles to paths, and every tool reads roles. An existing folder of notes becomes an EVÐA by adding `evda.json` with a mapping that names where things already are, and nothing moves. `evda adopt <dir>` writes that mapping: an Iris Vault (`00_Inbox` through `06_Metadata`) maps without a question, any other folder is placed by its name or named back to the owner to answer with `--map <folder>=<role>`, and it adds `AGENTS.md`, `.evda/`, the Claude Code hooks, one `@AGENTS.md` line at the end of the owner's `CLAUDE.md`, and the `README.md` sections `AGENTS.md` sends the agent to (turning training off, what each AI company says, running a model locally) as one marked block after the owner's own `README.md`, which `evda upgrade` keeps current. Every other file stays byte for byte, and `evda adopt --revert <manifest>` puts the folder back exactly as it was. A chat app runs it through `evda_adopt`, which writes nothing without `confirmed: true`.
|
|
182
182
|
|
|
183
183
|
### Four roles, and records inside records
|
|
184
184
|
|
package/bin/evda.mjs
CHANGED
|
@@ -729,7 +729,8 @@ async function main(argv) {
|
|
|
729
729
|
|
|
730
730
|
case "doctor": {
|
|
731
731
|
const { dir, via } = pickDir(args);
|
|
732
|
-
|
|
732
|
+
// The package root this doctor runs from, so it can say whether that is current main (HQ-2736).
|
|
733
|
+
return doctor(dir, { via, checkout: fileURLToPath(new URL("..", import.meta.url)) }).exit;
|
|
733
734
|
}
|
|
734
735
|
|
|
735
736
|
case "guard":
|
|
@@ -946,6 +947,11 @@ async function main(argv) {
|
|
|
946
947
|
const parts = splitFence(text);
|
|
947
948
|
if (!parts) throw new EvdaError(`${a.rel} has no evda:generated fence. The owner has taken the whole file; there is nothing the installer owns to replace, and nothing was changed.`);
|
|
948
949
|
const shipped = shippedGenerated();
|
|
950
|
+
// HQ-2812: the README.md sections the operating layer sends the agent to, kept as EVÐA's
|
|
951
|
+
// marked block (added when one is missing, refreshed when it is there); the owner's text is
|
|
952
|
+
// never touched.
|
|
953
|
+
const { ensureReadme, readmeLine } = await import("../lib/readme.mjs");
|
|
954
|
+
console.log(`upgrade: ${readmeLine(ensureReadme(dir, { agents: replaceFence(text, shipped) }))}`);
|
|
949
955
|
if (parts.generated === shipped) {
|
|
950
956
|
stampInstalledVersion(dir, packageVersion());
|
|
951
957
|
console.log("upgrade: the generated block already matches what this tool ships. Nothing outside it was read.");
|
package/create-evda.mjs
CHANGED
|
@@ -40,8 +40,9 @@ import { applyPreset, listPresets, planLines, presetIds, NONE } from "./lib/pres
|
|
|
40
40
|
import { FILING_MODES } from "./lib/day-file.mjs";
|
|
41
41
|
import { installPromptHook } from "./lib/hooks.mjs";
|
|
42
42
|
import { defaultParts } from "./lib/circle-plan.mjs";
|
|
43
|
-
import { PLATFORMS, PLATFORM_KEYS, PARTS_LINE, LOADERS, DESKTOP_FILESYSTEM, DICTATION, DEFAULT_AGENT_NAME, platformKey, fillOwnerSection, aiLine, interview,
|
|
43
|
+
import { PLATFORMS, PLATFORM_KEYS, PARTS_LINE, LOADERS, DESKTOP_FILESYSTEM, DICTATION, DEFAULT_AGENT_NAME, platformKey, fillOwnerSection, aiLine, interview, readerFor, INTRO } from "./lib/setup.mjs";
|
|
44
44
|
import { describeReaders, parseReaders } from "./lib/readers.mjs";
|
|
45
|
+
import { renderReadme } from "./lib/readme.mjs";
|
|
45
46
|
|
|
46
47
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
47
48
|
const TEMPLATES = join(HERE, "templates");
|
|
@@ -188,7 +189,7 @@ export function scaffold(opts) {
|
|
|
188
189
|
// agent asks at first connect.
|
|
189
190
|
if (opts.filing) manifest.filing = opts.filing;
|
|
190
191
|
writeIfAbsent(join(dir, "evda.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
191
|
-
writeIfAbsent(join(dir, "README.md"),
|
|
192
|
+
writeIfAbsent(join(dir, "README.md"), renderReadme());
|
|
192
193
|
// The owner section of AGENTS.md holds the setup answers in the owner's words. The AI line is
|
|
193
194
|
// written only when training off was confirmed; empty, it is what makes the agent ask.
|
|
194
195
|
const answers = {
|
|
@@ -231,12 +232,6 @@ export function scaffold(opts) {
|
|
|
231
232
|
return { dir, manifest, pointer, guard, hook, promptHook };
|
|
232
233
|
}
|
|
233
234
|
|
|
234
|
-
// The local-model doc as a README section: its title dropped and its headings one level down.
|
|
235
|
-
function localModelSection() {
|
|
236
|
-
const text = readFileSync(join(HERE, "docs", "local-model.md"), "utf8");
|
|
237
|
-
return text.split("\n").slice(1).join("\n").replace(/^## /gm, "### ").trim();
|
|
238
|
-
}
|
|
239
|
-
|
|
240
235
|
// Day one. The record's first daily file records the one true thing that has happened to it: it
|
|
241
236
|
// was created. It says so in three lines and says where the owner's words go, then gets out of the
|
|
242
237
|
// way. It is not an example note, it asks nothing, and it schedules nothing; every later file
|
package/lib/adopt.mjs
CHANGED
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
// does not is never guessed at: it is named back, and the owner answers with `--map`.
|
|
10
10
|
// - What is written: evda.json, AGENTS.md (the one create-evda ships), the GEMINI.md loader when
|
|
11
11
|
// absent, the `@AGENTS.md` line appended to the owner's CLAUDE.md (or the loader when there is
|
|
12
|
-
// none), folder manifests in the record folders, .evda/,
|
|
13
|
-
// .claude/settings.json
|
|
12
|
+
// none), folder manifests in the record folders, .evda/, the Claude Code hooks in
|
|
13
|
+
// .claude/settings.json, and the README.md sections AGENTS.md sends the agent to (training off,
|
|
14
|
+
// what each AI company says, a local model: HQ-2812), as one marked block after the owner's
|
|
15
|
+
// README, or the whole README create-evda writes when there is none. Every other file that was
|
|
16
|
+
// there stays byte for byte.
|
|
14
17
|
// - Every write is listed in a reversal manifest outside the folder. `revertAdopt` restores the
|
|
15
18
|
// folder byte for byte, folder times included, and refuses when a file the adopt wrote or changed
|
|
16
|
-
// was edited since, unless forced.
|
|
17
|
-
// (only the added line is taken out)
|
|
19
|
+
// was edited since, unless forced. Edits that never block it: the owner's own edits to CLAUDE.md
|
|
20
|
+
// or README.md (only the added line, or the added block, is taken out), `evda upgrade` replacing
|
|
21
|
+
// AGENTS.md's generated block, and `evda upgrade` refreshing the README block.
|
|
18
22
|
//
|
|
19
23
|
// EVDA_ADOPT_FALSIFY=<mode,...>, for the tests: guess-unplaced places an unknown folder in
|
|
20
24
|
// knowledge instead of asking; claude-rewrite writes CLAUDE.md as the loader instead of appending;
|
|
@@ -40,6 +44,8 @@ import { readPointer, setPointer, pointerPath } from "./locate.mjs";
|
|
|
40
44
|
import { stampInstalledVersion } from "./update-check.mjs";
|
|
41
45
|
import { doctor } from "./doctor.mjs";
|
|
42
46
|
import { everyChatSetupLines } from "./every-chat.mjs";
|
|
47
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
48
|
+
import { ensureReadme, withoutBlock, README_REL } from "./readme.mjs";
|
|
43
49
|
|
|
44
50
|
export const ADOPT_VERSION = 1;
|
|
45
51
|
export const ADOPT_FALSIFY_MODES = ["guess-unplaced", "claude-rewrite", "revert-ignores-edits", "revert-not-exact"];
|
|
@@ -56,13 +62,13 @@ export const CLAUDE_LINE = "@AGENTS.md";
|
|
|
56
62
|
// The starter kit's seven folders. Five or more of them at the top is an Iris Vault.
|
|
57
63
|
export const IRIS_VAULT_DIRS = ["00_Inbox", "01_Projects", "02_Areas", "03_Resources", "04_Archive", "05_Attachments", "06_Metadata"];
|
|
58
64
|
// Folders no one keeps notes in: tooling, never asked about.
|
|
59
|
-
const IGNORED = new Set(["
|
|
65
|
+
const IGNORED = new Set(["dist", "build"]); // plus outsideRecord: node_modules, any *.nosync
|
|
60
66
|
// What the adopt writes under these is derived state, rebuilt by the tooling, so an edit there never
|
|
61
67
|
// blocks a revert.
|
|
62
68
|
const DERIVED = [".evda/"];
|
|
63
69
|
// The files whose bytes are kept before the adopt runs, because the adopt (or an installer it calls)
|
|
64
70
|
// may change them. Anything else that changes is a bug, and the adopt says so.
|
|
65
|
-
const MAY_CHANGE = ["CLAUDE.md", "GEMINI.md", "AGENTS.md", "evda.json", ".claude/settings.json"];
|
|
71
|
+
const MAY_CHANGE = ["CLAUDE.md", "GEMINI.md", "AGENTS.md", "evda.json", ".claude/settings.json", README_REL];
|
|
66
72
|
|
|
67
73
|
// The Iris Vault mapping, as the reference instance (the starter kit's author's own vault) keeps it.
|
|
68
74
|
// `make: true` is a folder the role needs that the starter kit does not ship.
|
|
@@ -119,7 +125,7 @@ export function parseMapArgs(values) {
|
|
|
119
125
|
export function proposeAdoption(dirIn, { map = {} } = {}) {
|
|
120
126
|
const dir = resolve(dirIn);
|
|
121
127
|
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw refuse(`${dir} is not a folder.`, "NOT_A_FOLDER");
|
|
122
|
-
const top = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".") && !IGNORED.has(d.name)).map((d) => d.name).sort();
|
|
128
|
+
const top = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".") && !IGNORED.has(d.name) && !outsideRecord(d.name)).map((d) => d.name).sort();
|
|
123
129
|
const iris = IRIS_VAULT_DIRS.filter((d) => top.includes(d)).length >= 5;
|
|
124
130
|
const entries = [];
|
|
125
131
|
const problems = [];
|
|
@@ -181,7 +187,7 @@ export function proposalLines(p) {
|
|
|
181
187
|
function walk(root, { files, dirs }, rel = "") {
|
|
182
188
|
for (const d of readdirSync(join(root, rel), { withFileTypes: true })) {
|
|
183
189
|
const r = rel ? `${rel}/${d.name}` : d.name;
|
|
184
|
-
if (d.name === ".git" || d.name
|
|
190
|
+
if (d.name === ".git" || outsideRecord(d.name)) continue;
|
|
185
191
|
const abs = join(root, r);
|
|
186
192
|
const st = lstatSync(abs);
|
|
187
193
|
if (st.isDirectory()) { dirs.set(r, { atimeMs: st.atimeMs, mtimeMs: st.mtimeMs }); walk(root, { files, dirs }, r); }
|
|
@@ -233,8 +239,12 @@ export function adopt(dirIn, { map = {}, filing = null, owner = "", agentName =
|
|
|
233
239
|
if (existsSync(join(dir, "evda.json"))) throw refuse(`${dir} already holds evda.json, so it is already an EVDA. Nothing was written. \`evda update\` keeps it current.`, "ALREADY_ADOPTED");
|
|
234
240
|
const p = proposeAdoption(dir, { map });
|
|
235
241
|
const questions = adoptQuestions(p);
|
|
242
|
+
const agentsText = fillOwnerSection(render("AGENTS.md"), { agentName, owner, purpose: "", voice: "", ai: "" });
|
|
236
243
|
const result = { dir, dry, proposal: p, questions, lines: proposalLines(p) };
|
|
237
|
-
if (dry)
|
|
244
|
+
if (dry) {
|
|
245
|
+
const rd = ensureReadme(dir, { agents: agentsText, dry: true });
|
|
246
|
+
return { ...result, readme: rd, lines: [...result.lines, ` ${dryReadmeLine(rd)}`] };
|
|
247
|
+
}
|
|
238
248
|
if (questions.length) throw new EvdaError(["REFUSED: some folders need your answer before anything is written. Nothing was written.", ...questions.map((q) => ` ${q}`)].join("\n"), { code: "NEEDS_ANSWERS", exit: 2 });
|
|
239
249
|
for (const f of ["AGENTS.md"]) if (existsSync(join(dir, f))) throw refuse(`${f} is already in ${dir}, and the adopt writes its own; it never overwrites a file of yours. Rename it, or map it by hand in evda.json. Nothing was written.`, "AGENTS_EXISTS");
|
|
240
250
|
|
|
@@ -267,7 +277,10 @@ export function adopt(dirIn, { map = {}, filing = null, owner = "", agentName =
|
|
|
267
277
|
if (filing) manifest.filing = filing;
|
|
268
278
|
writeNew("evda.json", JSON.stringify(manifest, null, 2) + "\n");
|
|
269
279
|
// 3. The operating layer, the one create-evda ships, with the owner section filled from the answers.
|
|
270
|
-
writeNew("AGENTS.md",
|
|
280
|
+
writeNew("AGENTS.md", agentsText);
|
|
281
|
+
// 3b. The README sections AGENTS.md cites (HQ-2812): appended as one marked block, or the whole
|
|
282
|
+
// README when there is none. The owner's text is never touched.
|
|
283
|
+
const readme = ensureReadme(dir, { agents: agentsText });
|
|
271
284
|
// 4. Loaders. The owner's CLAUDE.md is theirs: one line is added at its end and nothing else changes.
|
|
272
285
|
const claude = join(dir, "CLAUDE.md");
|
|
273
286
|
let claudeAdded = null;
|
|
@@ -313,7 +326,7 @@ export function adopt(dirIn, { map = {}, filing = null, owner = "", agentName =
|
|
|
313
326
|
const kept = before.kept.get(rel);
|
|
314
327
|
if (kept ? kept.equals(buf) : was.size === st.size && was.mtimeMs === st.mtimeMs) continue;
|
|
315
328
|
if (!kept) { unexpected.push(rel); continue; }
|
|
316
|
-
modified.push({ path: rel, before: kept.toString("base64"), mode: was.mode, atimeMs: was.atimeMs, mtimeMs: was.mtimeMs, sha256: sha256(buf), ...(rel === "CLAUDE.md" && claudeAdded ? { added: claudeAdded } : {}) });
|
|
329
|
+
modified.push({ path: rel, before: kept.toString("base64"), mode: was.mode, atimeMs: was.atimeMs, mtimeMs: was.mtimeMs, sha256: sha256(buf), ...(rel === "CLAUDE.md" && claudeAdded ? { added: claudeAdded } : {}), ...(rel === README_REL && readme.added ? { added: readme.added, block: true } : {}) });
|
|
317
330
|
}
|
|
318
331
|
const dirs = [...after.dirs.keys()].filter((d) => !before.dirs.has(d));
|
|
319
332
|
const dirTimes = [...before.dirs].filter(([d]) => existsSync(join(dir, d))).map(([d, t]) => ({ path: d, ...t }));
|
|
@@ -337,7 +350,7 @@ export function adopt(dirIn, { map = {}, filing = null, owner = "", agentName =
|
|
|
337
350
|
`adopted ${dir} in place. Nothing moved.`,
|
|
338
351
|
...proposalLines(p).slice(1),
|
|
339
352
|
` wrote ${created.length} new file(s): ${created.map((c) => c.path).join(", ")}`,
|
|
340
|
-
` changed ${modified.length} file(s) of yours: ${modified.map((m) => m.path + (m.added ? " (one line added at the end)" : "")).join(", ") || "none"}`,
|
|
353
|
+
` changed ${modified.length} file(s) of yours: ${modified.map((m) => m.path + (m.block ? ` (EVÐA's block added at the end: ${readme.citations.map((c) => `"${c}"`).join(", ")})` : m.added ? " (one line added at the end)" : "")).join(", ") || "none"}`,
|
|
341
354
|
...(dirs.length ? [` made ${dirs.length} folder(s): ${dirs.join(", ")}`] : []),
|
|
342
355
|
...hookLines.map((l) => ` ${l}`),
|
|
343
356
|
` filing: ${filing === "everything" ? "everything you say is filed automatically" : filing === "asked" ? "only when you ask" : "not answered yet; your agent asks the first time you connect"}`,
|
|
@@ -360,6 +373,14 @@ export function connectLines(dir, filing) {
|
|
|
360
373
|
return out;
|
|
361
374
|
}
|
|
362
375
|
|
|
376
|
+
// What a dry run says about README.md, naming the sections it would add.
|
|
377
|
+
function dryReadmeLine(r) {
|
|
378
|
+
const names = r.citations.map((c) => `"${c}"`).join(", ");
|
|
379
|
+
if (r.action === "written") return `${README_REL}: would be written, with ${names}`;
|
|
380
|
+
if (r.action === "appended") return `${README_REL}: EVÐA's block would be added at the end, with ${names}; your text above it stays as it is`;
|
|
381
|
+
return `${README_REL}: already has ${names}; left alone`;
|
|
382
|
+
}
|
|
383
|
+
|
|
363
384
|
// ---- revert ----
|
|
364
385
|
|
|
365
386
|
export function loadAdoptManifest(path) {
|
|
@@ -406,7 +427,14 @@ export function revertAdopt(path, { force = false, now = new Date(), env = proce
|
|
|
406
427
|
for (const f of m.modified) {
|
|
407
428
|
const a = join(root, f.path);
|
|
408
429
|
if (hashOf(a) === f.sha256) continue;
|
|
409
|
-
if (f.
|
|
430
|
+
if (f.block && existsSync(a)) {
|
|
431
|
+
// The README block: refreshed by `evda upgrade` and nothing else, the bytes before come back
|
|
432
|
+
// whole; edited around by the owner, their edits stay and only the block goes.
|
|
433
|
+
const rest = withoutBlock(readFileSync(a, "utf8"), f.added);
|
|
434
|
+
if (rest !== null && Buffer.from(rest).equals(Buffer.from(f.before, "base64"))) continue;
|
|
435
|
+
if (rest !== null) { ownerEdits.push(f.path); continue; }
|
|
436
|
+
}
|
|
437
|
+
if (f.added && !f.block && existsSync(a) && withoutLine(readFileSync(a, "utf8"), f.added) !== null) { ownerEdits.push(f.path); continue; }
|
|
410
438
|
edited.push(f.path);
|
|
411
439
|
}
|
|
412
440
|
if (edited.length && !force && !falsified("revert-ignores-edits")) {
|
|
@@ -421,7 +449,7 @@ export function revertAdopt(path, { force = false, now = new Date(), env = proce
|
|
|
421
449
|
const st = existsSync(a) ? statSync(a) : null;
|
|
422
450
|
if (ownerEdits.includes(f.path) && !force) {
|
|
423
451
|
// Their edits stay: only the line the adopt added is taken out.
|
|
424
|
-
writeFileSync(a, withoutLine(readFileSync(a, "utf8"), f.added));
|
|
452
|
+
writeFileSync(a, f.block ? withoutBlock(readFileSync(a, "utf8"), f.added) : withoutLine(readFileSync(a, "utf8"), f.added));
|
|
425
453
|
if (st) utimesSync(a, st.atimeMs / 1000, st.mtimeMs / 1000);
|
|
426
454
|
} else {
|
|
427
455
|
mkdirSync(dirname(a), { recursive: true });
|
|
@@ -460,7 +488,7 @@ export function revertAdopt(path, { force = false, now = new Date(), env = proce
|
|
|
460
488
|
export function revertLines(r) {
|
|
461
489
|
const out = [`Reverted the adopt of ${r.target}.`, ` removed ${r.removed} file(s) the adopt wrote`, ` restored ${r.restored} file(s) of yours to their bytes before it`];
|
|
462
490
|
if (r.derived) out.push(` removed .evda/, which the adopt made, with ${r.derived} file(s) the tooling wrote into it since`);
|
|
463
|
-
for (const f of r.ownerEdits) out.push(` kept your edits to ${f}; only the ${CLAUDE_LINE} line the adopt added was taken out`);
|
|
491
|
+
for (const f of r.ownerEdits) out.push(` kept your edits to ${f}; only the ${f === README_REL ? "EVÐA block" : `${CLAUDE_LINE} line`} the adopt added was taken out`);
|
|
464
492
|
if (r.forced) out.push(` forced ${r.edited.length} file(s) edited since the adopt were reverted anyway: ${r.edited.join(", ")}`);
|
|
465
493
|
for (const d of r.kept) out.push(` kept ${d}/, which holds files the adopt did not write`);
|
|
466
494
|
if (r.gone.length) out.push(` gone ${r.gone.length} file(s) were already removed`);
|
package/lib/attested-host.mjs
CHANGED
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
// The protocol, version 1. Over TLS to the host, on one connection:
|
|
10
10
|
// client -> host POST /.well-known/evda-attestation {"v":1,"nonce":"<base64, 32 bytes>"}
|
|
11
11
|
// host -> client {"v":1,"platform":"tdx","quote":"<base64>","collateral":{tcbInfo,
|
|
12
|
-
// tcbInfoIssuerChain,qeIdentity,qeIdentityIssuerChain}
|
|
12
|
+
// tcbInfoIssuerChain,qeIdentity,qeIdentityIssuerChain},
|
|
13
|
+
// "wrapKey":"<base64, 32 bytes, optional>"}
|
|
13
14
|
// The VM puts reportDataFor(nonce, its TLS public key) in the quote's REPORTDATA. The client takes the
|
|
14
15
|
// TLS key from the connection it is holding, never from the response body, so a host cannot name a key
|
|
15
16
|
// other than the one it is actually serving with. Delivery then goes over a connection pinned to that
|
|
16
17
|
// key (SHA-256 of its SPKI DER); a connection presenting any other key is refused before anything is
|
|
17
18
|
// written to it.
|
|
18
19
|
//
|
|
20
|
+
// A host that will receive circle keys also names `wrapKey`, the X25519 public key it made inside the
|
|
21
|
+
// VM, and binds it into REPORTDATA beside the nonce and TLS key (reportDataFor's second form). The
|
|
22
|
+
// client has to take that key from the body, so the binding is what makes it trustworthy: a key the
|
|
23
|
+
// quote does not bind is refused as NOT_BOUND. lib/attested-wrap.mjs wraps circle keys to it.
|
|
24
|
+
//
|
|
19
25
|
// The pinned measurement is the client's own configuration, never something a host supplies. With no
|
|
20
26
|
// pinned measurement the gate refuses: an unconfigured client is not a client that trusts everyone.
|
|
21
27
|
//
|
|
@@ -45,6 +51,8 @@ export const REFUSAL = Object.freeze({
|
|
|
45
51
|
NOT_PROVEN: "not-proven",
|
|
46
52
|
WRONG_CONNECTION: "wrong-connection",
|
|
47
53
|
NOT_DELIVERED: "not-delivered",
|
|
54
|
+
NO_WRAP_KEY: "no-wrap-key",
|
|
55
|
+
NOTHING_TO_WRAP: "nothing-to-wrap",
|
|
48
56
|
});
|
|
49
57
|
|
|
50
58
|
// What a person reads. Each says that nothing was sent and why, in words that need no TDX.
|
|
@@ -59,12 +67,14 @@ const SAID = {
|
|
|
59
67
|
[REFUSAL.NOT_PROVEN]: "the server could not prove it is a locked, up to date part of the chip",
|
|
60
68
|
[REFUSAL.WRONG_CONNECTION]: "the connection is not the one the server proved, so someone else could be in the middle",
|
|
61
69
|
[REFUSAL.NOT_DELIVERED]: "the server was proven, and sending to it then failed",
|
|
70
|
+
[REFUSAL.NO_WRAP_KEY]: "the server did not prove it made a key of its own to receive your keys",
|
|
71
|
+
[REFUSAL.NOTHING_TO_WRAP]: "no keys were named to send",
|
|
62
72
|
};
|
|
63
73
|
|
|
64
74
|
const sha256Hex = (b) => createHash("sha256").update(b).digest("hex");
|
|
65
75
|
|
|
66
76
|
/** A refusal: nothing was sent. `reason` is the detail for a log, `message` the sentence for a person. */
|
|
67
|
-
function refuse(code, reason) {
|
|
77
|
+
export function refuse(code, reason) {
|
|
68
78
|
return { ok: false, sent: false, code, reason, message: `Nothing was sent: ${SAID[code]}.` };
|
|
69
79
|
}
|
|
70
80
|
|
|
@@ -122,7 +132,13 @@ export function parseAttestationResponse(body) {
|
|
|
122
132
|
if (r.platform !== "tdx") return { error: `the response is for platform ${JSON.stringify(r.platform ?? null)}, not tdx` };
|
|
123
133
|
if (typeof r.quote !== "string" || r.quote.length === 0) return { error: "the response carries no quote", missing: true };
|
|
124
134
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(r.quote)) return { error: "the quote is not base64" };
|
|
125
|
-
|
|
135
|
+
let wrapKey = null;
|
|
136
|
+
if (r.wrapKey != null) {
|
|
137
|
+
if (typeof r.wrapKey !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(r.wrapKey)) return { error: "the wrap key is not base64" };
|
|
138
|
+
wrapKey = Buffer.from(r.wrapKey, "base64");
|
|
139
|
+
if (wrapKey.length !== 32) return { error: `the wrap key is ${wrapKey.length} bytes, not 32` };
|
|
140
|
+
}
|
|
141
|
+
return { quote: Buffer.from(r.quote, "base64"), collateral: r.collateral ?? null, wrapKey };
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
// The verifier's reasons, sorted into what a person needs to know. The tests hold one case per code,
|
|
@@ -173,26 +189,28 @@ export function createAttestedHostGate({ policy, nonces = createNonceBook(), clo
|
|
|
173
189
|
if (!answer.tlsPublicKey) return refuse(REFUSAL.WRONG_CONNECTION, "the transport did not report the TLS key of the connection the quote came over");
|
|
174
190
|
|
|
175
191
|
const v = verifyTdxAttestation({
|
|
176
|
-
quote: parsed.quote, nonce, nonces, tlsPublicKey: answer.tlsPublicKey,
|
|
192
|
+
quote: parsed.quote, nonce, nonces, tlsPublicKey: answer.tlsPublicKey, wrapPublicKey: parsed.wrapKey ?? undefined,
|
|
177
193
|
expected: policy.measurements, minTcb: policy.minTcb, collateral: parsed.collateral, now: clock(), trust,
|
|
178
194
|
});
|
|
179
195
|
if (!v.ok) return refuse(classify(v.reason), v.reason);
|
|
180
|
-
return session({ pin: v.tlsPublicKeySha256, measurements: v.measurements, tcb: v.tcb });
|
|
196
|
+
return session({ pin: v.tlsPublicKeySha256, measurements: v.measurements, tcb: v.tcb, wrapKey: v.wrapPublicKey ?? null });
|
|
181
197
|
},
|
|
182
198
|
};
|
|
183
199
|
}
|
|
184
200
|
|
|
185
|
-
function session({ pin, measurements, tcb }) {
|
|
201
|
+
function session({ pin, measurements, tcb, wrapKey }) {
|
|
186
202
|
return {
|
|
187
203
|
ok: true,
|
|
188
204
|
pin,
|
|
189
205
|
measurements,
|
|
190
206
|
tcb,
|
|
207
|
+
// The VM's key-wrapping key, only when the quote bound it; null when the host named none.
|
|
208
|
+
wrapKey,
|
|
191
209
|
async send(payload, { deliver } = {}) {
|
|
192
210
|
if (typeof deliver !== "function") return refuse(REFUSAL.NOT_DELIVERED, "no delivery transport was given");
|
|
193
211
|
let bytes;
|
|
194
212
|
try {
|
|
195
|
-
bytes = typeof payload === "function" ? await payload({ pin, measurements }) : payload;
|
|
213
|
+
bytes = typeof payload === "function" ? await payload({ pin, measurements, wrapKey }) : payload;
|
|
196
214
|
} catch (err) {
|
|
197
215
|
return refuse(REFUSAL.NOT_DELIVERED, `the payload could not be prepared: ${err?.message ?? err}`);
|
|
198
216
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Circle keys go to a hosted EVÐA only sealed to a key born inside the attested VM. (HQ-2083, Spec 08
|
|
2
|
+
// section 8: the VM holds a person's circle keys and EVÐA's operators cannot read them.)
|
|
3
|
+
//
|
|
4
|
+
// The VM side, createEnclaveKey(): the VM makes an X25519 key pair when it starts, keeps the private
|
|
5
|
+
// half in this closure (inside TDX-encrypted memory on a c3 VM), and puts the public half into every
|
|
6
|
+
// quote's REPORTDATA beside the nonce and TLS key (tdx-attestation.mjs reportDataFor, second form). It
|
|
7
|
+
// names the public half as `wrapKey` in its attestation answer (lib/attested-host.mjs).
|
|
8
|
+
//
|
|
9
|
+
// The client side, wrapCircleKeysTo(session, ...): `session` is what gate.open returned. Only a session
|
|
10
|
+
// whose quote verified under the pinned measurement AND bound the wrap key gets anything; the client's
|
|
11
|
+
// key holder is not asked to seal a single circle key before that. The holder is the HostCrypto shape
|
|
12
|
+
// every EVÐA device already has (`sealCircleKeyTo(circleId, epoch, publicKey)`, contracts/src/host.ts),
|
|
13
|
+
// so the raw scalar never passes through this file on a real device.
|
|
14
|
+
//
|
|
15
|
+
// The envelope is the circle key delivery wire (xania/seal/v1, the same bytes `sealCircleKeyTo` makes
|
|
16
|
+
// and `importCircleKey` opens, pinned by ekati's circle-seal vectors):
|
|
17
|
+
// [version 0x01][ephemeral X25519 public:32][ChaCha20-Poly1305 ciphertext][tag:16]
|
|
18
|
+
// key||nonce = HKDF-SHA256(ECDH, salt empty, info "xania/seal/v1" || ephemeral public || recipient public, 44)
|
|
19
|
+
// AAD = the 33-byte header.
|
|
20
|
+
// sealToKey and openSealed are the dependency-free copies person-record and the VM use; the test opens
|
|
21
|
+
// the frozen vectors with them, so a drift from the wire fails there.
|
|
22
|
+
//
|
|
23
|
+
// The payload the client delivers, version 1:
|
|
24
|
+
// { "v": 1, "wrapKey": "<base64>", "keys": [{ "circleId", "epoch", "sealedKey": "<base64>" }] }
|
|
25
|
+
//
|
|
26
|
+
// Not here yet: the Swift port of the client side in iris-mobile, the VM's server that holds what it
|
|
27
|
+
// unwrapped, and a signed key-delivery string (the relay path signs deliveries because a relay could
|
|
28
|
+
// plant a key; here the gate's pinned TLS key carries the payload, so the host is already the proven VM).
|
|
29
|
+
|
|
30
|
+
import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync } from "node:crypto";
|
|
31
|
+
import { REFUSAL, refuse } from "./attested-host.mjs";
|
|
32
|
+
import { reportDataFor } from "./tdx-attestation.mjs";
|
|
33
|
+
|
|
34
|
+
/** The length of a key-wrapping public key: raw X25519. */
|
|
35
|
+
export const WRAP_KEY_BYTES = 32;
|
|
36
|
+
|
|
37
|
+
/** The version of the wrapped-keys payload. */
|
|
38
|
+
export const WRAPPED_KEYS_VERSION = 1;
|
|
39
|
+
|
|
40
|
+
const SEAL_VERSION = 1;
|
|
41
|
+
const SEAL_KDF_DOMAIN = Buffer.from("xania/seal/v1", "utf8");
|
|
42
|
+
const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
|
|
43
|
+
|
|
44
|
+
const rawPublic = (keyObject) => keyObject.export({ type: "spki", format: "der" }).subarray(-32);
|
|
45
|
+
const x25519Public = (raw) => {
|
|
46
|
+
const b = Buffer.from(raw);
|
|
47
|
+
if (b.length !== WRAP_KEY_BYTES) throw new Error(`an X25519 public key is 32 bytes, not ${b.length}`);
|
|
48
|
+
return createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, b]), format: "der", type: "spki" });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function sealDerive(shared, ephemeralPublic, recipientPublic) {
|
|
52
|
+
const okm = Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0), Buffer.concat([SEAL_KDF_DOMAIN, ephemeralPublic, recipientPublic]), 44));
|
|
53
|
+
return { key: okm.subarray(0, 32), nonce: okm.subarray(32, 44) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Seals `plaintext` to the raw X25519 `recipientPublicKey`, with a fresh ephemeral key per call. */
|
|
57
|
+
export function sealToKey(recipientPublicKey, plaintext) {
|
|
58
|
+
const recipient = Buffer.from(recipientPublicKey);
|
|
59
|
+
const ephemeral = generateKeyPairSync("x25519");
|
|
60
|
+
const ephemeralPublic = rawPublic(ephemeral.publicKey);
|
|
61
|
+
const shared = diffieHellman({ privateKey: ephemeral.privateKey, publicKey: x25519Public(recipient) });
|
|
62
|
+
const header = Buffer.concat([Buffer.from([SEAL_VERSION]), ephemeralPublic]);
|
|
63
|
+
const { key, nonce } = sealDerive(shared, ephemeralPublic, recipient);
|
|
64
|
+
const cipher = createCipheriv("chacha20-poly1305", key, nonce, { authTagLength: 16 });
|
|
65
|
+
cipher.setAAD(header, { plaintextLength: plaintext.length });
|
|
66
|
+
const body = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()]);
|
|
67
|
+
return Buffer.concat([header, body, cipher.getAuthTag()]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Opens an envelope with the X25519 private KeyObject it was sealed to. Throws when it does not open. */
|
|
71
|
+
export function openSealed(privateKey, envelope) {
|
|
72
|
+
const buf = Buffer.from(envelope);
|
|
73
|
+
if (buf.length < 1 + 32 + 16) throw new Error(`the envelope is ${buf.length} bytes, too short to be one`);
|
|
74
|
+
if (buf[0] !== SEAL_VERSION) throw new Error(`unknown envelope version ${buf[0]}`);
|
|
75
|
+
const header = buf.subarray(0, 33);
|
|
76
|
+
const ephemeralPublic = buf.subarray(1, 33);
|
|
77
|
+
const ciphertext = buf.subarray(33, buf.length - 16);
|
|
78
|
+
const shared = diffieHellman({ privateKey, publicKey: x25519Public(ephemeralPublic) });
|
|
79
|
+
const { key, nonce } = sealDerive(shared, ephemeralPublic, rawPublic(createPublicKey(privateKey)));
|
|
80
|
+
const decipher = createDecipheriv("chacha20-poly1305", key, nonce, { authTagLength: 16 });
|
|
81
|
+
decipher.setAAD(header, { plaintextLength: ciphertext.length });
|
|
82
|
+
decipher.setAuthTag(buf.subarray(buf.length - 16));
|
|
83
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── the VM ──────────────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The VM's key-wrapping key, made inside the VM. Returns a frozen object holding the public half and two
|
|
90
|
+
* functions; the private half is reachable only through them.
|
|
91
|
+
* reportDataFor(nonce, tlsPublicKey): the REPORTDATA the VM asks the TDX module to sign.
|
|
92
|
+
* unwrapAll(payload): opens a wrapped-keys payload addressed to this key. Throws when the payload
|
|
93
|
+
* names another key or any envelope does not open. Returns [{ circleId, epoch, scalar }].
|
|
94
|
+
*/
|
|
95
|
+
export function createEnclaveKey() {
|
|
96
|
+
const pair = generateKeyPairSync("x25519");
|
|
97
|
+
const publicKey = rawPublic(pair.publicKey);
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
publicKey,
|
|
100
|
+
reportDataFor: (nonce, tlsPublicKey) => reportDataFor(nonce, tlsPublicKey, publicKey),
|
|
101
|
+
unwrapAll(payload) {
|
|
102
|
+
if (payload?.v !== WRAPPED_KEYS_VERSION) throw new Error(`the payload speaks version ${JSON.stringify(payload?.v ?? null)}, not ${WRAPPED_KEYS_VERSION}`);
|
|
103
|
+
if (!Buffer.from(String(payload.wrapKey ?? ""), "base64").equals(publicKey)) throw new Error("the payload was wrapped to another key");
|
|
104
|
+
if (!Array.isArray(payload.keys)) throw new Error("the payload carries no keys");
|
|
105
|
+
return payload.keys.map((k) => {
|
|
106
|
+
const scalar = openSealed(pair.privateKey, Buffer.from(String(k.sealedKey ?? ""), "base64"));
|
|
107
|
+
if (scalar.length !== 32) throw new Error("a circle key is 32 bytes");
|
|
108
|
+
return { circleId: k.circleId, epoch: k.epoch, scalar };
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── the client ──────────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function circlesProblem(circles) {
|
|
117
|
+
if (!Array.isArray(circles) || circles.length === 0) return "no circle keys were named";
|
|
118
|
+
for (const c of circles) {
|
|
119
|
+
if (typeof c?.circleId !== "string" || c.circleId.length === 0) return "a circle key was named without its circle";
|
|
120
|
+
if (!Number.isInteger(c.epoch) || c.epoch < 1) return `circle ${c.circleId} was named with epoch ${JSON.stringify(c.epoch ?? null)}, not a whole number from 1`;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Wraps the named circle keys to the attested VM's own key and delivers them once over the attested
|
|
127
|
+
* connection. `session` is gate.open's result; `crypto` has `sealCircleKeyTo(circleId, epoch, publicKey)`;
|
|
128
|
+
* `circles` is [{ circleId, epoch }]; `deliver` is the transport session.send takes.
|
|
129
|
+
* Returns session.send's result, or a refusal with nothing sealed and nothing sent.
|
|
130
|
+
*/
|
|
131
|
+
export async function wrapCircleKeysTo(session, { crypto, circles, deliver } = {}) {
|
|
132
|
+
if (!session?.ok) return session?.code ? session : refuse(REFUSAL.NOT_PROVEN, "no attested session was given");
|
|
133
|
+
const wrapKey = session.wrapKey;
|
|
134
|
+
if (!wrapKey) return refuse(REFUSAL.NO_WRAP_KEY, "the host's quote bound no key-wrapping key");
|
|
135
|
+
if (Buffer.from(wrapKey).length !== WRAP_KEY_BYTES) return refuse(REFUSAL.NO_WRAP_KEY, "the session's wrap key is not 32 bytes");
|
|
136
|
+
const problem = circlesProblem(circles);
|
|
137
|
+
if (problem) return refuse(REFUSAL.NOTHING_TO_WRAP, problem);
|
|
138
|
+
if (typeof crypto?.sealCircleKeyTo !== "function") return refuse(REFUSAL.NOTHING_TO_WRAP, "no key holder was given to seal the circle keys");
|
|
139
|
+
return session.send(async ({ wrapKey: bound }) => {
|
|
140
|
+
const keys = [];
|
|
141
|
+
for (const { circleId, epoch } of circles) {
|
|
142
|
+
const sealed = await crypto.sealCircleKeyTo(circleId, epoch, new Uint8Array(bound));
|
|
143
|
+
keys.push({ circleId, epoch, sealedKey: Buffer.from(sealed).toString("base64") });
|
|
144
|
+
}
|
|
145
|
+
return { v: WRAPPED_KEYS_VERSION, wrapKey: Buffer.from(bound).toString("base64"), keys };
|
|
146
|
+
}, { deliver });
|
|
147
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Is the doctor that is about to give a verdict the doctor on origin/main? (HQ-2736)
|
|
2
|
+
//
|
|
3
|
+
// On 2026-10-19 a session checked a vault change with `evda doctor` from a checkout sitting on a
|
|
4
|
+
// branch merged two weeks earlier, read exit 0, and the change broke every evda PR's Argus
|
|
5
|
+
// baseline, because the doctor on origin/main refused what the stale one passed. The verdict was
|
|
6
|
+
// true of the code that ran; nothing said that code was not main's.
|
|
7
|
+
//
|
|
8
|
+
// So the doctor says, before its verdict, when its own checkout is behind origin/main or not on
|
|
9
|
+
// main. It says nothing on current main. It never changes the exit code: the exit is the record's
|
|
10
|
+
// verdict, and Argus runs the doctor from PR branches, where "not on main" is the expected state.
|
|
11
|
+
//
|
|
12
|
+
// The comparison target is origin/main after one bounded `git fetch` (FETCH_TIMEOUT_MS, no
|
|
13
|
+
// prompts, ssh in batch mode), because the local ref alone goes stale with the checkout: a
|
|
14
|
+
// checkout nobody fetched in two weeks would compare equal to its own two-week-old origin/main
|
|
15
|
+
// and read as current, which is the same false clean. A fetch only moves remote-tracking refs,
|
|
16
|
+
// never the working tree. EVDA_DOCTOR_FETCH=0 skips it and compares against the ref as last
|
|
17
|
+
// fetched, and says so. A failed fetch, no origin remote, or no git at all still lets every check
|
|
18
|
+
// run; the doctor then says it could not compare, and why.
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
export const FETCH_TIMEOUT_MS = 5000;
|
|
23
|
+
const GIT_TIMEOUT_MS = 3000;
|
|
24
|
+
|
|
25
|
+
function git(root, args, timeout = GIT_TIMEOUT_MS, env = process.env) {
|
|
26
|
+
const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
27
|
+
if (r.error) return { ok: false, out: "", err: r.error.code === "ETIMEDOUT" ? `timed out after ${timeout}ms` : String(r.error.message) };
|
|
28
|
+
// git's reason is its first `fatal:` line; the lines after it are advice ("and the repository exists.").
|
|
29
|
+
const errLines = (r.stderr ?? "").trim().split("\n").filter(Boolean);
|
|
30
|
+
const err = (errLines.find((l) => l.startsWith("fatal:")) ?? errLines[0] ?? "").replace(/^fatal:\s*/, "");
|
|
31
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").trim(), err };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const short = (sha) => sha.slice(0, 7);
|
|
35
|
+
|
|
36
|
+
// Returns { state, lines }. `lines` is what the doctor prints; it is empty exactly when the
|
|
37
|
+
// checkout is main at origin/main as of a fetch that just succeeded.
|
|
38
|
+
// state: "current" | "behind" | "off-main" | "not-compared" | "not-a-checkout"
|
|
39
|
+
export function checkoutFreshness(root, { fetch = process.env.EVDA_DOCTOR_FETCH !== "0", fetchTimeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
40
|
+
const top = git(root, ["rev-parse", "--show-toplevel"]);
|
|
41
|
+
if (!top.ok) {
|
|
42
|
+
return { state: "not-a-checkout", lines: [`could not compare this doctor against origin/main: ${root} is not a git checkout (an installed or copied doctor). Its verdict is this copy's.`] };
|
|
43
|
+
}
|
|
44
|
+
const head = git(root, ["rev-parse", "HEAD"]);
|
|
45
|
+
if (!head.ok) return { state: "not-compared", lines: [`could not compare this doctor against origin/main: the checkout has no commit (${head.err}).`] };
|
|
46
|
+
const branch = git(root, ["symbolic-ref", "--short", "-q", "HEAD"]).out || null;
|
|
47
|
+
const where = branch ? `branch ${branch}` : "a detached HEAD";
|
|
48
|
+
|
|
49
|
+
if (!git(root, ["remote", "get-url", "origin"]).ok) {
|
|
50
|
+
return { state: "not-compared", lines: [`could not compare this doctor against origin/main: the checkout has no origin remote. It runs from ${where} at ${short(head.out)}.`] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let fetchNote = null;
|
|
54
|
+
if (fetch) {
|
|
55
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
56
|
+
if (!env.GIT_SSH_COMMAND) env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=3";
|
|
57
|
+
const f = git(root, ["fetch", "--quiet", "--no-tags", "origin", "main"], fetchTimeoutMs, env);
|
|
58
|
+
if (!f.ok) fetchNote = `git fetch failed (${f.err || "no reason given"})`;
|
|
59
|
+
} else {
|
|
60
|
+
fetchNote = "fetch skipped (EVDA_DOCTOR_FETCH=0)";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ref = git(root, ["rev-parse", "--verify", "-q", "refs/remotes/origin/main"]);
|
|
64
|
+
if (!ref.ok) {
|
|
65
|
+
return { state: "not-compared", lines: [`could not compare this doctor against origin/main: ${fetchNote ?? "the fetch succeeded"} and there is no origin/main ref. It runs from ${where} at ${short(head.out)}.`] };
|
|
66
|
+
}
|
|
67
|
+
const behind = Number(git(root, ["rev-list", "--count", `HEAD..${ref.out}`]).out || 0);
|
|
68
|
+
const ahead = Number(git(root, ["rev-list", "--count", `${ref.out}..HEAD`]).out || 0);
|
|
69
|
+
const onMain = branch === "main";
|
|
70
|
+
const against = fetchNote ? `origin/main as last fetched (${short(ref.out)}; ${fetchNote}, so main may be further ahead)` : `origin/main ${short(ref.out)}`;
|
|
71
|
+
|
|
72
|
+
const lines = [];
|
|
73
|
+
let state;
|
|
74
|
+
if (behind > 0) {
|
|
75
|
+
state = "behind";
|
|
76
|
+
lines.push(`this doctor runs from ${where} at ${short(head.out)}, ${behind} commit(s) behind ${against}. Its verdict can differ from main's; rerun from an up-to-date main before trusting it.`);
|
|
77
|
+
} else if (!onMain) {
|
|
78
|
+
state = "off-main";
|
|
79
|
+
lines.push(`this doctor runs from ${where} at ${short(head.out)}, not main (${ahead} commit(s) ahead of ${against}). Its verdict is this branch's, which main may not share.`);
|
|
80
|
+
} else {
|
|
81
|
+
state = "current";
|
|
82
|
+
}
|
|
83
|
+
if (fetchNote && state === "current") {
|
|
84
|
+
state = "not-compared";
|
|
85
|
+
lines.push(`could not compare this doctor against current origin/main: ${fetchNote}. It matches ${against.replace(/ \(.*$/, "")} at ${short(ref.out)}.`);
|
|
86
|
+
}
|
|
87
|
+
return { state, lines };
|
|
88
|
+
}
|
package/lib/circles.mjs
CHANGED
|
@@ -74,6 +74,7 @@ import { resolveRole } from "./roles.mjs";
|
|
|
74
74
|
import { boundaries, insideAny } from "./records.mjs";
|
|
75
75
|
import { parseYaml, toYaml } from "./yaml.mjs";
|
|
76
76
|
import { EvdaError } from "./errors.mjs";
|
|
77
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
77
78
|
|
|
78
79
|
export const ECO_SCHEMA_VERSION = "0.1";
|
|
79
80
|
// atlas-arch:yaml-encoding: the eco manifest is a config file the substrate names by its suffix, not an entity key
|
|
@@ -544,13 +545,14 @@ export function frontmatterOf(text) {
|
|
|
544
545
|
}
|
|
545
546
|
}
|
|
546
547
|
|
|
547
|
-
// Every markdown file under the record root, skipping dot-directories and
|
|
548
|
+
// Every markdown file under the record root, skipping dot-directories and what outsideRecord names
|
|
549
|
+
// (node_modules, any *.nosync). The mark
|
|
548
550
|
// walk reads the whole record, not only the record roles: a released fact may live anywhere.
|
|
549
551
|
export function markdownFiles(root) {
|
|
550
552
|
const out = [];
|
|
551
553
|
const walk = (abs) => {
|
|
552
554
|
for (const name of readdirSync(abs).sort()) {
|
|
553
|
-
if (name.startsWith(".") || name
|
|
555
|
+
if (name.startsWith(".") || outsideRecord(name)) continue;
|
|
554
556
|
const p = join(abs, name);
|
|
555
557
|
// A dangling symlink (target lives only on another machine, e.g. raw media kept
|
|
556
558
|
// outside the synced vault) has nothing to stat. It is not markdown content either
|