create-evda 0.1.7 → 0.1.8

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.
@@ -39,6 +39,7 @@ import { circleManifest, readCircles, writeCircle, opaqueIdOf, circlesDir, isRel
39
39
  import { grant } from "./grants.mjs";
40
40
  import { openEvda, readManifest, writeManifest } from "./manifest.mjs";
41
41
  import { EvdaError } from "./errors.mjs";
42
+ import { defaultPartsPlan } from "./setup.mjs";
42
43
 
43
44
  export const PLAN_FILE = "plan.json";
44
45
 
@@ -221,7 +222,8 @@ export function declare(dir, plan, { circlesRel = "06_Metadata/Circles", grantsR
221
222
  mkdirSync(join(dir, manifest.mapping.grants), { recursive: true });
222
223
  const evda = openEvda(dir);
223
224
  const existing = new Map((readCircles(evda) ?? []).filter((c) => c.manifest !== null).map((c) => [c.name, c.manifest]));
224
- const owner = holder ?? manifest.owner?.name ?? manifest.id;
225
+ // An owner with no name yet (create-evda run with no --owner) is named by the record id.
226
+ const owner = holder || manifest.owner?.name || manifest.id;
225
227
 
226
228
  const written = [];
227
229
  for (const c of planned.circles) {
@@ -232,6 +234,8 @@ export function declare(dir, plan, { circlesRel = "06_Metadata/Circles", grantsR
232
234
  isPublic: c.public,
233
235
  holders: [owner],
234
236
  members: numbered(c.members.map((m) => ({ path: m.path }))),
237
+ // A plan circle may start the circle's rules (Spec 07, section 6); the default parts start empty.
238
+ policy: plan.circles.find((p) => p.name === c.name)?.policy ?? null,
235
239
  description: c.public ? "Public: anyone may read it." : `Private: read by the devices granted it, and no one else.`,
236
240
  });
237
241
  written.push(writeCircle(evda, c.name, m, { overwrite: true }));
@@ -275,3 +279,22 @@ export function deviceCircles(plan, device, view = null) {
275
279
  : [];
276
280
  return { atlasId: d.atlasId, holds, never: plan.circles.map((c) => c.name).filter((n) => !holds.includes(n)), drift };
277
281
  }
282
+
283
+ // The default parts (HQ-1925): journal and private, from setup.mjs `defaultPartsPlan`, declared by
284
+ // create-evda on a new record and, on the owner's yes, over an adopted record that has none. A
285
+ // record that already has a private part is left exactly as it is, so a yes can never replace
286
+ // parts the owner declared. `dryRun` answers whether the default is needed and writes nothing.
287
+ // Returns { needed, declared, parts }, parts being the private part names it has or would have.
288
+ export function defaultParts(dir, { dryRun = false, circlesRel = "circles", grantsRel = "grants", at = new Date() } = {}) {
289
+ const evda = openEvda(dir);
290
+ const have = (readCircles(evda) ?? []).filter((c) => c.manifest && c.manifest.public !== true && !c.mount).map((c) => c.name).sort();
291
+ if (have.length) return { needed: false, declared: false, parts: have };
292
+ const m = evda.manifest;
293
+ const slug = String(m.name ?? "record").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "record";
294
+ const recordId = typeof m.id === "string" && m.id ? m.id : `${slug}.atlas`;
295
+ const plan = defaultPartsPlan(m.mapping ?? {}, recordId);
296
+ const parts = plan.circles.map((c) => c.name).sort();
297
+ if (dryRun) return { needed: true, declared: false, parts };
298
+ declare(dir, plan, { circlesRel, grantsRel, recordId, at });
299
+ return { needed: false, declared: true, parts };
300
+ }
package/lib/mcp.mjs CHANGED
@@ -9,9 +9,11 @@
9
9
  // stdout carries protocol messages only. Nothing in this file or the tools it calls prints to
10
10
  // stdout; doctor's report is captured into the tool result.
11
11
  //
12
- // Every tool here reads and writes files on this machine, with one exception: evda_check_update
13
- // asks the npm registry for the tooling's latest version number. That is the server's only network
14
- // call, it carries nothing from the record, and it installs nothing.
12
+ // Every tool here reads and writes files on this machine, with two exceptions. evda_check_update
13
+ // asks the npm registry for the tooling's latest version number; it carries nothing from the record
14
+ // and installs nothing. The device tools (evda_pair_device, evda_sync_devices, HQ-1217) talk to the
15
+ // relay that carries the record between the owner's own devices, and what they send is sealed on
16
+ // this machine: the relay cannot read it.
15
17
 
16
18
  import { createInterface } from "node:readline";
17
19
  import { SCHEMA_VERSION } from "./manifest.mjs";
@@ -28,6 +30,7 @@ import { createSubrecord } from "./subrecord.mjs";
28
30
  import { FALSIFY } from "./mcp-auth.mjs";
29
31
  import { filingOf, FILING_MODES } from "./day-file.mjs";
30
32
  import { readManifest, writeManifest } from "./manifest.mjs";
33
+ import * as pairing from "./pairing.mjs";
31
34
 
32
35
  // Newest first. The server answers with the client's version when it supports it, and with the
33
36
  // newest otherwise; the client decides whether to continue.
@@ -527,10 +530,51 @@ export const TOOLS = [
527
530
  return { text: `${r.text}\n\nexit ${r.exit}`, data: { exit: r.exit }, isError: r.exit !== 0 };
528
531
  },
529
532
  },
533
+ {
534
+ name: "evda_pair_device",
535
+ title: "Pair a phone",
536
+ description: "Call only when the owner asks to pair, add or connect a phone or another device to their EVÐA. Returns a one-time pairing code and a QR link for the phone, and the steps to do on it. The code works once, for 10 minutes, and the device gets only the parts named (journal by default). Show the code to the owner and no one else.",
537
+ inputSchema: {
538
+ type: "object",
539
+ properties: {
540
+ name: { type: "string", description: "What to call the device, such as phone. Default phone." },
541
+ parts: { type: "array", items: { type: "string" }, description: "The private parts of the record the device may read and add to. Default [\"journal\"]." },
542
+ },
543
+ additionalProperties: false,
544
+ },
545
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
546
+ run: async (evda, args) => {
547
+ tools.onlyKeys("evda_pair_device", args, ["name", "parts"]);
548
+ const r = await pairing.pair(evda.dir, { name: args.name, parts: args.parts });
549
+ return { text: pairing.pairText(r) };
550
+ },
551
+ },
552
+ {
553
+ name: "evda_sync_devices",
554
+ title: "Sync with paired devices",
555
+ description: "Call after the owner used a pairing code on their phone, or when they ask to sync their devices: adds a device that used its code, takes in what paired devices wrote, and sends what changed here. Answers in plain words.",
556
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
557
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
558
+ run: async (evda, args) => {
559
+ tools.onlyKeys("evda_sync_devices", args, []);
560
+ return { text: (await pairing.sync(evda.dir)).join("\n") };
561
+ },
562
+ },
563
+ {
564
+ name: "evda_devices",
565
+ title: "Paired devices",
566
+ description: "Lists the devices paired with the owner's EVÐA, whether each is waiting, paired or removed, and which parts each reads. Removing one is a command the owner runs: evda remove <device>.",
567
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
568
+ annotations: { readOnlyHint: true, openWorldHint: false },
569
+ run: async (evda, args) => {
570
+ tools.onlyKeys("evda_devices", args, []);
571
+ return { text: (await pairing.devices(evda.dir)).join("\n") };
572
+ },
573
+ },
530
574
  {
531
575
  name: "evda_check_update",
532
576
  title: "Check for newer EVDA tooling",
533
- description: "Asks the npm registry whether a newer version of the EVDA tooling exists, and returns the one line that says so. At most one real check an hour; inside that hour it reports the cached result. A check that fails reports the status as UNKNOWN, never as up to date. This is the only tool that reaches the network, it sends nothing from the record, and it changes nothing: taking the update is a command the owner runs in their own terminal.",
577
+ description: "Asks the npm registry whether a newer version of the EVDA tooling exists, and returns the one line that says so. At most one real check an hour; inside that hour it reports the cached result. A check that fails reports the status as UNKNOWN, never as up to date. It sends nothing from the record, and it changes nothing: taking the update is a command the owner runs in their own terminal.",
534
578
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
535
579
  annotations: { readOnlyHint: true, openWorldHint: true },
536
580
  run: async (evda) => {
@@ -0,0 +1,165 @@
1
+ // Device pairing for the evda CLI and the MCP (HQ-1217): pair a phone, list and remove devices, sync.
2
+ //
3
+ // The pairing code itself is atlas-local's (atlas/block/atlas-local/src/node/pairing.ts), shipped
4
+ // in this package as one bundled file, dist/pairing.mjs (scripts/build-pairing.mjs). It is loaded
5
+ // only when a pairing command runs, because it needs node:sqlite, which the rest of this package
6
+ // does not: an owner on an older Node still has every other command.
7
+ //
8
+ // Everything a person reads from here is plain words. The one credential shown is the pairing code,
9
+ // because typing or scanning it on the phone is the point; no id, key or hash is printed.
10
+
11
+ import { existsSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { EvdaError } from "./errors.mjs";
16
+ import { openEvda } from "./manifest.mjs";
17
+
18
+ const PKG = resolve(dirname(fileURLToPath(import.meta.url)), "..");
19
+ export const PAIRING_BUNDLE = join(PKG, "dist", "pairing.mjs");
20
+
21
+ // node:sqlite is unflagged from Node 22.13 (and 23.4); before that it needs --experimental-sqlite.
22
+ export const PAIRING_NODE = "22.13";
23
+
24
+ /** Why pairing cannot run on this Node, in plain words, or null when it can. */
25
+ export function pairingUnsupported(version = process.versions.node) {
26
+ const [major, minor] = String(version).split(".").map(Number);
27
+ const ok = major > 23 || (major === 23 && minor >= 4) || (major === 22 && minor >= 13);
28
+ return ok ? null : `Pairing a device needs Node ${PAIRING_NODE} or newer, and this is Node ${version}. Everything else in evda works on this Node; install a newer one to pair.`;
29
+ }
30
+
31
+ let loaded = null;
32
+
33
+ /** The bundled pairing module, pointed at this package's own lib/. Throws in plain words when it cannot load. */
34
+ export async function loadPairing({ bundle = PAIRING_BUNDLE, version = process.versions.node } = {}) {
35
+ const unsupported = pairingUnsupported(version);
36
+ if (unsupported) throw new EvdaError(unsupported, { code: "NODE_TOO_OLD", exit: 2 });
37
+ if (loaded?.bundle === bundle) return loaded.mod;
38
+ if (!existsSync(bundle)) {
39
+ throw new EvdaError("This copy of evda was installed without device pairing. Run it from the published package (npx -p create-evda evda pair), or build it here with `node scripts/build-pairing.mjs`.", { code: "NOT_BUILT", exit: 2 });
40
+ }
41
+ // Node 22 and 23 announce node:sqlite as experimental on stderr. That line is for developers, not
42
+ // for someone pairing a phone, so it is dropped while the bundle loads and nothing else is.
43
+ const emit = process.emitWarning;
44
+ process.emitWarning = function (warning, ...rest) {
45
+ if (/SQLite/i.test(String(warning?.message ?? warning))) return;
46
+ return emit.call(process, warning, ...rest);
47
+ };
48
+ try {
49
+ const mod = await import(pathToFileURL(bundle).href);
50
+ mod.usePersonRecord(PKG);
51
+ loaded = { bundle, mod };
52
+ return mod;
53
+ } catch (error) {
54
+ // A Node that has the version but not the module (started with --no-experimental-sqlite, or a
55
+ // build without it) fails here, and is told the same thing in the same words.
56
+ if (error?.code === "ERR_UNKNOWN_BUILTIN_MODULE") {
57
+ throw new EvdaError(`Pairing a device needs Node ${PAIRING_NODE} or newer with its built-in SQLite, and this Node cannot load it. Everything else in evda works on this Node.`, { code: "NODE_TOO_OLD", exit: 2 });
58
+ }
59
+ throw error;
60
+ } finally {
61
+ process.emitWarning = emit;
62
+ }
63
+ }
64
+
65
+ // A refusal wrote nothing and is exit 2; anything else (the relay unreachable) is exit 1.
66
+ const REFUSALS = /^(Your EVÐA has no private|This record has no private part|There is already a device|There is no paired device|Give the device a name|Name at least one part)/;
67
+ function asEvdaError(error) {
68
+ if (error instanceof EvdaError) return error;
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ return new EvdaError(message, { code: "PAIRING", exit: REFUSALS.test(message) ? 2 : 1 });
71
+ }
72
+
73
+ /** Where the computer keeps its device state: `<home>/.evda/<record>/`, never inside the record. */
74
+ export function ownerOptions(dir, { env = process.env, relay } = {}) {
75
+ return { record: dir, home: resolve(env.HOME || homedir()), relayUrl: relay || env.EVDA_RELAY || undefined };
76
+ }
77
+
78
+ const partsList = (value) => (Array.isArray(value) ? value : String(value ?? "").split(",")).map((s) => String(s).trim()).filter(Boolean);
79
+
80
+ export async function pair(dir, { name, parts, relay, env } = {}) {
81
+ const p = await loadPairing();
82
+ try {
83
+ return await p.pairDevice(ownerOptions(dir, { env, relay }), { name: name ?? "phone", parts: partsList(parts ?? "journal") });
84
+ } catch (error) {
85
+ throw asEvdaError(error);
86
+ }
87
+ }
88
+
89
+ export const NO_DEVICES = "No devices are paired with this EVÐA.";
90
+
91
+ // The computer's list of devices lives beside its device state (pairing.ts, paired-devices.json).
92
+ // A record with no id, or no list there, has paired nothing: that is answered without loading the
93
+ // bundle, so listing devices on a fresh record, or on a Node or a copy that cannot pair, is an
94
+ // answer and not an error.
95
+ function pairedList(dir, env) {
96
+ let id;
97
+ try { id = openEvda(dir).manifest.id; } catch { return null; }
98
+ if (typeof id !== "string" || !id || /[/\\]/.test(id)) return null;
99
+ return join(resolve(env.HOME || homedir()), ".evda", id, "paired-devices.json");
100
+ }
101
+
102
+ export async function devices(dir, { relay, env = process.env, bundle } = {}) {
103
+ const list = pairedList(dir, env);
104
+ if (!list || !existsSync(list)) return [NO_DEVICES];
105
+ const p = await loadPairing(bundle ? { bundle } : {});
106
+ try {
107
+ return await p.deviceLines(ownerOptions(dir, { env, relay }));
108
+ } catch (error) {
109
+ throw asEvdaError(error);
110
+ }
111
+ }
112
+
113
+ /** The devices as data, for an app (HQ-1925): `[{name, state, parts, since}]`, empty when nothing is paired. No id. */
114
+ export async function deviceData(dir, { relay, env = process.env, bundle } = {}) {
115
+ const list = pairedList(dir, env);
116
+ if (!list || !existsSync(list)) return [];
117
+ const p = await loadPairing(bundle ? { bundle } : {});
118
+ try {
119
+ return await p.deviceList(ownerOptions(dir, { env, relay }));
120
+ } catch (error) {
121
+ throw asEvdaError(error);
122
+ }
123
+ }
124
+
125
+ /** One sync as data, for an app (HQ-1925): `{paired: [names], received, sent, refused}`. No id. */
126
+ export async function syncData(dir, { relay, env } = {}) {
127
+ const p = await loadPairing();
128
+ try {
129
+ return await p.syncOnce(ownerOptions(dir, { env, relay }));
130
+ } catch (error) {
131
+ throw asEvdaError(error);
132
+ }
133
+ }
134
+
135
+ export async function remove(dir, name, { relay, env } = {}) {
136
+ if (!name || !String(name).trim()) throw new EvdaError("Say which device to remove: evda remove <device>. `evda devices` lists them.", { code: "USAGE", exit: 2 });
137
+ const p = await loadPairing();
138
+ try {
139
+ return await p.removeDeviceWords(ownerOptions(dir, { env, relay }), String(name).trim());
140
+ } catch (error) {
141
+ throw asEvdaError(error);
142
+ }
143
+ }
144
+
145
+ export async function sync(dir, { relay, env } = {}) {
146
+ const p = await loadPairing();
147
+ try {
148
+ return await p.syncWords(ownerOptions(dir, { env, relay }));
149
+ } catch (error) {
150
+ throw asEvdaError(error);
151
+ }
152
+ }
153
+
154
+ /** The MCP tool's answer: the code, the QR link, and what to do on the phone, step by step. */
155
+ export function pairText(r) {
156
+ return [
157
+ `Pairing code for ${r.name}: ${r.code}`,
158
+ `QR link (make it a QR code, or open it on the phone): ${r.qr}`,
159
+ "",
160
+ "On the phone:",
161
+ ...r.phoneSteps.map((s, i) => `${i + 1}. ${s}`),
162
+ "",
163
+ `${r.name} will be able to read and add to ${r.parts.join(", ")}, and nothing else. The code is a key until it is used: show it only to the owner.`,
164
+ ].join("\n");
165
+ }
package/lib/setup.mjs CHANGED
@@ -405,3 +405,44 @@ export async function interview(opts, io, { presets = [] } = {}) {
405
405
  }
406
406
  return a;
407
407
  }
408
+
409
+ // ─── The parts a record starts with (HQ-1925) ────────────────────────
410
+ //
411
+ // Decided by the owner: privacy is set per folder when the record is made, everything is
412
+ // accessible by default, and the AI reads on request. So a new record is split into two parts on
413
+ // day one, and a phone can be added with no terminal step:
414
+ // journal the day files and captures, wherever the manifest maps them
415
+ // private the record root: everything else, including any folder made later
416
+ // The computer holds both as owner. A phone is offered the journal and nothing else.
417
+ // Pure, like the rest of this file, so the app's window reads the same words; circle-plan.mjs
418
+ // `defaultParts` writes the plan.
419
+
420
+ export const JOURNAL_PART = "journal";
421
+ export const PRIVATE_PART = "private";
422
+ /** What Add a phone offers a phone. */
423
+ export const PHONE_PARTS = [JOURNAL_PART];
424
+ /** The roles whose folders are the journal. */
425
+ const JOURNAL_ROLES = ["record/daily", "record/captures"];
426
+
427
+ /** The circle plan for a record's default parts, from its manifest mapping and its id. */
428
+ export function defaultPartsPlan(mapping = {}, recordId) {
429
+ const roots = [...new Set(JOURNAL_ROLES.map((r) => mapping[r]).filter((p) => typeof p === "string").map((p) => p.replace(/^\/+|\/+$/g, "")).filter(Boolean))];
430
+ // Each starts with an empty policy: the owner's corrections about a part land there.
431
+ const circles = roots.length ? [{ name: JOURNAL_PART, roots, policy: [] }] : [];
432
+ circles.push({ name: PRIVATE_PART, roots: [""], policy: [] });
433
+ return { circles, devices: { computer: { atlasId: recordId, grants: { "*": "owner" } } } };
434
+ }
435
+
436
+ /** One line for create-evda's report. */
437
+ export const PARTS_LINE = "parts: journal (your daily pages and captures) and private (everything else). A phone you add gets the journal.";
438
+
439
+ /** What the app shows when an adopted record has no parts, and the one button that splits it. */
440
+ export const PARTS_OFFER = {
441
+ title: "Your EVÐA is not split into parts yet",
442
+ lines: [
443
+ "A phone only gets the parts of your EVÐA you give it, so this Mac needs to know which part is your journal.",
444
+ "This makes two parts. Journal is your daily pages and captures, and it is what a phone gets. Private is everything else, and it stays on this Mac.",
445
+ "Nothing in your files moves or changes, and nothing reaches a phone until you add one.",
446
+ ],
447
+ button: "Split it into journal and private",
448
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-evda",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "The person layer over the substrate: scaffold and operate an EVDA, a directory that knows what it is. Plain files are the truth, the tooling is optional, and there are no runtime dependencies.",
6
6
  "bin": {
@@ -22,13 +22,15 @@
22
22
  "vectors:both": "bash scripts/vectors-both.sh",
23
23
  "doctor": "node bin/evda.mjs doctor",
24
24
  "publish:dry": "node scripts/publish.mjs",
25
- "publish:live": "node scripts/publish.mjs --publish"
25
+ "publish:live": "node scripts/publish.mjs --publish",
26
+ "pairing:build": "node scripts/build-pairing.mjs"
26
27
  },
27
28
  "files": [
28
29
  "bin",
29
30
  "hooks",
30
31
  "docs",
31
32
  "lib",
33
+ "dist",
32
34
  "templates",
33
35
  "skills",
34
36
  "migrations",
@@ -20,8 +20,7 @@ calls no model; you are their own AI, and everything you keep goes through the t
20
20
  Say nothing if it is current or was skipped. If an update is available or the check failed, say
21
21
  the one line it returns, briefly, and hand them the command it names to run in a terminal. The
22
22
  tool only tells: nothing in this server installs an update. It asks the npm registry for a
23
- version number and sends nothing from the record, and it is the only tool here that reaches the
24
- network at all. `evda check-update` is the same check from a shell, for a session that has one.
23
+ version number and sends nothing from the record. `evda check-update` is the same check from a shell, for a session that has one.
25
24
 
26
25
  If the tools are not there, say so plainly and point them at `evda connect`, which prints the
27
26
  setup for each client. Do not keep the record some other way.