create-evda 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +74 -7
  3. package/bin/evda.mjs +427 -9
  4. package/create-evda.mjs +64 -16
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +346 -13
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/folders.mjs +1 -1
  17. package/lib/gather-apply.mjs +640 -0
  18. package/lib/gather.mjs +858 -0
  19. package/lib/grants.mjs +7 -1
  20. package/lib/guard.mjs +132 -0
  21. package/lib/hooks.mjs +124 -0
  22. package/lib/keep-hook.mjs +185 -0
  23. package/lib/kinds.mjs +23 -6
  24. package/lib/kinds.schema.json +2 -1
  25. package/lib/manifest.mjs +14 -1
  26. package/lib/mcp-auth.mjs +370 -0
  27. package/lib/mcp-http.mjs +336 -0
  28. package/lib/mcp.mjs +456 -36
  29. package/lib/messages.mjs +205 -0
  30. package/lib/migrate-people.mjs +231 -0
  31. package/lib/organize.mjs +176 -32
  32. package/lib/parity.mjs +184 -0
  33. package/lib/presets.mjs +58 -7
  34. package/lib/prompt-hook.mjs +88 -0
  35. package/lib/publish.mjs +264 -0
  36. package/lib/readers.mjs +319 -0
  37. package/lib/records.mjs +52 -0
  38. package/lib/references.mjs +5 -51
  39. package/lib/render.mjs +158 -0
  40. package/lib/roles.mjs +106 -1
  41. package/lib/routines.mjs +142 -18
  42. package/lib/secret-shape.mjs +58 -0
  43. package/lib/setup.mjs +124 -4
  44. package/lib/skills.mjs +266 -0
  45. package/lib/subrecord.mjs +121 -0
  46. package/lib/sync-rules.mjs +93 -0
  47. package/lib/tools.mjs +133 -27
  48. package/lib/update-check.mjs +146 -0
  49. package/lib/work.mjs +180 -0
  50. package/lib/yaml.mjs +117 -42
  51. package/package.json +25 -15
  52. package/presets/journal/preset.json +9 -1
  53. package/presets/projects-and-areas/preset.json +16 -9
  54. package/skills/evda/SKILL.md +67 -8
  55. package/templates/AGENTS.md +122 -14
  56. package/templates/README.md +15 -1
  57. package/templates/circles-README.md +3 -3
  58. package/templates/evda-digest.md +31 -6
  59. package/LICENSE +0 -21
  60. package/presets/student/preset.json +0 -13
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Every message the owner sends, filed, in Claude Code (HQ-1385): a UserPromptSubmit hook that
3
+ // appends each prompt verbatim to the day's record, deterministically, before the model sees it.
4
+ //
5
+ // Mike, 2026-09-22, asked what the Claude app files: "Everything... how we can make this even more
6
+ // sustainable so that I can do this for all of the users." A tool description and an instruction
7
+ // are prompts, and a model can ignore both; a hook cannot be ignored. It exists only for an owner who
8
+ // answered "everything" to the one setup question: it reads evda.json `filing` on every call and
9
+ // files nothing when it says anything else, and `evda hook install` installs it only then.
10
+ //
11
+ // The append is lib/day-file.mjs appendToDay, the path evda_write uses: the first prompt of a
12
+ // session opens `## HH:MM`, later ones go under it. The session is Claude Code's session_id, kept
13
+ // in a small state file outside the record. A prompt holding something shaped like a password, key
14
+ // or token is not filed (lib/secret-shape.mjs, the check evda_write makes); a slash command is not
15
+ // the owner's words and is not filed.
16
+ //
17
+ // What it prints is added to the model's context: one line naming the file, so the model does not
18
+ // file the same words again with evda_write.
19
+ //
20
+ // Copied byte for byte into <record>/.claude/hooks/evda-file-prompt.mjs beside day-file.mjs and
21
+ // secret-shape.mjs. Any error of its own exits 0 with no output: a broken hook must never block the
22
+ // owner's message.
23
+ //
24
+ // EVDA_CHAT_FALSIFY=ignore-filing files even when evda.json does not say "everything", for the test
25
+ // that shows the hook reads the owner's answer.
26
+
27
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
28
+ import { dirname, join, relative } from "node:path";
29
+ import { tmpdir } from "node:os";
30
+ import { fileURLToPath } from "node:url";
31
+ import { dayRole, findRecord, filingOf, appendToDay, localDate } from "./day-file.mjs";
32
+ import { secretShape } from "./secret-shape.mjs";
33
+
34
+ export const PROMPT_HOOK_NAME = "evda-file-prompt.mjs";
35
+
36
+ const falsified = (mode) => (process.env.EVDA_CHAT_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
37
+
38
+ function stateFile(sid) {
39
+ const dir = process.env.EVDA_PROMPT_STATE_DIR || join(tmpdir(), "evda-file-prompt");
40
+ mkdirSync(dir, { recursive: true });
41
+ return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
42
+ }
43
+
44
+ // One UserPromptSubmit event in, the hook's stdout out ("" for nothing to say). Never throws.
45
+ export function run(payload, { now = new Date() } = {}) {
46
+ try {
47
+ const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
48
+ if (!prompt.trim() || /^\s*\//.test(prompt)) return "";
49
+ const here = dirname(fileURLToPath(import.meta.url));
50
+ const root = findRecord(payload.cwd || process.cwd()) ?? findRecord(here);
51
+ if (!root) return "";
52
+ const manifest = JSON.parse(readFileSync(join(root, "evda.json"), "utf8"));
53
+ if (filingOf(manifest) !== "everything" && !falsified("ignore-filing")) return "";
54
+ const role = dayRole(manifest.mapping ?? {});
55
+ if (!role) return "";
56
+ const shape = prompt.split("\n").map(secretShape).find(Boolean);
57
+ if (shape) return `evda: this message was not filed, because it holds ${shape}. Keep that in a password manager; file the rest of what they said with evda_write if it is worth keeping.`;
58
+ const dayAbs = join(root, manifest.mapping[role].replace(/\/+$/, ""));
59
+ mkdirSync(dayAbs, { recursive: true });
60
+ const abs = join(dayAbs, `${localDate(now)}.md`);
61
+ const sf = stateFile(payload.session_id ?? "unknown");
62
+ let session = {};
63
+ try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* the first prompt of the session */ }
64
+ appendToDay(abs, { now, words: prompt, session });
65
+ writeFileSync(sf, JSON.stringify(session) + "\n");
66
+ const rel = relative(root, abs).split("\\").join("/");
67
+ return `evda: the owner's message above is already filed verbatim in ${rel}. Do not file it again with evda_write.`;
68
+ } catch {
69
+ return "";
70
+ }
71
+ }
72
+
73
+ const entry = (() => {
74
+ try { return process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
75
+ })();
76
+
77
+ if (entry && existsSync(process.argv[1])) {
78
+ let input = "";
79
+ process.stdin.setEncoding("utf8");
80
+ process.stdin.on("data", (d) => (input += d));
81
+ process.stdin.on("end", () => {
82
+ let payload = null;
83
+ try { payload = JSON.parse(input); } catch { /* not a hook call */ }
84
+ const out = run(payload);
85
+ if (out) process.stdout.write(out);
86
+ process.exit(0);
87
+ });
88
+ }
@@ -0,0 +1,264 @@
1
+ // `evda publish public`: the public circle as a signed, plaintext export (Spec 08, section 4).
2
+ //
3
+ // Runs on the owner's device, reads only the public circle's declared members, and writes
4
+ //
5
+ // <out>/manifest.json format, circle id, record id, publishedAt, files [{ path, sha256, bytes }],
6
+ // tables [{ table, where, rows }], publicKey, signature (Ed25519 over the
7
+ // canonical manifest, which is the manifest without its signature, keys sorted)
8
+ // <out>/files/<path> the member files, byte for byte; and each render member's file, rendered
9
+ // from the released entries that name its view (lib/render.mjs, HQ-1351),
10
+ // listed in the manifest with `by: "view"`, the view's name and the source
11
+ // entries' record paths and sha256s. The entries a view consumes are not
12
+ // exported as themselves: the rendering is their public form, and the
13
+ // public set stays exactly what the site reads.
14
+ //
15
+ // Never through the relay: a public circle needs no key, a durable URL and a cache, and the relay
16
+ // is delete-on-ack with sealed envelopes a browser cannot open.
17
+ //
18
+ // Three checks run on the device before anything is written, and each can refuse (exit 2):
19
+ //
20
+ // 1. the circle is opened through `publicCircle`, which throws unless exactly one public circle
21
+ // is named, and only its declared members are read;
22
+ // 2. one keyed circle per path, and that circle allows release: a file under a public path member
23
+ // that any other circle also covers is refused, so a private path cannot also be a public
24
+ // member; a file gathered by rule (Spec 07 section 3, HQ-1351) is exported at its record path
25
+ // only when its one home circle's release names the public circle, and a match the home
26
+ // refuses refuses the whole publish rather than being dropped; a view with any problem (an
27
+ // entry naming a view nothing renders, a repeated order, a body with no JSON) refuses too;
28
+ // 3. render as a stranger: every private circle's label is searched for in the bytes about to be
29
+ // written, and a hit refuses. A label is its opaque id, its namespace, its own directory, a
30
+ // member path, or its name between slashes. The exported files' own paths are the released
31
+ // set (doctor prints them every run) and are left out of that search, since a marked file
32
+ // necessarily lives under a keyed circle's member path.
33
+ //
34
+ // The fourth check in the spec (the site holds no credential to anything else) is a property of
35
+ // where the export is served, not of this code.
36
+ //
37
+ // The signing key is the record's identity key. The Node host keeps it in its own database until
38
+ // the keychain adapter (slice 2), so this reads the 32-byte Ed25519 seed from a file the device
39
+ // exports, hex encoded, named by --key.
40
+
41
+ import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
42
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
43
+ import { dirname, join, relative, resolve, sep } from "node:path";
44
+ import { openEvda } from "./manifest.mjs";
45
+ import { publicCircle, readCircles, released, homesOf, withoutSealed, carries } from "./circles.mjs";
46
+ import { renderViews } from "./render.mjs";
47
+ import { EvdaError } from "./errors.mjs";
48
+
49
+ export const EXPORT_FORMAT = "evda-public-export/1";
50
+
51
+ // The boundary checks, as a switch a test can turn off to show that the acceptance test fails
52
+ // without them (FALSIFIED lines in atlas-local/src/slice.e2e.test.ts). Nothing in this package
53
+ // turns them off.
54
+ export const PUBLISH_CHECKS = { onePerPath: true, strangerRender: true };
55
+
56
+ const ED25519_PKCS8 = "302e020100300506032b657004220420";
57
+ const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
58
+
59
+ function filesUnder(abs) {
60
+ const st = statSync(abs);
61
+ if (st.isFile()) return [abs];
62
+ const out = [];
63
+ for (const name of readdirSync(abs).sort()) {
64
+ if (name.startsWith(".")) continue;
65
+ out.push(...filesUnder(join(abs, name)));
66
+ }
67
+ return out;
68
+ }
69
+
70
+ const posix = (rel) => rel.split(sep).join("/");
71
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
+
73
+ // Build the export in memory and run the checks. Writes nothing.
74
+ export function buildPublicExport(evda, { circleName = null, publishedAt = new Date() } = {}) {
75
+ const circle = publicCircle(evda, circleName);
76
+ const all = readCircles(evda) ?? [];
77
+
78
+ const files = [];
79
+ for (const m of Object.values(circle.members)) {
80
+ if (m.kind !== "path") continue;
81
+ for (const abs of filesUnder(m.abs)) files.push({ abs, path: posix(relative(evda.dir, abs)), by: "path" });
82
+ }
83
+
84
+ if (PUBLISH_CHECKS.onePerPath) {
85
+ // A file under a public member is exported only when the public circle is its HOME, by longest
86
+ // member path (HQ-1364): a private circle at the record root does not make it private, and a
87
+ // private circle with a longer member under the public one does.
88
+ for (const f of files) {
89
+ const others = homesOf(evda, all, f.abs).filter((n) => n !== circle.name);
90
+ if (others.length) {
91
+ throw new EvdaError(`REFUSED: ${f.path} is a public member and is also covered by circle "${others[0]}". One access answer per entry; nothing was written.`, { code: "TWO_CIRCLES", exit: 2 });
92
+ }
93
+ }
94
+ }
95
+
96
+ // Files gathered by rule: from wherever they live, only out of a home whose release names this
97
+ // circle. With the check off (a test proving it matters), a refused match is exported like any other.
98
+ const gathered = released(evda, all).find((r) => r.circle === circle.name) ?? { byRule: [], refused: [] };
99
+ if (PUBLISH_CHECKS.onePerPath && gathered.refused.length) {
100
+ const f = gathered.refused[0];
101
+ throw new EvdaError(`REFUSED: ${f.path} matches a rule of the public circle and ${f.reason}. Nothing was written.`, { code: "REFUSED_MARK", exit: 2 });
102
+ }
103
+ for (const f of gathered.byRule) files.push({ abs: f.abs, path: f.path, by: "mark", circle: f.circle });
104
+ if (!PUBLISH_CHECKS.onePerPath) for (const f of gathered.refused.filter((f) => f.abs !== undefined && f.member !== "release")) files.push({ abs: f.abs, path: f.path, by: "mark", circle: f.circle });
105
+
106
+ // Views (HQ-1351): every released entry that names a view is consumed by the render member that
107
+ // declares it, and the rendered file is exported in its place. Any problem refuses the publish.
108
+ const rendered = renderViews(evda, circle, { byPath: files.filter((f) => f.by === "path"), byRule: files.filter((f) => f.by === "mark") });
109
+ if (rendered.problems.length) {
110
+ const p = rendered.problems[0];
111
+ throw new EvdaError(`REFUSED: ${p.path} ${p.message}${rendered.problems.length > 1 ? ` (${rendered.problems.length - 1} more problem(s); evda doctor names them all)` : ""} Nothing was written.`, { code: "REFUSED_VIEW", exit: 2 });
112
+ }
113
+ const consumed = new Set(rendered.views.flatMap((v) => v.entries.map((e) => e.path)));
114
+ // A sealed section (HQ-1220) is cut out of every markdown file on the way out; the rest of the
115
+ // file is exported, and nothing marks where the section was. A sealed FILE never gets here:
116
+ // released() refuses it, and a refusal refuses the publish above.
117
+ let sealedCut = 0;
118
+ const exportBytes = (abs) => {
119
+ const bytes = readFileSync(abs);
120
+ if (!/\.md$/i.test(abs) || !carries("publish", "sealed")) return bytes;
121
+ const cut = withoutSealed(bytes.toString("utf8"));
122
+ if (!cut.sections) return bytes;
123
+ sealedCut += cut.sections;
124
+ return Buffer.from(cut.text, "utf8");
125
+ };
126
+ const loaded = files.filter((f) => !consumed.has(f.path)).map((f) => ({ path: f.path, bytes: exportBytes(f.abs), by: f.by }));
127
+ for (const v of rendered.views) loaded.push({ path: v.path, bytes: v.bytes, by: "view", view: v.view, sources: v.entries.map((e) => ({ path: e.path, sha256: e.sha256 })) });
128
+ loaded.sort((a, b) => a.path.localeCompare(b.path));
129
+
130
+ const tables = Object.values(circle.members)
131
+ .filter((m) => m.kind === "table")
132
+ // A table member is a live read on the host that serves it. The snapshot for a reader that has
133
+ // no such host is not built in the first slice, so the rows are recorded as not included.
134
+ .map((m) => ({ table: m.table, where: m.where, rows: null }));
135
+ const manifest = {
136
+ format: EXPORT_FORMAT,
137
+ circle: circle.id,
138
+ record: evda.manifest.id ?? null,
139
+ publishedAt: publishedAt instanceof Date ? publishedAt.toISOString() : String(publishedAt),
140
+ files: loaded.map((f) => (f.by === "view"
141
+ ? { path: f.path, sha256: sha256Hex(f.bytes), bytes: f.bytes.length, by: "view", view: f.view, sources: f.sources }
142
+ : { path: f.path, sha256: sha256Hex(f.bytes), bytes: f.bytes.length })),
143
+ tables,
144
+ };
145
+
146
+ if (PUBLISH_CHECKS.strangerRender) {
147
+ // The manifest minus the exported paths themselves and a rendered file's source paths: they
148
+ // are the released set by definition, and a marked file's path is always under some keyed
149
+ // circle's member path. A rendered file's own path is chosen, not inherited, so it is searched.
150
+ const manifestSansPaths = { ...manifest, files: manifest.files.map(({ path: _p, sources: _s, ...rest }) => rest) };
151
+ const haystacks = [
152
+ { where: "manifest.json", text: JSON.stringify(manifestSansPaths) },
153
+ { where: "manifest.json (rendered paths)", text: manifest.files.filter((f) => f.by === "view").map((f) => f.path).join("\n") },
154
+ ...loaded.map((f) => ({ where: f.path, text: f.bytes.toString("utf8") })),
155
+ ];
156
+ for (const c of all.filter((x) => x.manifest?.public !== true)) {
157
+ // What identifies a private circle to a stranger is a LABEL: a path segment, the circle's own
158
+ // directory, or the opaque id that travels on a Change and in an export manifest. A name
159
+ // matched as a word anywhere in the bytes is not that test, and on a real record it refuses
160
+ // every publish there is: his private circles are core, health, money, people, school,
161
+ // therapy, wellness and workshop, and his public fact sheet says "10 core mechanics" and
162
+ // "help other people understand me" (2026-09-21, HQ-1199). So the name is matched only
163
+ // between slashes, and the id and namespace, which the word match never looked at, are
164
+ // matched literally. The gap this leaves, stated so the next reader does not rediscover it:
165
+ // a distinctive circle name written as bare prose passes.
166
+ const needles = [];
167
+ // A one-segment path is a bare name, so it is matched the way a name is: between slashes.
168
+ // "CLAUDE.md" is a member of his core circle AND a filename his public projects page names;
169
+ // as a raw substring it refuses every publish while disclosing nothing.
170
+ const label = (s) => (s.includes("/") ? new RegExp(escapeRe(s)) : new RegExp(`(^|[\\\\/])${escapeRe(s)}($|[\\\\/])`, "iu"));
171
+ for (const s of new Set([c.name, c.manifest?.title].filter((v) => typeof v === "string" && v.trim() !== ""))) {
172
+ needles.push({ what: `the name of private circle "${c.name}" as a path segment`, re: label(s) });
173
+ }
174
+ for (const s of new Set([c.id, c.manifest?.id, c.manifest?.namespace].filter((v) => typeof v === "string" && v.trim() !== ""))) {
175
+ needles.push({ what: `the id of private circle "${c.name}" (${s})`, re: new RegExp(escapeRe(s)) });
176
+ }
177
+ needles.push({ what: `the directory of private circle "${c.name}" (${c.rel})`, re: label(c.rel.replace(/\/+$/, "")) });
178
+ for (const m of Object.values(c.members)) {
179
+ // The record root ("") is a member with no name: it identifies nothing to a stranger, and as
180
+ // a bare label it matched every "//" in a URL and refused every publish (found 2026-09-22,
181
+ // the day after HQ-1364 made core a root member).
182
+ if (m.kind === "path" && m.rel !== "") needles.push({ what: `a member path of private circle "${c.name}" (${m.rel})`, re: label(m.rel.replace(/\/+$/, "")) });
183
+ }
184
+ for (const n of needles) {
185
+ const hit = haystacks.find((h) => n.re.test(h.text));
186
+ if (hit) throw new EvdaError(`REFUSED: rendered as a stranger, the export shows ${n.what} in ${hit.where}. Nothing was written.`, { code: "STRANGER_RENDER", exit: 2 });
187
+ }
188
+ }
189
+ }
190
+
191
+ return { manifest, files: loaded, sealedCut };
192
+ }
193
+
194
+ export function canonicalManifest(manifest) {
195
+ const sortKeys = (v) => (Array.isArray(v) ? v.map(sortKeys) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v);
196
+ const { signature: _omit, ...rest } = manifest;
197
+ return JSON.stringify(sortKeys(rest));
198
+ }
199
+
200
+ export function signManifest(manifest, seedHex) {
201
+ if (!/^[0-9a-f]{64}$/i.test(seedHex.trim())) throw new EvdaError("REFUSED: the signing key must be a 32-byte Ed25519 seed, hex encoded.", { code: "BAD_KEY", exit: 2 });
202
+ const privateKey = createPrivateKey({ key: Buffer.concat([Buffer.from(ED25519_PKCS8, "hex"), Buffer.from(seedHex.trim(), "hex")]), format: "der", type: "pkcs8" });
203
+ const { x } = createPublicKey(privateKey).export({ format: "jwk" });
204
+ const withKey = { ...manifest, publicKey: Buffer.from(x, "base64url").toString("base64") };
205
+ return { ...withKey, signature: sign(null, Buffer.from(canonicalManifest(withKey)), privateKey).toString("base64") };
206
+ }
207
+
208
+ // Verify an export against a key the reader already trusts (raw 32-byte Ed25519 public key), never
209
+ // against the key the manifest carries. Returns { ok, why }.
210
+ export function verifyExport(out, publicKeyRaw) {
211
+ let manifest;
212
+ try {
213
+ manifest = JSON.parse(readFileSync(join(out, "manifest.json"), "utf8"));
214
+ } catch (e) {
215
+ return { ok: false, why: `manifest.json is unreadable (${e.message})` };
216
+ }
217
+ const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(publicKeyRaw).toString("base64url") }, format: "jwk" });
218
+ let good = false;
219
+ try {
220
+ good = verify(null, Buffer.from(canonicalManifest(manifest)), key, Buffer.from(String(manifest.signature ?? ""), "base64"));
221
+ } catch {
222
+ good = false;
223
+ }
224
+ if (!good) return { ok: false, why: "the signature does not verify against the key given" };
225
+ for (const f of manifest.files ?? []) {
226
+ const p = join(out, "files", f.path);
227
+ if (!existsSync(p)) return { ok: false, why: `${f.path} is listed and missing` };
228
+ const bytes = readFileSync(p);
229
+ if (bytes.length !== f.bytes || sha256Hex(bytes) !== f.sha256) return { ok: false, why: `${f.path} does not match its manifest entry` };
230
+ }
231
+ return { ok: true, why: `signature verifies; ${manifest.files.length} file(s) match` };
232
+ }
233
+
234
+ // Write the export. The record is never the destination.
235
+ export function writeExport(evda, out, { manifest, files }) {
236
+ const abs = resolve(out);
237
+ if (abs === evda.dir || abs.startsWith(evda.dir + sep)) throw new EvdaError(`REFUSED: ${out} is inside the record; an export is written outside it.`, { code: "EXPORT_INSIDE", exit: 2 });
238
+ rmSync(join(abs, "files"), { recursive: true, force: true });
239
+ for (const f of files) {
240
+ const p = join(abs, "files", f.path);
241
+ mkdirSync(dirname(p), { recursive: true });
242
+ writeFileSync(p, f.bytes);
243
+ }
244
+ writeFileSync(join(abs, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
245
+ return abs;
246
+ }
247
+
248
+ // The CLI half, called from bin/evda.mjs so that file only dispatches.
249
+ export async function publishCommand(dir, circleName, { out, key }) {
250
+ if (circleName !== "public") throw new EvdaError(`publish takes the public circle: evda publish public --out <dir> --key <seed file>`, { exit: 2 });
251
+ if (!out || !key) throw new EvdaError("publish needs --out <dir> and --key <file holding the identity seed, hex>", { exit: 2 });
252
+ const evda = openEvda(dir);
253
+ const built = buildPublicExport(evda);
254
+ const signed = signManifest(built.manifest, readFileSync(key, "utf8"));
255
+ const at = writeExport(evda, out, { manifest: signed, files: built.files });
256
+ const marked = built.files.filter((f) => f.by === "mark").length;
257
+ const views = built.files.filter((f) => f.by === "view");
258
+ const drawn = views.reduce((n, f) => n + f.sources.length, 0);
259
+ console.log(`published ${built.files.length} file(s) from the public circle to ${at} (${built.files.length - marked - views.length} by path, ${marked} by mark${views.length ? `, ${views.length} rendered from ${drawn} entries` : ""})`);
260
+ for (const f of views) console.log(` view ${f.path} rendered from ${f.sources.length} entries (${f.view})`);
261
+ if (built.sealedCut) console.log(` ${built.sealedCut} sealed section(s) left out of the files they sit in`);
262
+ console.log(`CHECKED ${built.files.length} public members, signed by ${signed.publicKey}`);
263
+ return 0;
264
+ }