@ccmsg/cli 0.9.0 → 0.10.1

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 { isAbsolute, join } 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,30 @@ 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). */
121
+ /** Every mesh endpoint, this instance's own among them (§7.1).
122
+ *
123
+ * Derived rather than written: the instances on this host are the ones whose
124
+ * files say which port they listen on, and the rest are the endpoints
125
+ * `peers.json` names. A person who had to write the local half as well would
126
+ * be writing down a second time what `daemon add` already settled, and could
127
+ * get it wrong — which is a mesh an instance is silently not in. Which entry
128
+ * of the list is this instance is settled at startup by the probe (§7.1). */
117
129
  readonly peers: readonly Endpoint[];
130
+ /** Where peers and people reach this instance, when that is not the address
131
+ * it binds.
132
+ *
133
+ * An instance behind a reverse proxy is dialled at the proxy's name and
134
+ * listens on loopback, and the two cannot be derived from each other. It is
135
+ * what the mesh puts in its list for this instance — so it is what the probe
136
+ * settles `self` to, what a handshake carries as `iss` and `aud`, and what
137
+ * a person is handed to open a page at (§7.1). Absent leaves the address
138
+ * this instance binds, which is what a host with no proxy in front of it
139
+ * has.
140
+ *
141
+ * Stated per instance, in the file that already states which port: what a
142
+ * proxy is set up to forward where is one fact, and writing it twice is a
143
+ * second place for it to be wrong. */
144
+ readonly endpoint?: Endpoint;
118
145
  /** Absent when this instance serves the unix socket only. */
119
146
  readonly entry?: EntryConfig;
120
147
  readonly upstream: UpstreamConfig;
@@ -161,47 +188,302 @@ export const DEFAULT_CONFIG: InstanceConfig = {
161
188
  dump: { presets: [] },
162
189
  };
163
190
 
164
- /** Read the config, once, at startup (DV-Q8).
191
+ /** The file every instance's settings start from, and the directory holding
192
+ * one file per instance. Both are read from the config home a person edits
193
+ * (§8.2). The names are held here alone, so what the files are called is one
194
+ * edit rather than a search. */
195
+ export const CONFIG_FILE = "config_v2.ts";
196
+ export const INSTANCES_DIR = "instances";
197
+
198
+ /** The declarations a config file writes against, as they are called where
199
+ * they are copied to, and as this build keeps them. */
200
+ export const TYPES_FILE = "ccmsg-config_v2.d.ts";
201
+ const TYPES_SOURCE = "ccmsg-config.d.ts";
202
+
203
+ /** What the settings used to be written in. Named so a config home that still
204
+ * holds one is told where its settings have moved to, rather than starting
205
+ * with every setting it carried silently absent. */
206
+ const JSON_FILE = "config.json";
207
+
208
+ /** The fields a config function may hand back. Checked rather than ignored,
209
+ * because a misspelled field is a setting that was written and does not take:
210
+ * the types say so while the file is being edited, and this says so when it is
211
+ * read. */
212
+ const FIELDS = ["harness", "entry", "upstream", "direct_delivery", "fork_origin", "dump"] as const;
213
+
214
+ /** What only one instance's own file may state: which config home it answers
215
+ * for, and the address it is reached at. Neither is a thing the shared file
216
+ * could say once for everybody. */
217
+ const INSTANCE_FIELDS = ["dir", "name", "endpoint"] as const;
218
+
219
+ /** Which clusters this host knows of, and where each one's own file is.
220
+ *
221
+ * Data rather than a function, and a list rather than a directory listing: what
222
+ * is a cluster and what is an instance is stated, so a file nobody listed is
223
+ * not read and a file somebody listed and then deleted is an error rather than
224
+ * a cluster that quietly shrank. `ccmsg mesh` and `daemon add` write these,
225
+ * which is why they are the shape a program reads whole.
165
226
  *
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));
227
+ * A cluster's file is this host's account of that cluster. A cluster spans
228
+ * hosts and no copy of it is the canonical one: each host writes down the peers
229
+ * it dials and the instances it runs. */
230
+ export const CLUSTERS_FILE = "clusters.json";
231
+ export const CLUSTERS_DIR = "clusters";
232
+
233
+ export function clusterFileName(id: string): string {
234
+ return `cluster-${id}.json`;
235
+ }
236
+
237
+ export function instanceFileName(id: string): string {
238
+ return `instance-${id}.ts`;
171
239
  }
