@ccmsg/cli 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,15 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, isAbsolute } from "node:path";
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { dirname, isAbsolute, join, relative } from "node:path";
3
10
  import { type DumpPreset, type Endpoint, TranscriptItemSelector } from "@ccmsg/protocol";
4
11
  import { DEFAULT_HARNESS, type Harness, HARNESSES, isHarness } from "../harness/index.ts";
12
+ import { ID } from "./identity.ts";
5
13
  import { parseCidr } from "./client.ts";
6
14
 
7
15
  /** Where the instance accepts WebSocket connections, and from whom.
@@ -110,11 +118,24 @@ export interface InstanceConfig {
110
118
  * home says nothing about the program it belongs to, and an instance that
111
119
  * guessed would walk the wrong tree for the whole of its first session. */
112
120
  readonly harness: Harness;
113
- /** Every mesh endpoint, this instance's own among them (§7.1). The same list
114
- * goes to every instance and names none of them in particular: which entry is
115
- * this one is settled at startup by the probe, so one file can be copied to
116
- * every host unchanged (§8.2). */
117
- readonly peers: readonly Endpoint[];
121
+ /** Every instance of the mesh, this one among them (§7.1).
122
+ *
123
+ * Data, and the same data on every host: a settings function is handed it
124
+ * and may read it — an instance that wants to know who else there is has it
125
+ * here but a returned list that differs from the file's is refused. Which
126
+ * entry is this instance is the row carrying its own id, which is what
127
+ * settles `endpoint` below. */
128
+ readonly endpoints: readonly EndpointRow[];
129
+ /** Where peers and people reach this instance: its own row of the mesh
130
+ * (§7.1).
131
+ *
132
+ * Not something a settings file states — the row is, and two places to write
133
+ * one address is one place for it to be wrong. An instance behind a reverse
134
+ * proxy is dialled at the proxy's name and listens on loopback, and the two
135
+ * cannot be derived from each other, so what a peer dials is written down
136
+ * beside who it belongs to. Absent on an instance the mesh does not name,
137
+ * which is one that serves the unix socket alone. */
138
+ readonly endpoint?: Endpoint;
118
139
  /** Absent when this instance serves the unix socket only. */
119
140
  readonly entry?: EntryConfig;
120
141
  readonly upstream: UpstreamConfig;
@@ -147,176 +168,543 @@ export class ConfigError extends Error {
147
168
  }
148
169
  }
149
170
 
150
- /** An instance with no config file: the unix socket, no peers, no upstreams.
171
+ /** An instance with no config file: the unix socket, no mesh, no upstreams.
151
172
  *
152
173
  * Absent is not broken. A config that is not there states nothing wrong, while
153
174
  * one that is there and unreadable states something wrong — only the second is
154
175
  * the fail-fast case. */
155
176
  export const DEFAULT_CONFIG: InstanceConfig = {
156
177
  harness: DEFAULT_HARNESS,
157
- peers: [],
178
+ endpoints: [],
158
179
  upstream: {},
159
180
  direct_delivery: true,
160
181
  fork_origin: false,
161
182
  dump: { presets: [] },
162
183
  };
163
184
 
