@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.
@@ -2,9 +2,10 @@ import { PROTOCOL_VERSION } from "@ccmsg/protocol";
2
2
 
3
3
  /** One short exchange over an instance's unix socket.
4
4
  *
5
- * The CLI asks one thing at a time, so a `request_id` is a counter and the
6
- * answer is the next frame: nothing here needs correlation, and the connection
7
- * lives no longer than the command that opened it. */
5
+ * The CLI asks one thing at a time, so a `request_id` is a counter and the
6
+ * answer is the frame carrying it, not the next one to arrive: an instance
7
+ * pushes topic frames of its own accord, and one landing mid-exchange would
8
+ * otherwise be read as the reply. */
8
9
  export interface Conn {
9
10
  ask(request: Record<string, unknown>): Promise<Record<string, unknown>>;
10
11
  /** The next frame the instance sends without having been asked for one: a
@@ -35,8 +36,12 @@ export async function connect(path: string): Promise<Conn | undefined> {
35
36
  return {
36
37
  ask(request) {
37
38
  counter += 1;
38
- socket.write(`${JSON.stringify({ request_id: `${counter}`, ...request })}\n`);
39
- return replies.next();
39
+ const id = `${counter}`;
40
+ // Registered before the write, so a reply that arrives in the same turn
41
+ // is the one this call settles on.
42
+ const answer = replies.answer(id);
43
+ socket.write(`${JSON.stringify({ request_id: id, ...request })}\n`);
44
+ return answer;
40
45
  },
41
46
  next() {
42
47
  return replies.next();
@@ -55,9 +60,18 @@ export function greetAsUser(conn: Conn): Promise<Record<string, unknown>> {
55
60
  return conn.ask({ op: "hello.user", protocol_version: PROTOCOL_VERSION });
56
61
  }
57
62
 
58
- /** Reassemble the replies of one exchange, by arrival order. */
63
+ /** Sort what arrives on one connection into the answer somebody is waiting for
64
+ * and everything else.
65
+ *
66
+ * By `request_id` rather than by arrival order, because the two are not the
67
+ * same stream: an instance pushes topic frames of its own accord (§6), and one
68
+ * of those landing between a request and its reply would otherwise be read as
69
+ * the reply. It is not a rare window — greeting an instance that has mesh peers
70
+ * is enough, since a peer connecting moves a row on `instances`. */
59
71
  class Replies {
60
- readonly #ready: Record<string, unknown>[] = [];
72
+ readonly #unasked: Record<string, unknown>[] = [];
73
+ readonly #answers = new Map<string, (frame: Record<string, unknown>) => void>();
74
+ readonly #ready = new Map<string, Record<string, unknown>>();
61
75
  #waiting: ((frame: Record<string, unknown>) => void) | undefined;
62
76
  #buffer = "";
63
77
 
@@ -69,8 +83,33 @@ class Replies {
69
83
  this.#buffer = this.#buffer.slice(at + 1);
70
84
  if (line.trim() === "") continue;
71
85
  const frame = JSON.parse(line) as Record<string, unknown>;
86
+ const id = frame["request_id"];
87
+ if (typeof id === "string") {
88
+ const asked = this.#answers.get(id);
89
+ if (asked !== undefined) {
90
+ this.#answers.delete(id);
91
+ asked(frame);
92
+ continue;
93
+ }
94
+ if (this.#answers.size > 0) {
95
+ // A reply to something still being waited for by somebody who has not
96
+ // got here yet: held by its id rather than queued as unasked-for.
97
+ this.#ready.set(id, frame);
98
+ continue;
99
+ }
100
+ } else if (frame["ev"] === undefined && this.#answers.size === 1) {
101
+ // An answer that named nothing, while exactly one thing is waiting for
102
+ // one: it is that one. Matching only by id would leave a caller waiting
103
+ // for ever on a refusal raised before the request could be read, which
104
+ // is the moment an answer is most needed.
105
+ for (const [pending, settle] of this.#answers) {
106
+ this.#answers.delete(pending);
107
+ settle(frame);
108
+ }
109
+ continue;
110
+ }
72
111
  const waiting = this.#waiting;
73
- if (waiting === undefined) this.#ready.push(frame);
112
+ if (waiting === undefined) this.#unasked.push(frame);
74
113
  else {
75
114
  this.#waiting = undefined;
76
115
  waiting(frame);
@@ -78,8 +117,21 @@ class Replies {
78
117
  }
79
118
  }
80
119
 
120
+ /** The reply to one request, whenever it lands. */
121
+ answer(id: string): Promise<Record<string, unknown>> {
122
+ const already = this.#ready.get(id);
123
+ if (already !== undefined) {
124
+ this.#ready.delete(id);
125
+ return Promise.resolve(already);
126
+ }
127
+ return new Promise((resolve) => {
128
+ this.#answers.set(id, resolve);
129
+ });
130
+ }
131
+
132
+ /** The next frame nobody asked for. */
81
133
  next(): Promise<Record<string, unknown>> {
82
- const first = this.#ready.shift();
134
+ const first = this.#unasked.shift();
83
135
  if (first !== undefined) return Promise.resolve(first);
84
136
  return new Promise((resolve) => {
85
137
  this.#waiting = resolve;
@@ -1,15 +1,25 @@
1
- import { existsSync, mkdirSync, watch } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
2
2
  import { basename, isAbsolute, join, resolve } from "node:path";
3
3
  import type { Endpoint, InstanceId, InstancePingResult } from "@ccmsg/protocol";
4
- import { DEFAULT_HARNESS, type Harness, HARNESS, isHarness } from "../harness/index.ts";
4
+ import { DEFAULT_HARNESS, type Harness, HARNESS, HARNESSES } from "../harness/index.ts";
5
5
  import {
6
+ applied,
7
+ type ConfigProblem,
8
+ CONFIG_FILE,
9
+ CONFIG_NAME,
10
+ configOf,
11
+ DEFAULT_CONFIG,
12
+ type EndpointRow,
13
+ ENDPOINTS_FILE,
14
+ evaluate,
6
15
  type InstanceConfig,
7
- type InstanceEntry,
8
- loadConfig,
9
- loadShared,
10
- saveShared,
11
- settingsFor,
12
- type SharedConfig,
16
+ instanceFileName,
17
+ type InstanceSetting,
18
+ type Satisfied,
19
+ settle,
20
+ SUPERVISOR_FILE,
21
+ TYPES_FILE,
22
+ writeConfigTypes,
13
23
  } from "../instance/config.ts";
14
24
  import { instanceIdentity } from "../instance/identity.ts";
15
25
  import { alive, lockHolder } from "../instance/lock.ts";
@@ -37,25 +47,61 @@ export function configHome(dir: string, harness: Harness = DEFAULT_HARNESS): str
37
47
  return path;
38
48
  }
39
49
 
40
- /** Which harness a registered config home runs, as the shared file records it.
50
+ /** Which harness a registered config home runs, as its own file says.
41
51
  *
42
- * Read from the same entry the instance itself will read (§8.2), so a command
52
+ * Read from the same file the instance itself will read (§8.2), so a command
43
53
  * that has to know before anything is running — `run`, and the supervisor's
44
- * own start — reaches the same answer the instance does. A directory the file
45
- * does not list runs the default, which is what an unregistered `daemon run`
46
- * is. */
47
- export function harnessFor(env: Env, dir: string): Harness {
54
+ * own start — reaches the same answer the instance does. A directory no file
55
+ * names runs whatever the defaults say, which is what an unregistered
56
+ * `daemon run` is. */
57
+ export async function harnessFor(env: Env, dir: string): Promise<Harness> {
48
58
  const path = isAbsolute(dir) ? dir : resolve(dir);
49
- const settings = settingsFor(loadShared(resolvePaths(env).configFile), path);
50
- const named = settings["harness"];
51
- return isHarness(named) ? named : DEFAULT_HARNESS;
59
+ const found = (await known(env)).instances.find((one) => one.dir === path);
60
+ return found?.config.harness ?? DEFAULT_HARNESS;
61
+ }
62
+
63
+ /** What this host runs: the settings that are applied, read again from the
64
+ * files when they check out.
65
+ *
66
+ * Every command goes through the one path — read, check, apply — so what a
67
+ * command acts on is what a start would run. A config that does not check out
68
+ * leaves the applied one standing, which is what keeps a command about one
69
+ * instance working while another instance's file is being edited. */
70
+ export async function known(env: Env): Promise<Satisfied> {
71
+ const paths = resolvePaths(env);
72
+ const read = await evaluate(paths.configDir);
73
+ return read.satisfied ?? applied(paths.stateRoot) ?? EMPTY_SATISFIED;
74
+ }
75
+
76
+ const EMPTY_SATISFIED: Satisfied = {
77
+ endpoints: [],
78
+ supervisor: { instances: [] },
79
+ instances: [],
80
+ };
81
+
82
+ /** Read the files, check them, and write down what holds — the one thing a
83
+ * start, a reload, an `add` and a `remove` all do. */
84
+ export async function reload(env: Env): Promise<{
85
+ satisfied: Satisfied;
86
+ problems: readonly ConfigProblem[];
87
+ }> {
88
+ const paths = resolvePaths(env);
89
+ const settled = await settle(paths.configDir, paths.stateRoot);
90
+ return { satisfied: settled.satisfied, problems: settled.problems };
52
91
  }
53
92
 
54
93
  /** One row of `daemon list`: which config home, and whether anything answers
55
94
  * for it right now. */
56
95
  export interface InstanceRow {
57
96
  readonly id: InstanceId;
97
+ /** The label this instance is listed under: its own file's `name`, which
98
+ * defaults to its id. A `daemon run` on a config home nothing states
99
+ * settings for has none. */
100
+ readonly name?: string;
58
101
  readonly dir: string;
102
+ /** The address it binds, and the one its peers dial (§7.1). */
103
+ readonly port?: number;
104
+ readonly endpoint?: string;
59
105
  readonly running: boolean;
60
106
  readonly pid?: number;
61
107
  }
@@ -63,6 +109,9 @@ export interface InstanceRow {
63
109
  /** One row of `daemon status`: the list's row, plus what the instance itself
64
110
  * says when there is one to ask. */
65
111
  export interface StatusRow extends InstanceRow {
112
+ /** What was wrong with the files, where the applied settings are older than
113
+ * what is written. */
114
+ readonly config_problems?: readonly ConfigProblem[];
66
115
  /** What this config home's instance is configured with, after the shared
67
116
  * file's defaults and its own entry are merged (§8.2).
68
117
  *
@@ -81,18 +130,36 @@ export interface StatusRow extends InstanceRow {
81
130
 
82
131
  /** Everything one command needs to reach one config home. */
83
132
  export interface Target {
133
+ /** The label this instance is listed under, where it is registered. */
134
+ readonly name?: string;
135
+ /** Its id, which is what its file is called. */
136
+ readonly id?: string;
84
137
  readonly dir: string;
85
138
  readonly paths: InstancePaths;
86
139
  }
87
140
 
88
- export function targetFor(env: Env, dir: string): Target {
89
- return { dir, paths: resolvePathsFor(dir, env) };
141
+ export function targetFor(env: Env, dir: string, name?: string, id?: string): Target {
142
+ return {
143
+ ...(name === undefined ? {} : { name }),
144
+ ...(id === undefined ? {} : { id }),
145
+ dir,
146
+ paths: resolvePathsFor(dir, env),
147
+ };
148
+ }
149
+
150
+ /** The config homes this host starts, in the order the supervisor lists them. */
151
+ export async function registered(env: Env): Promise<Target[]> {
152
+ return (await known(env)).instances.map((entry) =>
153
+ targetFor(env, entry.dir, entry.name, entry.id),
154
+ );
90
155
  }
91
156
 
92
- /** The config homes the shared file lists, in the order it lists them. */
93
- export function registered(env: Env): Target[] {
94
- const paths = resolvePaths(env);
95
- return loadShared(paths.configFile).instances.map((entry) => targetFor(env, entry.dir));
157
+ /** The instance a command was given, by any of the three things a person has
158
+ * to hand: the label it is listed under, its id, or the directory itself. */
159
+ export async function targetNamed(env: Env, ref: string): Promise<Target | undefined> {
160
+ return (await registered(env)).find(
161
+ (target) => target.name === ref || target.id === ref || target.dir === ref,
162
+ );
96
163
  }
97
164
 
98
165
  /** The selections the shared file starts with.
@@ -138,55 +205,293 @@ const STARTING_PRESETS = [
138
205
  },
139
206
  ];
140
207
 
141
- /** Add a config home to the shared file. The settings it will run with are the
142
- * defaults until somebody edits its entry, so the entry starts empty — save
143
- * for the harness, which is written down when it is not the default because it
144
- * is the one setting the directory itself cannot be asked for (§3.8).
208
+ /** What `daemon add` takes: the config home the instance answers for, and the
209
+ * two settings a person would otherwise open the file to write. */
210
+ export interface AddOptions {
211
+ readonly harness?: Harness;
212
+ readonly port?: number;
213
+ }
214
+
215
+ /** What a config home is called, for a person reading a listing: its own last
216
+ * segment, without the dot a config home is usually hidden by.
217
+ *
218
+ * A label and not an identity — the file and everything the instance issued
219
+ * are keyed by its id, so this may be changed in the file afterwards. Taken
220
+ * from the directory because that is the one of the two that already exists;
221
+ * a directory whose name could not be a label leaves the id as the name, which
222
+ * is what a name defaults to anyway. */
223
+ export function nameFor(dir: string): string {
224
+ const name = basename(dir).replace(/^\.+/, "");
225
+ return CONFIG_NAME.test(name) ? name : "";
226
+ }
227
+
228
+ /** Which harness a config home runs, as the directory itself says.
229
+ *
230
+ * The marker file is the evidence: Claude Code keeps `settings.json` and Codex
231
+ * keeps `config.toml`, so a directory that holds one of them is that harness's
232
+ * (§3.8). A directory holding both, or neither, is not answered for — the
233
+ * first is two answers and the second is none, and guessing either way writes
234
+ * down a setting the instance will act on for the whole of its life. */
235
+ export function harnessOf(dir: string): Harness {
236
+ const found = HARNESSES.filter((harness) => existsSync(join(dir, HARNESS[harness].marker)));
237
+ const only = found[0];
238
+ if (found.length !== 1 || only === undefined) {
239
+ throw new CommandError(
240
+ "invalid_args",
241
+ found.length === 0
242
+ ? `${dir} がどの harness の config home か分かりません (${HARNESSES.map((one) => HARNESS[one].marker).join(" / ")} がありません)。--harness で指定してください`
243
+ : `${dir} は ${found.join(" と ")} の両方の目印を持っています。--harness で指定してください`,
244
+ );
245
+ }
246
+ return only;
247
+ }
248
+
249
+ /** The port the next instance listens on: one past the highest any registered
250
+ * instance holds, or the first of the range when there are none.
145
251
  *
146
- * The dump presets above go to `defaults`, and only where the file names none:
147
- * they are the same for every instance and are examples to edit, so writing
148
- * them per entry would repeat them and re-adding a config home would bring
149
- * back what somebody deleted. */
150
- export function add(env: Env, dir: string, harness: Harness = DEFAULT_HARNESS): InstanceRow {
151
- const home = configHome(dir, harness);
152
- const file = resolvePaths(env).configFile;
153
- const shared = loadShared(file);
154
- if (shared.instances.some((entry) => entry.dir === home)) {
155
- throw new CommandError("file_exists", `${home} は既に登録されています`);
252
+ * Counted from what is configured and then confirmed against the kernel,
253
+ * because the two answer different questions the first is what this host has
254
+ * already handed out, and the second is whether anything else on the machine
255
+ * is on it. A person who wants a particular port says so and gets it or gets
256
+ * the refusal. */
257
+ export const FIRST_PORT = 8643;
258
+
259
+ /** How far the search walks before it says so rather than going on. A run of
260
+ * this many taken ports is a host whose ports are somebody else's business. */
261
+ const PORT_SEARCH = 64;
262
+
263
+ export async function freePort(taken: readonly number[]): Promise<number> {
264
+ const first = taken.length === 0 ? FIRST_PORT : Math.max(...taken) + 1;
265
+ for (let port = first; port < first + PORT_SEARCH; port += 1) {
266
+ if (taken.includes(port)) continue;
267
+ if (await bindable(port)) return port;
156
268
  }
157
- const entry: InstanceEntry = {
158
- dir: home,
159
- settings: harness === DEFAULT_HARNESS ? {} : { harness },
160
- };
161
- const defaults =
162
- shared.defaults["dump"] === undefined
163
- ? { ...shared.defaults, dump: { presets: STARTING_PRESETS } }
164
- : shared.defaults;
165
- saveShared(file, { defaults, instances: [...shared.instances, entry] });
166
- const target = targetFor(env, home);
167
- // The id is made here rather than at the first start, so that what `add`
168
- // prints is what the instance will answer to and so that a person can write
169
- // the id into a peer's config before anything has run (DR-0001 §2.1).
170
- instanceIdentity(target.paths.instanceIdFile);
171
- return rowFor(target);
172
- }
173
-
174
- /** Take a config home off the list.
269
+ throw new CommandError(
270
+ "internal_error",
271
+ `${String(first)} から ${String(PORT_SEARCH)} 個のポートが全部塞がっています。--port で指定してください`,
272
+ );
273
+ }
274
+
275
+ /** Whether this host will give out an address, asked by taking it and letting
276
+ * it go again. Nothing else answers it: a port is free when the kernel says
277
+ * so, and every other account of it is out of date the moment it is read. */
278
+ async function bindable(port: number): Promise<boolean> {
279
+ try {
280
+ const server = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response("") });
281
+ await server.stop(true);
282
+ return true;
283
+ } catch {
284
+ return false;
285
+ }
286
+ }
287
+
288
+ /** Write down one more instance: one file under `instances/`, called by the
289
+ * name the instance is called.
290
+ *
291
+ * A template rather than an empty file, because what the file has to say —
292
+ * which config home, and how a setting is written at all — is exactly what a
293
+ * person adding their second instance does not yet know. It states only what
294
+ * differs from the shared file, which is what makes that file worth having:
295
+ * everything left out is whatever that file returns.
175
296
  *
176
- * The instance it names is left alone: what this changes is what the supervisor
297
+ * The shared file is written the first time, with the dump presets in it, and never
298
+ * again: what a preset names is an interest this instance has no opinion on, so
299
+ * they are examples in a file to edit rather than a default in the code that
300
+ * would come back after being deleted. */
301
+ export async function add(env: Env, dir: string, options: AddOptions = {}): Promise<InstanceRow> {
302
+ const where = isAbsolute(dir) ? dir : resolve(dir);
303
+ const harness = options.harness ?? harnessOf(where);
304
+ const home = configHome(where, harness);
305
+ const paths = resolvePaths(env);
306
+ const held = await known(env);
307
+ const taken = held.instances.find((one) => one.dir === home);
308
+ if (taken !== undefined) {
309
+ throw new CommandError("file_exists", `${home} は既に ${taken.name} として登録されています`);
310
+ }
311
+ // The id the state directory already holds, or a new one written there now:
312
+ // a config home that was registered before keeps the id everything it issued
313
+ // is keyed by, and a fresh one gets its id here rather than at its first
314
+ // start (DR-0001 §2.1).
315
+ const id = instanceIdentity(targetFor(env, home).paths.instanceIdFile);
316
+ // Every instance listens, because an instance is an entry of the mesh (§7.1)
317
+ // and a mesh is reached over the entry: what `--port` settles is which
318
+ // address, not whether there is one.
319
+ const port =
320
+ options.port ??
321
+ (await freePort(
322
+ held.instances.flatMap((one) =>
323
+ one.config.entry === undefined ? [] : [one.config.entry.port],
324
+ ),
325
+ ));
326
+ const name = nameFor(home) || id;
327
+ writeConfigTypes(paths.configDir);
328
+ if (!existsSync(paths.configFile)) writeFileSync(paths.configFile, defaultsTemplate());
329
+ mkdirSync(paths.instancesDir, { recursive: true });
330
+ writeFileSync(
331
+ join(paths.instancesDir, instanceFileName(id)),
332
+ instanceTemplate(name, home, harness, port),
333
+ );
334
+ // The loopback address, because that is the one this host is certainly
335
+ // reached at. A proxy in front of it is a deployment fact nothing here can
336
+ // see, so an operator who has one edits this row (§8.2).
337
+ saveEndpoints(paths.configDir, [
338
+ ...readEndpointRows(paths.configDir).filter((row) => row.id !== id),
339
+ { id, endpoint: `http://127.0.0.1:${String(port)}/` as EndpointRow["endpoint"] },
340
+ ]);
341
+ saveSupervisor(paths.configDir, [
342
+ ...readSupervised(paths.configDir).filter((one) => one !== id),
343
+ id,
344
+ ]);
345
+ const settled = await reload(env);
346
+ const written = configOf(settled.satisfied, home);
347
+ if (written === undefined) {
348
+ throw new CommandError(
349
+ "internal_error",
350
+ `${home} を書きましたが設定が通りませんでした: ${settled.problems
351
+ .map((one) => `${one.file}: ${one.msg}`)
352
+ .join("; ")}`,
353
+ );
354
+ }
355
+ return rowOf(env, written);
356
+ }
357
+
358
+ /** The mesh as the file holds it right now, for a command that is about to
359
+ * edit it. Read as data rather than through the checks, because a command that
360
+ * adds a row has to be able to fix a file that does not check out yet. */
361
+ function readEndpointRows(configDir: string): EndpointRow[] {
362
+ try {
363
+ const parsed = JSON.parse(readFileSync(join(configDir, ENDPOINTS_FILE), "utf8")) as unknown;
364
+ return Array.isArray(parsed) ? (parsed as EndpointRow[]) : [];
365
+ } catch {
366
+ return [];
367
+ }
368
+ }
369
+
370
+ function readSupervised(configDir: string): string[] {
371
+ try {
372
+ const parsed = JSON.parse(readFileSync(join(configDir, SUPERVISOR_FILE), "utf8")) as {
373
+ instances?: unknown;
374
+ };
375
+ return Array.isArray(parsed.instances) ? (parsed.instances as string[]) : [];
376
+ } catch {
377
+ return [];
378
+ }
379
+ }
380
+
381
+ function saveEndpoints(configDir: string, rows: readonly EndpointRow[]): void {
382
+ mkdirSync(configDir, { recursive: true });
383
+ writeFileSync(join(configDir, ENDPOINTS_FILE), `${JSON.stringify(rows, null, 2)}\n`);
384
+ }
385
+
386
+ function saveSupervisor(configDir: string, ids: readonly string[]): void {
387
+ mkdirSync(configDir, { recursive: true });
388
+ writeFileSync(
389
+ join(configDir, SUPERVISOR_FILE),
390
+ `${JSON.stringify({ instances: ids }, null, 2)}\n`,
391
+ );
392
+ }
393
+
394
+ /** Take one instance's file away.
395
+ *
396
+ * The instance it named is left alone: what this changes is what the supervisor
177
397
  * starts and what `--all` reaches, and an instance already serving a session is
178
- * not something a list edit should take away from it. `daemon stop` is how one
398
+ * not something a file edit should take away from it. `daemon stop` is how one
179
399
  * is stopped, and saying so is the point of keeping the two apart. */
180
- export function remove(env: Env, dir: string): { dir: string; removed: boolean } {
181
- const home = isAbsolute(dir) ? dir : resolve(dir);
182
- const file = resolvePaths(env).configFile;
183
- const shared: SharedConfig = loadShared(file);
184
- const kept = shared.instances.filter((entry) => entry.dir !== home);
185
- if (kept.length === shared.instances.length) {
186
- throw new CommandError("not_found", `${home} は登録されていません`);
187
- }
188
- saveShared(file, { ...shared, instances: kept });
189
- return { dir: home, removed: true };
400
+ export async function remove(
401
+ env: Env,
402
+ ref: string,
403
+ ): Promise<{ id: string; name: string; dir: string; removed: boolean }> {
404
+ const paths = resolvePaths(env);
405
+ const found = (await known(env)).instances.find(
406
+ (one) => one.id === ref || one.name === ref || one.dir === ref,
407
+ );
408
+ if (found === undefined) throw new CommandError("not_found", `${ref} は登録されていません`);
409
+ saveSupervisor(
410
+ paths.configDir,
411
+ readSupervised(paths.configDir).filter((one) => one !== found.id),
412
+ );
413
+ saveEndpoints(
414
+ paths.configDir,
415
+ readEndpointRows(paths.configDir).filter((row) => row.id !== found.id),
416
+ );
417
+ rmSync(join(paths.instancesDir, instanceFileName(found.id)), { force: true });
418
+ // The state directory stays, its id with it: what the instance issued is
419
+ // keyed by that id, and re-adding the same config home has to answer to the
420
+ // same one.
421
+ await reload(env);
422
+ return { id: found.id, name: found.name, dir: found.dir, removed: true };
423
+ }
424
+
425
+ /** The file every instance's settings start from, as it is first written. */
426
+ function defaultsTemplate(): string {
427
+ return `import type { Defaults } from "./${TYPES_FILE.replace(/\.d\.ts$/, "")}";
428
+
429
+ /** 全 instance に配る値。\`builtin\` は組み込みの既定値 (凍結済み)、\`config\` は
430
+ * そのコピーなので、書き換えて返す。ここに書いた値を各 instance が受け取る。 */
431
+ const defaults: Defaults = ({ config }) => {
432
+ // mesh はここには書かない。誰が居てどこで届くかは endpoints.json が正で、
433
+ // この関数は読めるが変えられない。
434
+
435
+ // dump の名前付き選択。prefix は一族を、\`@name\` は他の選択をその場に広げる。
436
+ config.dump.presets = [
437
+ ${STARTING_PRESETS.map((preset) => presetLiteral(preset)).join("\n")}
438
+ ];
439
+
440
+ return config;
441
+ };
442
+
443
+ export default defaults;
444
+ `;
445
+ }
446
+
447
+ /** One starting preset as a person would have typed it.
448
+ *
449
+ * Written out rather than stringified, because what this produces is a file
450
+ * somebody edits: JSON's quoted keys in the middle of a TypeScript file are
451
+ * the shape of a thing that was generated, and the next preset a person adds
452
+ * beside it would not look like it. */
453
+ function presetLiteral(preset: (typeof STARTING_PRESETS)[number]): string {
454
+ const types = preset.opts.types.map((type) => JSON.stringify(type)).join(", ");
455
+ return [
456
+ " {",
457
+ ` name: ${JSON.stringify(preset.name)},`,
458
+ ` description: ${JSON.stringify(preset.description)},`,
459
+ ` opts: { types: [${types}] },`,
460
+ " },",
461
+ ].join("\n");
462
+ }
463
+
464
+ /** One instance's file, as `add` first writes it: what differs from
465
+ * the shared file, and nothing else. */
466
+ function instanceTemplate(name: string, dir: string, harness: Harness, port: number): string {
467
+ const lines = [
468
+ ` config.name = ${JSON.stringify(name)};`,
469
+ ` config.dir = ${JSON.stringify(dir)};`,
470
+ "",
471
+ " // reverse proxy の後ろに居るなら、peer と人が届く公開 URL (末尾 /) を書く。",
472
+ " // 書かなければ下の待ち受け address がそのまま mesh の一覧に載る。",
473
+ ` // config.endpoint = "https://ccmsg-${name}.<host>/";`,
474
+ "",
475
+ ];
476
+ if (harness !== DEFAULT_HARNESS) lines.push(` config.harness = ${JSON.stringify(harness)};`);
477
+ lines.push(
478
+ ` config.entry = {`,
479
+ ` ...(config.entry ?? { host: "127.0.0.1", source_ips: [], trusted_proxies: [] }),`,
480
+ ` port: ${String(port)},`,
481
+ ` };`,
482
+ );
483
+ return `import type { Instance } from "../${TYPES_FILE.replace(/\.d\.ts$/, "")}";
484
+
485
+ /** ${name}: この instance だけの設定。\`default\` は ${CONFIG_FILE} が返した値
486
+ * (凍結済み)、\`config\` はそのコピーなので、差分だけ書き換えて返す。 */
487
+ const instance: Instance = ({ config }) => {
488
+ ${lines.join("\n")}
489
+
490
+ return config;
491
+ };
492
+
493
+ export default instance;
494
+ `;
190
495
  }
191
496
 
192
497
  /** What an instance is called, whether or not it is running.
@@ -204,20 +509,43 @@ export function rowFor(target: Target): InstanceRow {
204
509
  const running = pid !== undefined && alive(pid);
205
510
  return {
206
511
  id: idOf(target),
512
+ ...(target.name === undefined ? {} : { name: target.name }),
207
513
  dir: target.dir,
208
514
  running,
209
515
  ...(running ? { pid } : {}),
210
516
  };
211
517
  }
212
518
 
213
- export function list(env: Env): InstanceRow[] {
214
- return registered(env).map((target) => rowFor(target));
519
+ /** What `daemon list` answers: the instances this host starts, and whether
520
+ * anything answers for each right now. */
521
+ export async function list(env: Env): Promise<InstanceRow[]> {
522
+ return (await known(env)).instances.map((one) => rowOf(env, one));
523
+ }
524
+
525
+ function rowOf(env: Env, one: InstanceSetting): InstanceRow {
526
+ return {
527
+ ...rowFor(targetFor(env, one.dir, one.name, one.id)),
528
+ ...(one.config.entry === undefined ? {} : { port: one.config.entry.port }),
529
+ ...(one.config.endpoint === undefined ? {} : { endpoint: one.config.endpoint }),
530
+ };
215
531
  }
216
532
 
217
533
  /** Ask one instance how it is. A config home with nothing behind it answers the
218
534
  * list's row and nothing more: not running is a state, not a failure. */
219
535
  export async function status(target: Target): Promise<StatusRow> {
220
- const row = { ...rowFor(target), config: loadConfig(target.paths.configFile, target.dir) };
536
+ const read = await evaluate(target.paths.configDir);
537
+ const satisfied = read.satisfied ?? applied(target.paths.stateRoot) ?? EMPTY_SATISFIED;
538
+ const own = configOf(satisfied, target.dir);
539
+ const row = {
540
+ ...rowFor(target),
541
+ ...(own?.config.entry === undefined ? {} : { port: own.config.entry.port }),
542
+ ...(own?.config.endpoint === undefined ? {} : { endpoint: own.config.endpoint }),
543
+ config: own?.config ?? DEFAULT_CONFIG,
544
+ // What a person has to be told even though the instance is running: an
545
+ // edit that did not check out is not applied, and the only sign of it
546
+ // otherwise is a setting that did not take (§8.3).
547
+ ...(read.problems.length === 0 ? {} : { config_problems: read.problems }),
548
+ };
221
549
  const conn = await connect(target.paths.socket);
222
550
  if (conn === undefined) return row;
223
551
  try {