172
240
 
173
- /** One config home the shared file knows about.
241
+ /** What one file under `instances/` says: which config home it is for, and
242
+ * what that instance runs with.
174
243
  *
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 {
244
+ * The id is what the file is called and what everything the instance issued is
245
+ * keyed by; the name is a label a person picks and may change, and defaults to
246
+ * the id. Keeping them apart is what lets a rename be a rename — the file, the
247
+ * state directory and every record already written stay where they are. */
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;
253
+ /** The clusters this instance belongs to, in the order the host lists them.
254
+ * More than one is allowed: an instance is a config home, and which
255
+ * management units it is part of is a separate question (A2). */
256
+ readonly clusters: readonly ClusterInfo[];
181
257
  }
182
258
 
183
- /** The one file a person edits: what every instance gets, and which config
184
- * homes run one.
259
+ /** One cluster, as this host writes it down.
185
260
  *
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[];
261
+ * A cluster is the unit a person manages: some instances, one mesh, one scope
262
+ * for the authentication records that are replicated across it. Which
263
+ * instances are in it is stated here rather than discovered, so an instance
264
+ * file that nobody listed runs nothing and an id listed with no file is an
265
+ * error. */
266
+ export interface ClusterSetting {
267
+ readonly id: string;
268
+ readonly name: string;
269
+ /** The mesh endpoints of this cluster that this host does not serve itself.
270
+ * The ones it does serve are the instances listed below, at the address each
271
+ * of their files gives them. */
272
+ readonly peers: readonly Endpoint[];
273
+ readonly instances: readonly string[];
193
274
  }
194
275
 