164
- /** Read the config, once, at startup (DV-Q8).
185
+ /** The file every instance's settings start from, and the directory holding
186
+ * one file per instance. Both are read from the config home a person edits
187
+ * (§8.2). The names are held here alone, so what the files are called is one
188
+ * edit rather than a search. */
189
+ export const CONFIG_FILE = "config_v2.ts";
190
+ export const INSTANCES_DIR = "instances";
191
+
192
+ /** The declarations a config file writes against, as they are called where
193
+ * they are copied to, and as this build keeps them. */
194
+ export const TYPES_FILE = "ccmsg-config_v2.d.ts";
195
+ const TYPES_SOURCE = "ccmsg-config.d.ts";
196
+
197
+ /** What the settings used to be written in. Named so a config home that still
198
+ * holds one is told where its settings have moved to, rather than starting
199
+ * with every setting it carried silently absent. */
200
+ const JSON_FILE = "config.json";
201
+
202
+ /** The fields a config function may hand back. Checked rather than ignored,
203
+ * because a misspelled field is a setting that was written and does not take:
204
+ * the types say so while the file is being edited, and this says so when it is
205
+ * read. */
206
+ const FIELDS = ["harness", "entry", "upstream", "direct_delivery", "fork_origin", "dump"] as const;
207
+
208
+ /** What only one instance's own file may state: which config home it answers
209
+ * for, and the address it is reached at. Neither is a thing the shared file
210
+ * could say once for everybody. */
211
+ const INSTANCE_FIELDS = ["dir", "name"] as const;
212
+
213
+ /** The mesh, as data: who is in it and where each one is reached.
165
214
  *
166
- * There is no watch and no reload: the file is small, an instance is cheap to
167
- * restart because almost nothing it holds is persistent (§3.6), and restarting
168
- * is therefore the whole of "apply a config change" (§8.2). */
169
- export function loadConfig(file: string, dir: string): InstanceConfig {
170
- return parseConfig(file, settingsFor(loadShared(file), dir));
171
- }
215
+ * A list rather than something derived, because it is the one thing an
216
+ * instance cannot work out for itself which address of the several a host
217
+ * has is the one its peers dial, and which of the entries is this instance.
218
+ * Both are answered by the row carrying its own id, which is what settles
219
+ * `self` (§7.1) without asking the network anything.
220
+ *
221
+ * Every instance of the mesh is in it, this host's and the others', so one
222
+ * file can be copied to every host unchanged (§8.2). */
223
+ export const ENDPOINTS_FILE = "endpoints.json";
172
224
 