195
- export const EMPTY_SHARED: SharedConfig = { defaults: {}, instances: [] };
276
+ /** What an instance is told about one cluster it belongs to: which cluster,
277
+ * and every mesh endpoint of it — this instance's own among them, because that
278
+ * is what the startup probe settles which entry it is against (§7.1). */
279
+ export interface ClusterInfo {
280
+ readonly id: string;
281
+ readonly name: string;
282
+ readonly peers: readonly Endpoint[];
283
+ }
284
+
285
+ /** A label a person may give an instance or a cluster.
286
+ *
287
+ * Narrow because it is typed at a command and printed in a listing, not
288
+ * because anything is found by it: files are named by id, so a name may change
289
+ * without moving anything. */
290
+ export const CONFIG_NAME = /^[a-z0-9][a-z0-9._-]*$/;
291
+
292
+ /** Everything the config home says, read once (DV-Q8).
293
+ *
294
+ * There is no watch and no reload: the files are small, an instance is cheap
295
+ * to restart because almost nothing it holds is persistent (§3.6), and
296
+ * restarting is therefore the whole of "apply a config change" (§8.2).
297
+ *
298
+ * The functions are handed frozen copies of what they build on and a mutable
299
+ * copy of their own starting point, so what an instance runs with is what its
300
+ * file returned: there is no merge rule to know, because the file does the
301
+ * combining itself and can see exactly what it is combining with. */
302
+ export async function loadAll(configDir: string): Promise<{
303
+ readonly defaults: InstanceConfig;
304
+ readonly clusters: readonly ClusterSetting[];
305
+ readonly instances: readonly InstanceSetting[];
306
+ }> {
307
+ const file = join(configDir, CONFIG_FILE);
308
+ if (!existsSync(file)) {
309
+ const legacy = join(configDir, JSON_FILE);
310
+ if (existsSync(legacy)) {
311
+ throw new ConfigError(
312
+ legacy,
313
+ `settings are TypeScript now: write ${file}, ${join(configDir, CLUSTERS_FILE)} and ${join(configDir, INSTANCES_DIR, instanceFileName("<id>"))}`,
314
+ );
315
+ }
316
+ return { defaults: DEFAULT_CONFIG, clusters: [], instances: [] };
317
+ }
318
+ const returned = await called(file, {
319
+ builtin: frozen(DEFAULT_CONFIG),
320
+ config: copied(DEFAULT_CONFIG),
321
+ });
322
+ const defaults = settingsOf(file, returned, false).config;
323
+ const clusters = loadClusters(configDir);
324
+
325
+ // Every instance any cluster lists, read once however many clusters list it:
326
+ // an instance is one config home and one process, and belonging to two
327
+ // clusters is not being two of anything.
328
+ const own = new Map<string, { name: string; dir: string; config: InstanceConfig }>();
329
+ const homes = new Map<string, string>();
330
+ for (const cluster of clusters) {
331
+ for (const id of cluster.instances) {
332
+ if (own.has(id)) continue;
333
+ const at = join(configDir, INSTANCES_DIR, instanceFileName(id));
334
+ if (!existsSync(at)) {
335
+ throw new ConfigError(
336
+ join(configDir, CLUSTERS_DIR, clusterFileName(cluster.id)),
337
+ `names instance ${id}, whose file ${at} is not there`,
338
+ );
339
+ }
340
+ const answer = await called(at, {
341
+ builtin: frozen(DEFAULT_CONFIG),
342
+ default: frozen(defaults),
343
+ config: { ...copied(defaults), dir: "", name: id },
344
+ });
345
+ const settings = settingsOf(at, answer, true);
346
+ // Two instances answering for one config home would take each other's
347
+ // lock and state (A2), so which of the two files is wrong is asked here
348
+ // rather than discovered as a start that never settles.
349
+ const already = homes.get(settings.dir);
350
+ if (already !== undefined) {
351
+ throw new ConfigError(at, `dir ${settings.dir} is already what ${already} answers for`);
352
+ }
353
+ homes.set(settings.dir, instanceFileName(id));
354
+ own.set(id, {
355
+ name: settings.name === "" ? id : settings.name,
356
+ dir: settings.dir,
357
+ config: settings.config,
358
+ });
359
+ }
360
+ }
361
+
362
+ // What each cluster's mesh is: its own remote peers, and the instances of
363
+ // this host that are in it, each at the address its file gives it (§7.1).
364
+ const meshes = new Map<string, ClusterInfo>();
365
+ for (const cluster of clusters) {
366
+ const mesh: Endpoint[] = [];
367
+ for (const id of cluster.instances) {
368
+ const reached = endpointOfInstance(own.get(id)?.config);
369
+ if (reached !== undefined && !mesh.includes(reached)) mesh.push(reached);
370
+ }
371
+ for (const peer of cluster.peers) if (!mesh.includes(peer)) mesh.push(peer);
372
+ meshes.set(cluster.id, { id: cluster.id, name: cluster.name, peers: mesh });
373
+ }
374
+
375
+ const instances = [...own].map(([id, held]) => {
376
+ const mine = clusters
377
+ .filter((cluster) => cluster.instances.includes(id))
378
+ .flatMap((cluster) => {
379
+ const info = meshes.get(cluster.id);
380
+ return info === undefined ? [] : [info];
381
+ });
382
+ // The mesh this instance dials is every cluster it is in. Holding the
383
+ // clusters apart as well is what the isolation between them will be built
384
+ // on; what it does today is say which are which.
385
+ const peers: Endpoint[] = [];
386
+ for (const cluster of mine) {
387
+ for (const peer of cluster.peers) if (!peers.includes(peer)) peers.push(peer);
388
+ }
389
+ return {
390
+ id,
391
+ name: held.name,
392
+ dir: held.dir,
393
+ config: { ...held.config, peers },
394
+ clusters: mine,
395
+ };
396
+ });
397
+ return { defaults, clusters, instances };
398
+ }
399
+
400
+ /** Where a peer reaches one instance: what its file says it is reached at, and
401
+ * failing that the address it binds. Neither is an instance that serves the
402
+ * unix socket alone, which is in nobody's mesh. */
403
+ function endpointOfInstance(config: InstanceConfig | undefined): Endpoint | undefined {
404
+ if (config === undefined) return undefined;
405
+ if (config.endpoint !== undefined) return config.endpoint;
406
+ return config.entry === undefined ? undefined : localEndpoint(config.entry);
407
+ }
408
+
409
+ /** The clusters this host knows of, in the order it lists them. */
410
+ export function loadClusters(configDir: string): readonly ClusterSetting[] {
411
+ const file = join(configDir, CLUSTERS_FILE);
412
+ const top = readJson(file);
413
+ if (top === undefined) return [];
414
+ const listed = (top as { clusters?: unknown })["clusters"];
415
+ if (!Array.isArray(listed) || listed.some((id) => !ID.test(String(id)))) {
416
+ throw new ConfigError(file, "clusters must be an array of cluster ids");
417
+ }
418
+ const ids = listed as string[];
419
+ const repeated = ids.filter((id, index) => ids.indexOf(id) !== index);
420
+ if (repeated.length > 0) {
421
+ throw new ConfigError(file, `repeats ${[...new Set(repeated)].join(", ")}`);
422
+ }
423
+ return ids.map((id) => loadCluster(configDir, id));
424
+ }
425
+
426
+ /** One cluster's own file. Listed and missing is an error: a cluster whose
427
+ * instances could not be read is a mesh silently short of them. */
428
+ export function loadCluster(configDir: string, id: string): ClusterSetting {
429
+ const file = join(configDir, CLUSTERS_DIR, clusterFileName(id));
430
+ const fields = readJson(file);
431
+ if (fields === undefined) {
432
+ throw new ConfigError(
433
+ join(configDir, CLUSTERS_FILE),
434
+ `names cluster ${id}, whose file ${file} is not there`,
435
+ );
436
+ }
437
+ const name = fields["name"];
438
+ if (name !== undefined && (typeof name !== "string" || !CONFIG_NAME.test(name))) {
439
+ throw new ConfigError(file, "name must be a label in lower case, digits, dots, dashes");
440
+ }
441
+ const peers = fields["peers"];
442
+ if (peers !== undefined && !Array.isArray(peers)) {
443
+ throw new ConfigError(file, "peers must be an array of endpoint URLs");
444
+ }
445
+ const read = ((peers ?? []) as unknown[]).map((peer, index) =>
446
+ endpointOf(file, `peers[${String(index)}]`, peer),
447
+ );
448
+ const twice = read.filter((peer, index) => read.indexOf(peer) !== index);
449
+ if (twice.length > 0)
450
+ throw new ConfigError(file, `peers repeats ${[...new Set(twice)].join(", ")}`);
451
+ const instances = fields["instances"];
452
+ if (
453
+ instances !== undefined &&
454
+ (!Array.isArray(instances) || instances.some((one) => !ID.test(String(one))))
455
+ ) {
456
+ throw new ConfigError(file, "instances must be an array of instance ids");
457
+ }
458
+ return {
459
+ id,
460
+ name: typeof name === "string" ? name : id,
461
+ peers: read,
462
+ instances: (instances ?? []) as string[],
463
+ };
464
+ }
465
+
466
+ /** Write one cluster's file back, at the shape a person reads it in. */
467
+ export function saveCluster(configDir: string, cluster: ClusterSetting): void {
468
+ mkdirSync(join(configDir, CLUSTERS_DIR), { recursive: true });
469
+ writeFileSync(
470
+ join(configDir, CLUSTERS_DIR, clusterFileName(cluster.id)),
471
+ `${JSON.stringify({ name: cluster.name, peers: cluster.peers, instances: cluster.instances }, null, 2)}\n`,
472
+ );
473
+ }
196
474
 
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 {
475
+ /** Write down which clusters there are. */
476
+ export function saveClusters(configDir: string, ids: readonly string[]): void {
477
+ mkdirSync(configDir, { recursive: true });
478
+ writeFileSync(join(configDir, CLUSTERS_FILE), `${JSON.stringify({ clusters: ids }, null, 2)}\n`);
479
+ }
480
+
481
+ function readJson(file: string): Record<string, unknown> | undefined {
200
482
  let text: string;
201
483
  try {
202
484
  text = readFileSync(file, "utf8");
203
485
  } catch {
204
- return EMPTY_SHARED;
486
+ return undefined;
205
487
  }
206
488
  let parsed: unknown;
207
489
  try {
@@ -209,114 +491,156 @@ export function loadShared(file: string): SharedConfig {
209
491
  } catch (cause) {
210
492
  throw new ConfigError(file, `not valid JSON (${String(cause)})`);
211
493
  }
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`);
216
- }
217
- }
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");
221
- }
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`);
228
- }
229
- if (seen.has(dir)) throw new ConfigError(file, `instances[${index}].dir repeats ${dir}`);
230
- seen.add(dir);
231
- return { dir, settings };
232
- });
233
- return {
234
- defaults: top["defaults"] === undefined ? {} : objectOf(file, "defaults", top["defaults"]),
235
- instances,
236
- };
494
+ return objectOf(file, "the top level", parsed);
495
+ }
496
+
497
+ /** The address another instance on this host is dialled at, when its file
498
+ * states no public one.
499
+ *
500
+ * A bind of every address is not an address, so a host that listens on all of
501
+ * them is reached at the loopback one — the peer doing the dialling is on this
502
+ * machine, and that is the address it has. */
503
+ function localEndpoint(entry: EntryConfig): Endpoint {
504
+ const host = entry.host === "0.0.0.0" || entry.host === "::" ? "127.0.0.1" : entry.host;
505
+ const at = host.includes(":") ? `[${host}]` : host;
506
+ return `http://${at}:${String(entry.port)}/` as Endpoint;
237
507
  }
238
508
 
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`);
509
+ /** The instances this host runs, in the order its clusters list them. */
510
+ export async function loadInstances(configDir: string): Promise<readonly InstanceSetting[]> {
511
+ return (await loadAll(configDir)).instances;
244
512
  }
245
513
 
246
- /** How one field of the shared file combines an instance's entry with the
247
- * defaults.
514
+ /** What one config home's instance runs with. A config home no cluster lists
515
+ * still resolves — `daemon run` on an unregistered directory is what
516
+ * `config.ts` returns, plus the built-ins. */
517
+ export async function loadConfig(configDir: string, dir: string): Promise<InstanceConfig> {
518
+ const all = await loadAll(configDir);
519
+ return all.instances.find((one) => one.dir === dir)?.config ?? all.defaults;
520
+ }
521
+
522
+ /** Put the declarations a config file writes against beside the files that
523
+ * write against them.
248
524
  *
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";
525
+ * Copied into the config home rather than reached where this build keeps them:
526
+ * a relative `import type` resolves with no tsconfig and no node_modules
527
+ * anywhere near it, and it goes on resolving when this checkout moves. */
528
+ export function writeConfigTypes(configDir: string): string {
529
+ const at = join(configDir, TYPES_FILE);
530
+ mkdirSync(configDir, { recursive: true });
531
+ copyFileSync(new URL(`./${TYPES_SOURCE}`, import.meta.url).pathname, at);
532
+ return at;
533
+ }
252
534
 
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.
535
+ /** Import one config file and call what it exports.
255
536
  *
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
- };
537
+ * The modified time rides on the specifier because an import is cached by it:
538
+ * a file read again in the same process after being edited a supervisor
539
+ * asked to add an instance, a test writing two configs would otherwise be
540
+ * the first read over again. */
541
+ async function called(file: string, ctx: Record<string, unknown>): Promise<unknown> {
542
+ let module: { default?: unknown };
543
+ try {
544
+ module = (await import(`${file}?mtime=${String(statSync(file).mtimeMs)}`)) as {
545
+ default?: unknown;
546
+ };
547
+ } catch (cause) {
548
+ throw new ConfigError(file, `cannot be loaded (${String(cause)})`);
549
+ }
550
+ const define = module.default;
551
+ if (typeof define !== "function") {
552
+ throw new ConfigError(
553
+ file,
554
+ "must default export a function taking { config } and returning it",
555
+ );
556
+ }
557
+ try {
558
+ return await (define as (given: unknown) => unknown)(ctx);
559
+ } catch (cause) {
560
+ if (cause instanceof ConfigError) throw cause;
561
+ throw new ConfigError(file, `threw while being read (${String(cause)})`);
562
+ }
563
+ }
280
564
 
281
- function ruleFor(path: string): MergeRule {
282
- return MERGE_RULES[path] ?? "replace";
565
+ /** What one config function handed back, checked at the shape an instance uses
566
+ * it. */
567
+ function settingsOf(
568
+ file: string,
569
+ returned: unknown,
570
+ wantsDir: boolean,
571
+ ): { dir: string; name: string; config: InstanceConfig } {
572
+ const fields = objectOf(file, "what the config function returned", returned);
573
+ for (const name of Object.keys(fields)) {
574
+ if ((INSTANCE_FIELDS as readonly string[]).includes(name)) {
575
+ if (wantsDir) continue;
576
+ throw new ConfigError(
577
+ file,
578
+ `${name} belongs to an ${INSTANCES_DIR}/ file, which this is not`,
579
+ );
580
+ }
581
+ if (name === "peers") {
582
+ // Said as its own refusal rather than as an unknown field, because a
583
+ // person writing one is not misspelling anything: they are stating a
584
+ // mesh, and the answer is where a mesh is stated now.
585
+ throw new ConfigError(
586
+ file,
587
+ `peers are not written here: a mesh belongs to a cluster, so the instances of one are the ids its ${CLUSTERS_DIR}/ file lists and the rest are that file's peers (ccmsg mesh add)`,
588
+ );
589
+ }
590
+ if (!(FIELDS as readonly string[]).includes(name)) {
591
+ throw new ConfigError(file, `unknown field ${name}; expected ${FIELDS.join(", ")}`);
592
+ }
593
+ }
594
+ const dir = fields["dir"];
595
+ if (wantsDir && (typeof dir !== "string" || !isAbsolute(dir))) {
596
+ throw new ConfigError(file, "dir must be the absolute config home this instance answers for");
597
+ }
598
+ const name = fields["name"];
599
+ if (name !== undefined && (typeof name !== "string" || !CONFIG_NAME.test(name))) {
600
+ throw new ConfigError(file, "name must be a label in lower case, digits, dots, dashes");
601
+ }
602
+ return {
603
+ dir: wantsDir ? (dir as string) : "",
604
+ name: typeof name === "string" ? name : "",
605
+ config: parseConfig(file, fields),
606
+ };
283
607
  }