173
- /** One config home the shared file knows about.
225
+ /** Which of them this host starts. An id here and not in the endpoints is a
226
+ * mistake; an id in the endpoints and not here is another host's instance,
227
+ * which this one dials and does not start. */
228
+ export const SUPERVISOR_FILE = "supervisor.json";
229
+
230
+ /** Where the settings that were read and checked are kept, and the one file
231
+ * the supervisor and every instance actually read.
174
232
  *
175
- * `dir` is the config home itself, which is what an instance is (A2); the rest
176
- * is whatever that instance sets differently from `defaults`, held raw because
177
- * it is merged before it is read. */
178
- export interface InstanceEntry {
233
+ * Apart from the files a person edits because the two answer different
234
+ * questions: what is being written, and what is running. A config that does
235
+ * not check out never reaches here, which is what lets a broken edit be
236
+ * reported without taking the host down (§8.3). */
237
+ export const STATE_CONFIG_DIR = "config";
238
+ export const SATISFIED_FILE = "satisfied.json";
239
+ export const REJECTED_DIR = "config.rejected";
240
+
241
+ /** One entry of the mesh. */
242
+ export interface EndpointRow {
243
+ readonly id: string;
244
+ readonly endpoint: Endpoint;
245
+ }
246
+
247
+ /** What one instance is, once its file has been read. */
248
+ export interface InstanceSetting {
249
+ readonly id: string;
250
+ readonly name: string;
179
251
  readonly dir: string;
180
- readonly settings: Record<string, unknown>;
252
+ readonly config: InstanceConfig;
181
253
  }
182
254
 
183
- /** The one file a person edits: what every instance gets, and which config
184
- * homes run one.
255
+ /** Everything that was read, checked, and is therefore what runs.
185
256
  *
186
- * One file rather than one per config home because both of the things it
187
- * carries are facts about the set the peer list is the same for every
188
- * instance (§7.1), and "which config homes run an instance" is a question no
189
- * single instance can answer about itself. */
190
- export interface SharedConfig {
191
- readonly defaults: Record<string, unknown>;
192
- readonly instances: readonly InstanceEntry[];
257
+ * One value rather than a directory to walk: the supervisor and the instances
258
+ * read this and nothing else, so what they run with is what was checked, and
259
+ * no TypeScript is evaluated a second time where a different answer could come
260
+ * back. */
261
+ export interface Satisfied {
262
+ readonly endpoints: readonly EndpointRow[];
263
+ readonly supervisor: { readonly instances: readonly string[] };
264
+ readonly instances: readonly InstanceSetting[];
193
265
  }
194
266
 
195
- export const EMPTY_SHARED: SharedConfig = { defaults: {}, instances: [] };
267
+ /** A label a person may give an instance. Narrow because it is typed at a
268
+ * command and printed in a listing; nothing is found by it, since files are
269
+ * named by id. */
270
+ export const CONFIG_NAME = /^[a-z0-9][a-z0-9._-]*$/;
196
271
 
197
- /** Read the shared file. Absent is not broken, for `DEFAULT_CONFIG`'s reason,
198
- * so it reads as the empty one; present and wrong ends the read (DV-Q9). */
199
- export function loadShared(file: string): SharedConfig {
200
- let text: string;
201
- try {
202
- text = readFileSync(file, "utf8");
203
- } catch {
204
- return EMPTY_SHARED;
272
+ /** Something wrong with one file, said where it is: which file, and what about
273
+ * it. Collected rather than thrown one at a time, so an operator who broke two
274
+ * things is told about both. */
275
+ export interface ConfigProblem {
276
+ readonly file: string;
277
+ readonly msg: string;
278
+ }
279
+
280
+ /** The files this reads, at the paths they are read and copied by. */
281
+ export function configFiles(configDir: string, instances: readonly string[]): string[] {
282
+ return [
283
+ join(configDir, CONFIG_FILE),
284
+ join(configDir, ENDPOINTS_FILE),
285
+ join(configDir, SUPERVISOR_FILE),
286
+ ...instances.map((id) => join(configDir, INSTANCES_DIR, instanceFileName(id))),
287
+ ];
288
+ }
289
+
290
+ export function instanceFileName(id: string): string {
291
+ return `instance-${id}.ts`;
292
+ }
293
+
294
+ /** Read everything a person edits, call what has to be called, and check the
295
+ * whole of it (DV-Q8, §8.3).
296
+ *
297
+ * One pass rather than a check per file, because what makes a config right is
298
+ * mostly between files: an id the supervisor starts has to be an entry of the
299
+ * mesh and have settings of its own, two instances must not hold one address
300
+ * or one config home, and the data is the mesh — a settings function that
301
+ * returned a different one has stated something it does not get to state. Each
302
+ * file is checked as far as it can be on its own so that a person is told
303
+ * where the mistake is, and nothing is applied until all of it holds.
304
+ *
305
+ * The settings functions are called here and never again: what they returned
306
+ * is what runs. They are expected to have no side effects, since this runs
307
+ * them to answer questions — `config show`, `config diff --satisfied` — as well as
308
+ * to apply them. */
309
+ export async function evaluate(
310
+ configDir: string,
311
+ ): Promise<{ satisfied?: Satisfied; problems: readonly ConfigProblem[] }> {
312
+ const problems: ConfigProblem[] = [];
313
+ const at = (file: string, msg: string): undefined => {
314
+ problems.push({ file, msg });
315
+ return undefined;
316
+ };
317
+
318
+ const endpointsFile = join(configDir, ENDPOINTS_FILE);
319
+ const supervisorFile = join(configDir, SUPERVISOR_FILE);
320
+ const configFile = join(configDir, CONFIG_FILE);
321
+
322
+ const endpoints = readEndpoints(endpointsFile, at);
323
+ const supervised = readSupervisor(supervisorFile, at);
324
+
325
+ // An empty config home is not a broken one: nothing is being run, which is
326
+ // what a host that has had no `daemon add` looks like.
327
+ if (endpoints === undefined || supervised === undefined) {
328
+ if (problems.length > 0) return { problems };
329
+ return {
330
+ satisfied: { endpoints: [], supervisor: { instances: [] }, instances: [] },
331
+ problems,
332
+ };
205
333
  }
206
- let parsed: unknown;
207
- try {
208
- parsed = JSON.parse(text);
209
- } catch (cause) {
210
- throw new ConfigError(file, `not valid JSON (${String(cause)})`);
334
+
335
+ const defaults = await defaultsOf(configFile, endpoints, at);
336
+ const instances: InstanceSetting[] = [];
337
+ for (const id of supervised) {
338
+ if (!endpoints.some((row) => row.id === id)) {
339
+ at(supervisorFile, `${id} is not an entry of ${ENDPOINTS_FILE}`);
340
+ continue;
341
+ }
342
+ const file = join(configDir, INSTANCES_DIR, instanceFileName(id));
343
+ if (!existsSync(file)) {
344
+ at(supervisorFile, `${id} has no settings of its own at ${file}`);
345
+ continue;
346
+ }
347
+ if (defaults === undefined) continue;
348
+ const read = await instanceOf(file, id, defaults, endpoints, at);
349
+ if (read !== undefined) instances.push(read);
350
+ }
351
+
352
+ // What no single file can be wrong about on its own.
353
+ const dirs = new Map<string, string>();
354
+ const ports = new Map<number, string>();
355
+ for (const one of instances) {
356
+ const file = join(configDir, INSTANCES_DIR, instanceFileName(one.id));
357
+ const home = dirs.get(one.dir);
358
+ if (home !== undefined) at(file, `dir ${one.dir} is already what ${home} answers for`);
359
+ else dirs.set(one.dir, one.name);
360
+ const port = one.config.entry?.port;
361
+ if (port === undefined || port === 0) continue;
362
+ const held = ports.get(port);
363
+ if (held !== undefined) at(file, `port ${String(port)} is already ${held}'s`);
364
+ else ports.set(port, one.name);
211
365
  }
212
- const top = objectOf(file, "the top level", parsed);
213
- for (const name of Object.keys(top)) {
214
- if (name !== "defaults" && name !== "instances") {
215
- throw new ConfigError(file, `unknown top-level key ${name}; expected defaults or instances`);
366
+
367
+ if (problems.length > 0) return { problems };
368
+ return {
369
+ satisfied: { endpoints, supervisor: { instances: supervised }, instances },
370
+ problems,
371
+ };
372
+ }
373
+
374
+ /** The mesh as the data states it. */
375
+ function readEndpoints(
376
+ file: string,
377
+ at: (file: string, msg: string) => undefined,
378
+ ): readonly EndpointRow[] | undefined {
379
+ const parsed = readJson(file, at);
380
+ if (parsed === undefined) return undefined;
381
+ if (!Array.isArray(parsed)) return at(file, "must be an array of {id, endpoint}");
382
+ const rows: EndpointRow[] = [];
383
+ for (const [index, raw] of parsed.entries()) {
384
+ const where = `[${String(index)}]`;
385
+ if (typeof raw !== "object" || raw === null) {
386
+ at(file, `${where} must be an object with id and endpoint`);
387
+ continue;
388
+ }
389
+ const fields = raw as Record<string, unknown>;
390
+ const id = fields["id"];
391
+ const endpoint = fields["endpoint"];
392
+ if (typeof id !== "string" || !ID.test(id)) {
393
+ at(file, `${where}.id must be an instance id`);
394
+ continue;
216
395
  }
396
+ if (typeof endpoint !== "string" || !ENDPOINT.test(endpoint)) {
397
+ at(file, `${where}.endpoint must be an http:// or https:// base URL ending in /`);
398
+ continue;
399
+ }
400
+ if (rows.some((row) => row.id === id)) at(file, `${where}.id repeats ${id}`);
401
+ else if (rows.some((row) => row.endpoint === endpoint)) {
402
+ // Two entries at one address would each be this instance to whoever
403
+ // dialled it, and neither could be told from the other (§7.1).
404
+ at(file, `${where}.endpoint repeats ${endpoint}`);
405
+ } else rows.push({ id, endpoint: endpoint as Endpoint });
217
406
  }
218
- const raw = top["instances"];
219
- if (raw !== undefined && !Array.isArray(raw)) {
220
- throw new ConfigError(file, "instances must be an array of config homes");
407
+ return rows;
408
+ }
409
+
410
+ function readSupervisor(
411
+ file: string,
412
+ at: (file: string, msg: string) => undefined,
413
+ ): readonly string[] | undefined {
414
+ const parsed = readJson(file, at);
415
+ if (parsed === undefined) return undefined;
416
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
417
+ return at(file, "must be an object with instances");
221
418
  }
222
- const seen = new Set<string>();
223
- const instances = ((raw ?? []) as unknown[]).map((entry, index) => {
224
- const fields = objectOf(file, `instances[${index}]`, entry);
225
- const { dir, ...settings } = fields;
226
- if (typeof dir !== "string" || !isAbsolute(dir)) {
227
- throw new ConfigError(file, `instances[${index}].dir must be an absolute config home`);
419
+ const listed = (parsed as Record<string, unknown>)["instances"] ?? [];
420
+ if (!Array.isArray(listed) || listed.some((id) => typeof id !== "string" || !ID.test(id))) {
421
+ return at(file, "instances must be an array of instance ids");
422
+ }
423
+ const ids = listed as string[];
424
+ const twice = ids.filter((id, index) => ids.indexOf(id) !== index);
425
+ if (twice.length > 0) return at(file, `instances repeats ${[...new Set(twice)].join(", ")}`);
426
+ return ids;
427
+ }
428
+
429
+ /** What every instance starts from. */
430
+ async function defaultsOf(
431
+ file: string,
432
+ endpoints: readonly EndpointRow[],
433
+ at: (file: string, msg: string) => undefined,
434
+ ): Promise<InstanceConfig | undefined> {
435
+ if (!existsSync(file)) {
436
+ const legacy = join(dirname(file), JSON_FILE);
437
+ if (existsSync(legacy)) {
438
+ return at(
439
+ legacy,
440
+ `settings are TypeScript now: write ${file}, ${join(dirname(file), ENDPOINTS_FILE)} and ${join(dirname(file), SUPERVISOR_FILE)}`,
441
+ );
228
442
  }
229
- if (seen.has(dir)) throw new ConfigError(file, `instances[${index}].dir repeats ${dir}`);
230
- seen.add(dir);
231
- return { dir, settings };
443
+ return { ...DEFAULT_CONFIG, endpoints };
444
+ }
445
+ const returned = await called(file, {
446
+ builtin: frozen({ ...DEFAULT_CONFIG, endpoints }),
447
+ config: copied({ ...DEFAULT_CONFIG, endpoints }),
448
+ });
449
+ if (returned.problem !== undefined) return at(file, returned.problem);
450
+ return settingsOf(file, returned.value, endpoints, false, at)?.config;
451
+ }
452
+
453
+ /** One instance's own file, read over what the shared one returned. */
454
+ async function instanceOf(
455
+ file: string,
456
+ id: string,
457
+ defaults: InstanceConfig,
458
+ endpoints: readonly EndpointRow[],
459
+ at: (file: string, msg: string) => undefined,
460
+ ): Promise<InstanceSetting | undefined> {
461
+ const returned = await called(file, {
462
+ builtin: frozen({ ...DEFAULT_CONFIG, endpoints }),
463
+ default: frozen(defaults),
464
+ config: { ...copied(defaults), dir: "", name: id },
232
465
  });
466
+ if (returned.problem !== undefined) return at(file, returned.problem);
467
+ const read = settingsOf(file, returned.value, endpoints, true, at);
468
+ if (read === undefined) return undefined;
469
+ // Where this instance is reached: its own row of the mesh. An instance the
470
+ // data does not name could not be dialled by anybody and could not settle
471
+ // what a handshake calls it (§7.1), so it is a config error rather than an
472
+ // instance with no address.
473
+ const mine = endpoints.find((row) => row.id === id);
474
+ if (mine === undefined) {
475
+ return at(file, `${id} is not an entry of ${ENDPOINTS_FILE}, so it has no endpoint`);
476
+ }
233
477
  return {
234
- defaults: top["defaults"] === undefined ? {} : objectOf(file, "defaults", top["defaults"]),
235
- instances,
478
+ id,
479
+ name: read.name === "" ? id : read.name,
480
+ dir: read.dir,
481
+ config: { ...read.config, endpoint: mine.endpoint },
236
482
  };
237
483
  }
238
484
 
239
- /** Write the shared file back, at the shape a person reads it in. */
240
- export function saveShared(file: string, shared: SharedConfig): void {
241
- const instances = shared.instances.map((entry) => ({ dir: entry.dir, ...entry.settings }));
242
- mkdirSync(dirname(file), { recursive: true });
243
- writeFileSync(file, `${JSON.stringify({ defaults: shared.defaults, instances }, null, 2)}\n`);
485
+ /** The settings that were applied, as the state directory holds them. */
486
+ export function applied(stateRoot: string): Satisfied | undefined {
487
+ let text: string;
488
+ try {
489
+ text = readFileSync(join(stateRoot, STATE_CONFIG_DIR, SATISFIED_FILE), "utf8");
490
+ } catch {
491
+ return undefined;
492
+ }
493
+ try {
494
+ return JSON.parse(text) as Satisfied;
495
+ } catch {
496
+ return undefined;
497
+ }
244
498
  }
245
499
 
246
- /** How one field of the shared file combines an instance's entry with the
247
- * defaults.
500
+ /** Write down what checked out: the value the supervisor and the instances
501
+ * read, and a copy of each file it was read from.
248
502
  *
249
- * `merge` takes the two field by field, so an instance states only what it
250
- * differs in; `replace` takes the instance's value whole. */
251
- export type MergeRule = "merge" | "replace";
503
+ * The copies are what `config diff` compares against and what `config revert`
504
+ * puts back, so they are taken at the same relative paths. Only the files
505
+ * named above are copied: what a settings file imports is its own business and
506
+ * is not backed up here, and a config home that loses one of those still
507
+ * starts, because what starts an instance is the value and not the file. */
508
+ export function apply(configDir: string, stateRoot: string, satisfied: Satisfied): void {
509
+ const into = join(stateRoot, STATE_CONFIG_DIR);
510
+ mkdirSync(join(into, INSTANCES_DIR), { recursive: true });
511
+ for (const file of configFiles(configDir, satisfied.supervisor.instances)) {
512
+ if (!existsSync(file)) continue;
513
+ copyFileSync(file, join(into, relative(configDir, file)));
514
+ }
515
+ writeFileSync(join(into, SATISFIED_FILE), `${JSON.stringify(satisfied, null, 2)}\n`);
516
+ }
252
517
 
253
- /** The rule for every field path that holds an object or an array, which are
254
- * the only ones where "combine" could mean more than one thing.
518
+ /** Read, check, and apply, which is the one thing startup and reload both do.
255
519
  *
256
- * Declared beside the parsers rather than derived from the values, because
257
- * whether a list is a sequence or a set is a fact about what the field means
258
- * and every list looks the same without it. A path not named here replaces:
259
- * that is what a scalar can do, and it is what an array does until some field
260
- * is a set and says so. */
261
- export const MERGE_RULES: Readonly<Record<string, MergeRule>> = {
262
- // The same finished list goes to every instance (§7.1), so an instance that
263
- // writes its own means to run with that one and no other.
264
- peers: "replace",
265
- entry: "merge",
266
- "entry.source_ips": "replace",
267
- "entry.trusted_proxies": "replace",
268
- upstream: "merge",
269
- "upstream.launcher": "merge",
270
- "upstream.launcher.root_dirs": "replace",
271
- "upstream.launcher.templates": "replace",
272
- "upstream.launcher.clean_env": "replace",
273
- "upstream.launcher.keep_env": "replace",
274
- dump: "merge",
275
- // A preset list is a whole vocabulary: an instance that names its own means
276
- // to dump by those and not by the defaults' as well, since a name it did not
277
- // write could shadow or be referenced by one it did.
278
- "dump.presets": "replace",
279
- };
520
+ * A config that does not check out leaves the applied one standing and is
521
+ * reported: an instance already serving a session is not something a typo in a
522
+ * file should take away, and an operator finds out from the log and from
523
+ * `daemon status` rather than from everything being gone. The first run is the
524
+ * exception — there is nothing to fall back to, so there is nothing to run. */
525
+ export async function settle(
526
+ configDir: string,
527
+ stateRoot: string,
528
+ ): Promise<{ satisfied: Satisfied; problems: readonly ConfigProblem[]; applied: boolean }> {
529
+ const read = await evaluate(configDir);
530
+ if (read.satisfied !== undefined) {
531
+ apply(configDir, stateRoot, read.satisfied);
532
+ return { satisfied: read.satisfied, problems: [], applied: true };
533
+ }
534
+ const standing = applied(stateRoot);
535
+ if (standing === undefined) {
536
+ throw new ConfigError(
537
+ read.problems[0]?.file ?? join(configDir, CONFIG_FILE),
538
+ read.problems.map((one) => `${one.file}: ${one.msg}`).join("; "),
539
+ );
540
+ }
541
+ return { satisfied: standing, problems: read.problems, applied: false };
542
+ }
280
543
 
281
- function ruleFor(path: string): MergeRule {
282
- return MERGE_RULES[path] ?? "replace";
544
+ /** What one config home's instance runs with, out of what is applied. */
545
+ export function configOf(satisfied: Satisfied, dir: string): InstanceSetting | undefined {
546
+ return satisfied.instances.find((one) => one.dir === dir);
547
+ }
548
+
549
+ function readJson(file: string, at: (file: string, msg: string) => undefined): unknown {
550
+ let text: string;
551
+ try {
552
+ text = readFileSync(file, "utf8");
553
+ } catch {
554
+ return undefined;
555
+ }
556
+ try {
557
+ return JSON.parse(text);
558
+ } catch (cause) {
559
+ return at(file, `not valid JSON (${String(cause)})`);
560
+ }
283
561
  }
284
562
 
285
- function plainObject(raw: unknown): raw is Record<string, unknown> {
286
- return typeof raw === "object" && raw !== null && !Array.isArray(raw);
563
+ /** Put the declarations a config file writes against beside the files that
564
+ * write against them.
565
+ *
566
+ * Copied into the config home rather than reached where this build keeps them:
567
+ * a relative `import type` resolves with no tsconfig and no node_modules
568
+ * anywhere near it, and it goes on resolving when this checkout moves. */
569
+ export function writeConfigTypes(configDir: string): string {
570
+ const at = join(configDir, TYPES_FILE);
571
+ mkdirSync(configDir, { recursive: true });
572
+ copyFileSync(new URL(`./${TYPES_SOURCE}`, import.meta.url).pathname, at);
573
+ return at;
287
574
  }
288
575
 
289
- function merged(
290
- base: Record<string, unknown>,
291
- over: Record<string, unknown>,
292
- at: string,
293
- ): Record<string, unknown> {
294
- const out: Record<string, unknown> = { ...base };
295
- for (const [name, value] of Object.entries(over)) {
296
- const path = at === "" ? name : `${at}.${name}`;
297
- const under = out[name];
298
- out[name] =
299
- ruleFor(path) === "merge" && plainObject(under) && plainObject(value)
300
- ? merged(under, value, path)
301
- : value;
576
+ /** Import one config file and call what it exports.
577
+ *
578
+ * The modified time rides on the specifier because an import is cached by it:
579
+ * a file read again in the same process after being edited — a supervisor
580
+ * asked to add an instance, a test writing two configs — would otherwise be
581
+ * the first read over again. */
582
+ async function called(
583
+ file: string,
584
+ ctx: Record<string, unknown>,
585
+ ): Promise<{ value?: unknown; problem?: string }> {
586
+ let module: { default?: unknown };
587
+ try {
588
+ module = (await import(`${file}?mtime=${String(statSync(file).mtimeMs)}`)) as {
589
+ default?: unknown;
590
+ };
591
+ } catch (cause) {
592
+ return { problem: `cannot be loaded (${String(cause)})` };
593
+ }
594
+ const define = module.default;
595
+ if (typeof define !== "function") {
596
+ return { problem: "must default export a function taking { config } and returning it" };
597
+ }
598
+ try {
599
+ // Awaited whatever it answers with: what a settings file has to do to
600
+ // answer — read a secret, ask something — is its own business.
601
+ return { value: await (define as (given: unknown) => unknown)(ctx) };
602
+ } catch (cause) {
603
+ return { problem: `threw while being read (${String(cause)})` };
302
604
  }
303
- return out;
304
605
  }
305
606
 
306
- /** What one config home's instance is configured with: its own entry over the
307
- * shared defaults, by the rule each field path declares. A config home the file
308
- * does not list still resolves — `daemon run` on an unregistered directory is
309
- * the defaults plus the built-ins. */
310
- export function settingsFor(shared: SharedConfig, dir: string): Record<string, unknown> {
311
- const entry = shared.instances.find((one) => one.dir === dir);
312
- return merged(shared.defaults, entry?.settings ?? {}, "");
607
+ /** What one config function handed back, checked at the shape an instance uses
608
+ * it. */
609
+ function settingsOf(
610
+ file: string,
611
+ returned: unknown,
612
+ endpoints: readonly EndpointRow[],
613
+ wantsDir: boolean,
614
+ at: (file: string, msg: string) => undefined,
615
+ ): { dir: string; name: string; config: InstanceConfig } | undefined {
616
+ if (typeof returned !== "object" || returned === null || Array.isArray(returned)) {
617
+ return at(file, "must return the config it was handed");
618
+ }
619
+ const fields = returned as Record<string, unknown>;
620
+ for (const name of Object.keys(fields)) {
621
+ if ((INSTANCE_FIELDS as readonly string[]).includes(name)) {
622
+ if (wantsDir) continue;
623
+ return at(file, `${name} belongs to an ${INSTANCES_DIR}/ file, which this is not`);
624
+ }
625
+ if (name === "endpoints") continue;
626
+ if (!(FIELDS as readonly string[]).includes(name)) {
627
+ return at(file, `unknown field ${name}; expected ${FIELDS.join(", ")}`);
628
+ }
629
+ }
630
+ // The mesh is data: a function is handed it so it can read it, and a
631
+ // function that handed back a different one has stated something that is
632
+ // not its to state — which would be a host running a mesh nobody wrote down.
633
+ if (!sameMesh(fields["endpoints"], endpoints)) {
634
+ return at(
635
+ file,
636
+ `endpoints are ${ENDPOINTS_FILE}'s to state, and this returned a different list`,
637
+ );
638
+ }
639
+ const dir = fields["dir"];
640
+ if (wantsDir && (typeof dir !== "string" || !isAbsolute(dir))) {
641
+ return at(file, "dir must be the absolute config home this instance answers for");
642
+ }
643
+ const name = fields["name"];
644
+ if (name !== undefined && (typeof name !== "string" || !CONFIG_NAME.test(name))) {
645
+ return at(file, "name must be a label in lower case, digits, dots, dashes");
646
+ }
647
+ let config: InstanceConfig;
648
+ try {
649
+ config = parseConfig(file, fields);
650
+ } catch (cause) {
651
+ return at(
652
+ file,
653
+ cause instanceof ConfigError ? cause.message.slice(file.length + 2) : String(cause),
654
+ );
655
+ }
656
+ return {
657
+ dir: wantsDir ? (dir as string) : "",
658
+ name: typeof name === "string" ? name : "",
659
+ config: { ...config, endpoints },
660
+ };
661
+ }
662
+
663
+ /** Whether what came back is the mesh that went in, row by row.
664
+ *
665
+ * Field by field rather than by serialising the two: what is being asked is
666
+ * whether a settings function changed anything, and two lists that differ in
667
+ * the order of their keys are the same mesh. */
668
+ function sameMesh(returned: unknown, rows: readonly EndpointRow[]): boolean {
669
+ if (!Array.isArray(returned)) return rows.length === 0;
670
+ if (returned.length !== rows.length) return false;
671
+ return rows.every((row, at) => {
672
+ const one = returned[at] as { id?: unknown; endpoint?: unknown } | undefined;
673
+ return one?.id === row.id && one.endpoint === row.endpoint;
674
+ });
675
+ }
676
+
677
+ /** A copy nothing can write to, for the values a config function builds on
678
+ * rather than edits: what `builtin` and `default` are is settled before the
679
+ * file runs, so a file that tried to edit one is told so where it did it. */
680
+ function frozen(value: InstanceConfig): Record<string, unknown> {
681
+ return deepFreeze(copied(value));
682
+ }
683
+
684
+ function deepFreeze<T>(value: T): T {
685
+ if (typeof value !== "object" || value === null) return value;
686
+ for (const held of Object.values(value)) deepFreeze(held);
687
+ return Object.freeze(value);
688
+ }
689
+
690
+ /** The mutable copy a config function edits and returns.
691
+ *
692
+ * The mesh is in it, because a settings function may want to read who else
693
+ * there is; handing back a different one is what is refused. */
694
+ function copied(value: InstanceConfig): Record<string, unknown> {
695
+ return structuredClone(value) as unknown as Record<string, unknown>;
313
696
  }
314
697
 
315
698
  /** One instance's settings, read at the shape the instance uses them. */
316
699
  export function parseConfig(file: string, fields: Record<string, unknown>): InstanceConfig {
317
700
  return {
318
701
  harness: harnessOf(file, fields["harness"]),
319
- peers: peersOf(file, fields["peers"]),
702
+ // Put back by the caller from the data, which is where the mesh is
703
+ // stated; what a settings function returned has already been held to it.
704
+ endpoints: [],
705
+ ...(fields["endpoint"] === undefined
706
+ ? {}
707
+ : { endpoint: endpointOf(file, "endpoint", fields["endpoint"]) }),
320
708
  ...(fields["entry"] === undefined ? {} : { entry: entryOf(file, fields["entry"]) }),
321
709
  upstream: upstreamOf(file, fields["upstream"]),
322
710
  direct_delivery: flagOf(
@@ -469,12 +857,6 @@ function terminalGatewayOf(file: string, raw: string): string {
469
857
  return raw;
470
858
  }
471
859
 
472
- function peersOf(file: string, raw: unknown): readonly Endpoint[] {
473
- if (raw === undefined) return [];
474
- if (!Array.isArray(raw)) throw new ConfigError(file, "peers must be an array of endpoint URLs");
475
- return raw.map((peer, index) => endpointOf(file, `peers[${index}]`, peer));
476
- }
477
-
478
860
  function entryOf(file: string, raw: unknown): EntryConfig {
479
861
  const fields = objectOf(file, "entry", raw);
480
862
  const port = fields["port"];