284
608
 
285
- function plainObject(raw: unknown): raw is Record<string, unknown> {
286
- return typeof raw === "object" && raw !== null && !Array.isArray(raw);
609
+ /** A copy nothing can write to, for the values a config function builds on
610
+ * rather than edits: what `builtin` and `default` are is settled before the
611
+ * file runs, so a file that tried to edit one is told so where it did it. */
612
+ function frozen(value: InstanceConfig): Record<string, unknown> {
613
+ return deepFreeze(copied(value));
287
614
  }
288
615
 
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;
302
- }
303
- return out;
616
+ function deepFreeze<T>(value: T): T {
617
+ if (typeof value !== "object" || value === null) return value;
618
+ for (const held of Object.values(value)) deepFreeze(held);
619
+ return Object.freeze(value);
304
620
  }
305
621
 
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 ?? {}, "");
622
+ /** The mutable copy a config function edits and returns.
623
+ *
624
+ * Without `peers`, which is the one field of a config that no file writes: it
625
+ * is derived from the instances of this host and `peers.json`, so handing it
626
+ * over would be offering a value that is ignored — and a file that returned it
627
+ * unchanged would be returning a field this refuses. */
628
+ function copied(value: InstanceConfig): Record<string, unknown> {
629
+ const { peers: _derived, ...written } = structuredClone(value);
630
+ return written as unknown as Record<string, unknown>;
313
631
  }
314
632
 
315
633
  /** One instance's settings, read at the shape the instance uses them. */
316
634
  export function parseConfig(file: string, fields: Record<string, unknown>): InstanceConfig {
317
635
  return {
318
636
  harness: harnessOf(file, fields["harness"]),
319
- peers: peersOf(file, fields["peers"]),
637
+ // Filled in by whoever read the config home, which is the only place the
638
+ // mesh is known: one file states one instance, and a mesh is every one of
639
+ // them plus what `peers.json` names.
640
+ peers: [],
641
+ ...(fields["endpoint"] === undefined
642
+ ? {}
643
+ : { endpoint: endpointOf(file, "endpoint", fields["endpoint"]) }),
320
644
  ...(fields["entry"] === undefined ? {} : { entry: entryOf(file, fields["entry"]) }),
321
645
  upstream: upstreamOf(file, fields["upstream"]),
322
646
  direct_delivery: flagOf(
@@ -469,12 +793,6 @@ function terminalGatewayOf(file: string, raw: string): string {
469
793
  return raw;
470
794
  }
471
795
 
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
796
  function entryOf(file: string, raw: unknown): EntryConfig {
479
797
  const fields = objectOf(file, "entry", raw);
480
798
  const port = fields["port"];