@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.
@@ -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,17 +1,23 @@
1
- import { existsSync, mkdirSync, watch } from "node:fs";
1
+ import { existsSync, mkdirSync, 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
+ type ClusterInfo,
7
+ type ClusterSetting,
8
+ CONFIG_FILE,
9
+ CONFIG_NAME,
6
10
  type InstanceConfig,
7
- type InstanceEntry,
11
+ instanceFileName,
12
+ loadAll,
8
13
  loadConfig,
9
- loadShared,
10
- saveShared,
11
- settingsFor,
12
- type SharedConfig,
14
+ loadInstances,
15
+ saveCluster,
16
+ saveClusters,
17
+ TYPES_FILE,
18
+ writeConfigTypes,
13
19
  } from "../instance/config.ts";
14
- import { instanceIdentity } from "../instance/identity.ts";
20
+ import { ID, instanceIdentity, newId } from "../instance/identity.ts";
15
21
  import { alive, lockHolder } from "../instance/lock.ts";
16
22
  import { type Env, type InstancePaths, resolvePaths, resolvePathsFor } from "../instance/paths.ts";
17
23
  import { prepareSocketDir } from "../instance/socket.ts";
@@ -37,25 +43,33 @@ export function configHome(dir: string, harness: Harness = DEFAULT_HARNESS): str
37
43
  return path;
38
44
  }
39
45
 
40
- /** Which harness a registered config home runs, as the shared file records it.
46
+ /** Which harness a registered config home runs, as its own file says.
41
47
  *
42
- * Read from the same entry the instance itself will read (§8.2), so a command
48
+ * Read from the same file the instance itself will read (§8.2), so a command
43
49
  * 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 {
50
+ * own start — reaches the same answer the instance does. A directory no file
51
+ * names runs whatever the defaults say, which is what an unregistered
52
+ * `daemon run` is. */
53
+ export async function harnessFor(env: Env, dir: string): Promise<Harness> {
48
54
  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;
55
+ return (await loadConfig(resolvePaths(env).configDir, path)).harness;
52
56
  }
53
57
 
54
58
  /** One row of `daemon list`: which config home, and whether anything answers
55
59
  * for it right now. */
56
60
  export interface InstanceRow {
57
61
  readonly id: InstanceId;
62
+ /** The label this instance is listed under: its own file's `name`, which
63
+ * defaults to its id. A `daemon run` on a config home no cluster lists has
64
+ * none. */
65
+ readonly name?: string;
66
+ /** Which cluster this row was read through. An instance in two clusters is
67
+ * one instance and one process, listed once under each of them. */
68
+ readonly cluster_id?: string;
69
+ readonly cluster_name?: string;
58
70
  readonly dir: string;
71
+ /** Where peers reach it, as its file states or as its entry implies. */
72
+ readonly port?: number;
59
73
  readonly running: boolean;
60
74
  readonly pid?: number;
61
75
  }
@@ -81,18 +95,46 @@ export interface StatusRow extends InstanceRow {
81
95
 
82
96
  /** Everything one command needs to reach one config home. */
83
97
  export interface Target {
98
+ /** The label this instance is listed under, where a cluster lists it. */
99
+ readonly name?: string;
100
+ /** Its id, which is what its file is called. */
101
+ readonly id?: string;
102
+ /** The clusters it belongs to, as the host writes them down. */
103
+ readonly clusters?: readonly ClusterInfo[];
84
104
  readonly dir: string;
85
105
  readonly paths: InstancePaths;
86
106
  }
87
107
 
88
- export function targetFor(env: Env, dir: string): Target {
89
- return { dir, paths: resolvePathsFor(dir, env) };
108
+ export function targetFor(
109
+ env: Env,
110
+ dir: string,
111
+ name?: string,
112
+ id?: string,
113
+ clusters?: readonly ClusterInfo[],
114
+ ): Target {
115
+ return {
116
+ ...(name === undefined ? {} : { name }),
117
+ ...(id === undefined ? {} : { id }),
118
+ ...(clusters === undefined ? {} : { clusters }),
119
+ dir,
120
+ paths: resolvePathsFor(dir, env),
121
+ };
90
122
  }
91
123
 
92
- /** The config homes the shared file lists, in the order it lists them. */
93
- export function registered(env: Env): Target[] {
124
+ /** The config homes this host's clusters list, each once. */
125
+ export async function registered(env: Env): Promise<Target[]> {
94
126
  const paths = resolvePaths(env);
95
- return loadShared(paths.configFile).instances.map((entry) => targetFor(env, entry.dir));
127
+ return (await loadInstances(paths.configDir)).map((entry) =>
128
+ targetFor(env, entry.dir, entry.name, entry.id, entry.clusters),
129
+ );
130
+ }
131
+
132
+ /** The instance a command was given, by any of the three things a person has
133
+ * to hand: the label it is listed under, its id, or the directory itself. */
134
+ export async function targetNamed(env: Env, ref: string): Promise<Target | undefined> {
135
+ return (await registered(env)).find(
136
+ (target) => target.name === ref || target.id === ref || target.dir === ref,
137
+ );
96
138
  }
97
139
 
98
140
  /** The selections the shared file starts with.
@@ -138,55 +180,294 @@ const STARTING_PRESETS = [
138
180
  },
139
181
  ];
140
182
 
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).
183
+ /** What `daemon add` takes: the config home the instance answers for, and the
184
+ * two settings a person would otherwise open the file to write. */
185
+ export interface AddOptions {
186
+ /** Which cluster the instance joins, by id or by name. With none said: the
187
+ * one cluster there is, a new one where there is none, and a refusal where
188
+ * there are several — the last because which management unit an instance
189
+ * belongs to is not something to guess at. An id nothing answers to is a
190
+ * cluster this host has not met yet and is made under that id, which is how
191
+ * a second host joins one. */
192
+ readonly cluster?: string;
193
+ readonly harness?: Harness;
194
+ readonly port?: number;
195
+ }
196
+
197
+ /** What a config home is called, for a person reading a listing: its own last
198
+ * segment, without the dot a config home is usually hidden by.
145
199
  *
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} は既に登録されています`);
200
+ * A label and not an identity the file and everything the instance issued
201
+ * are keyed by its id, so this may be changed in the file afterwards. Taken
202
+ * from the directory because that is the one of the two that already exists;
203
+ * a directory whose name could not be a label leaves the id as the name, which
204
+ * is what a name defaults to anyway. */
205
+ export function nameFor(dir: string): string {
206
+ const name = basename(dir).replace(/^\.+/, "");
207
+ return CONFIG_NAME.test(name) ? name : "";
208
+ }
209
+
210
+ /** Which harness a config home runs, as the directory itself says.
211
+ *
212
+ * The marker file is the evidence: Claude Code keeps `settings.json` and Codex
213
+ * keeps `config.toml`, so a directory that holds one of them is that harness's
214
+ * (§3.8). A directory holding both, or neither, is not answered for — the
215
+ * first is two answers and the second is none, and guessing either way writes
216
+ * down a setting the instance will act on for the whole of its life. */
217
+ export function harnessOf(dir: string): Harness {
218
+ const found = HARNESSES.filter((harness) => existsSync(join(dir, HARNESS[harness].marker)));
219
+ const only = found[0];
220
+ if (found.length !== 1 || only === undefined) {
221
+ throw new CommandError(
222
+ "invalid_args",
223
+ found.length === 0
224
+ ? `${dir} がどの harness の config home か分かりません (${HARNESSES.map((one) => HARNESS[one].marker).join(" / ")} がありません)。--harness で指定してください`
225
+ : `${dir} は ${found.join(" と ")} の両方の目印を持っています。--harness で指定してください`,
226
+ );
156
227
  }
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] });
228
+ return only;
229
+ }
230
+
231
+ /** The port the next instance listens on: one past the highest any registered
232
+ * instance holds, or the first of the range when there are none.
233
+ *
234
+ * Counted from what is configured and then confirmed against the kernel,
235
+ * because the two answer different questions — the first is what this host has
236
+ * already handed out, and the second is whether anything else on the machine
237
+ * is on it. A person who wants a particular port says so and gets it or gets
238
+ * the refusal. */
239
+ export const FIRST_PORT = 8643;
240
+
241
+ /** How far the search walks before it says so rather than going on. A run of
242
+ * this many taken ports is a host whose ports are somebody else's business. */
243
+ const PORT_SEARCH = 64;
244
+
245
+ export async function freePort(taken: readonly number[]): Promise<number> {
246
+ const first = taken.length === 0 ? FIRST_PORT : Math.max(...taken) + 1;
247
+ for (let port = first; port < first + PORT_SEARCH; port += 1) {
248
+ if (taken.includes(port)) continue;
249
+ if (await bindable(port)) return port;
250
+ }
251
+ throw new CommandError(
252
+ "internal_error",
253
+ `${String(first)} から ${String(PORT_SEARCH)} 個のポートが全部塞がっています。--port で指定してください`,
254
+ );
255
+ }
256
+
257
+ /** Whether this host will give out an address, asked by taking it and letting
258
+ * it go again. Nothing else answers it: a port is free when the kernel says
259
+ * so, and every other account of it is out of date the moment it is read. */
260
+ async function bindable(port: number): Promise<boolean> {
261
+ try {
262
+ const server = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response("") });
263
+ await server.stop(true);
264
+ return true;
265
+ } catch {
266
+ return false;
267
+ }
268
+ }
269
+
270
+ /** Write down one more instance: one file under `instances/`, called by the
271
+ * name the instance is called.
272
+ *
273
+ * A template rather than an empty file, because what the file has to say —
274
+ * which config home, and how a setting is written at all — is exactly what a
275
+ * person adding their second instance does not yet know. It states only what
276
+ * differs from the shared file, which is what makes that file worth having:
277
+ * everything left out is whatever that file returns.
278
+ *
279
+ * The shared file is written the first time, with the dump presets in it, and never
280
+ * again: what a preset names is an interest this instance has no opinion on, so
281
+ * they are examples in a file to edit rather than a default in the code that
282
+ * would come back after being deleted. */
283
+ export async function add(env: Env, dir: string, options: AddOptions = {}): Promise<InstanceRow> {
284
+ const where = isAbsolute(dir) ? dir : resolve(dir);
285
+ const harness = options.harness ?? harnessOf(where);
286
+ const home = configHome(where, harness);
287
+ const paths = resolvePaths(env);
288
+ const all = await loadAll(paths.configDir);
289
+ const taken = all.instances.find((one) => one.dir === home);
290
+ if (taken !== undefined) {
291
+ throw new CommandError("file_exists", `${home} は既に ${taken.name} として登録されています`);
292
+ }
293
+ const cluster = clusterFor(all.clusters, options.cluster);
294
+ // The id the state directory already holds, or a new one written there now:
295
+ // a config home that was registered before keeps the id everything it issued
296
+ // is keyed by, and a fresh one gets its id here rather than at its first
297
+ // start (DR-0001 §2.1).
166
298
  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);
299
+ const id = instanceIdentity(target.paths.instanceIdFile);
300
+ // Every instance listens, because an instance is in the mesh of its cluster
301
+ // (§7.1) and a mesh is reached over the entry: what `--port` settles is which
302
+ // address, not whether there is one.
303
+ const port =
304
+ options.port ??
305
+ (await freePort(
306
+ all.instances.flatMap((one) =>
307
+ one.config.entry === undefined ? [] : [one.config.entry.port],
308
+ ),
309
+ ));
310
+ const name = nameFor(home) || id;
311
+ writeConfigTypes(paths.configDir);
312
+ if (!existsSync(paths.configFile)) writeFileSync(paths.configFile, defaultsTemplate());
313
+ mkdirSync(paths.instancesDir, { recursive: true });
314
+ writeFileSync(
315
+ join(paths.instancesDir, instanceFileName(id)),
316
+ instanceTemplate(name, home, harness, port),
317
+ );
318
+ saveCluster(paths.configDir, {
319
+ ...cluster,
320
+ instances: cluster.instances.includes(id) ? cluster.instances : [...cluster.instances, id],
321
+ });
322
+ saveClusters(
323
+ paths.configDir,
324
+ all.clusters.some((one) => one.id === cluster.id)
325
+ ? all.clusters.map((one) => one.id)
326
+ : [...all.clusters.map((one) => one.id), cluster.id],
327
+ );
328
+ return {
329
+ ...rowFor(targetFor(env, home, name, id)),
330
+ cluster_id: cluster.id,
331
+ cluster_name: cluster.name,
332
+ port,
333
+ };
334
+ }
335
+
336
+ /** The cluster a command is about.
337
+ *
338
+ * Named or not, the answer has to be one cluster: a command that acted on "the
339
+ * clusters" would be deciding for a person which management unit a thing
340
+ * belongs to. An id this host has not met is a cluster that exists elsewhere —
341
+ * a cluster spans hosts — so it is written down under that id rather than
342
+ * refused, which is what lets a second host join one. */
343
+ export function clusterFor(
344
+ clusters: readonly ClusterSetting[],
345
+ named: string | undefined,
346
+ ): ClusterSetting {
347
+ if (named !== undefined) {
348
+ const found = clusters.find((one) => one.id === named || one.name === named);
349
+ if (found !== undefined) return found;
350
+ if (!ID.test(named)) {
351
+ throw new CommandError(
352
+ "not_found",
353
+ `${named} という cluster はありません (新しく作るなら id を渡してください)`,
354
+ );
355
+ }
356
+ return { id: named, name: named, peers: [], instances: [] };
357
+ }
358
+ const only = clusters[0];
359
+ if (clusters.length === 1 && only !== undefined) return only;
360
+ if (clusters.length === 0) {
361
+ const id = newId();
362
+ return { id, name: id, peers: [], instances: [] };
363
+ }
364
+ throw new CommandError(
365
+ "invalid_args",
366
+ `cluster が ${String(clusters.length)} 個あります。--cluster <id|name> で選んでください (${clusters.map((one) => one.name).join(", ")})`,
367
+ );
172
368
  }
173
369
 
174
- /** Take a config home off the list.
370
+ /** Take one instance's file away.
175
371
  *
176
- * The instance it names is left alone: what this changes is what the supervisor
372
+ * The instance it named is left alone: what this changes is what the supervisor
177
373
  * 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
374
+ * not something a file edit should take away from it. `daemon stop` is how one
179
375
  * 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} は登録されていません`);
376
+ export async function remove(
377
+ env: Env,
378
+ ref: string,
379
+ ): Promise<{ id: string; name: string; dir: string; removed: boolean }> {
380
+ const paths = resolvePaths(env);
381
+ const all = await loadAll(paths.configDir);
382
+ const found = all.instances.find((one) => one.id === ref || one.name === ref || one.dir === ref);
383
+ if (found === undefined) throw new CommandError("not_found", `${ref} は登録されていません`);
384
+ // Out of every cluster that listed it: a person removing an instance is
385
+ // removing it from this host, and leaving it in the second cluster would
386
+ // leave the supervisor starting it.
387
+ for (const cluster of all.clusters) {
388
+ if (!cluster.instances.includes(found.id)) continue;
389
+ saveCluster(paths.configDir, {
390
+ ...cluster,
391
+ instances: cluster.instances.filter((one) => one !== found.id),
392
+ });
187
393
  }
188
- saveShared(file, { ...shared, instances: kept });
189
- return { dir: home, removed: true };
394
+ rmSync(join(paths.instancesDir, instanceFileName(found.id)), { force: true });
395
+ // The state directory stays, its id with it: what the instance issued is
396
+ // keyed by that id, and re-adding the same config home has to answer to the
397
+ // same one.
398
+ return { id: found.id, name: found.name, dir: found.dir, removed: true };
399
+ }
400
+
401
+ /** The file every instance's settings start from, as it is first written. */
402
+ function defaultsTemplate(): string {
403
+ return `import type { Defaults } from "./${TYPES_FILE.replace(/\.d\.ts$/, "")}";
404
+
405
+ /** 全 instance に配る値。\`builtin\` は組み込みの既定値 (凍結済み)、\`config\` は
406
+ * そのコピーなので、書き換えて返す。ここに書いた値を各 instance が受け取る。 */
407
+ const defaults: Defaults = ({ config }) => {
408
+ // mesh の相手はここには書かない。cluster 内の instance は各 TS の endpoint /
409
+ // port から、別 host の endpoint は cluster の peers (ccmsg mesh add) から入る。
410
+
411
+ // dump の名前付き選択。prefix は一族を、\`@name\` は他の選択をその場に広げる。
412
+ config.dump.presets = [
413
+ ${STARTING_PRESETS.map((preset) => presetLiteral(preset)).join("\n")}
414
+ ];
415
+
416
+ return config;
417
+ };
418
+
419
+ export default defaults;
420
+ `;
421
+ }
422
+
423
+ /** One starting preset as a person would have typed it.
424
+ *
425
+ * Written out rather than stringified, because what this produces is a file
426
+ * somebody edits: JSON's quoted keys in the middle of a TypeScript file are
427
+ * the shape of a thing that was generated, and the next preset a person adds
428
+ * beside it would not look like it. */
429
+ function presetLiteral(preset: (typeof STARTING_PRESETS)[number]): string {
430
+ const types = preset.opts.types.map((type) => JSON.stringify(type)).join(", ");
431
+ return [
432
+ " {",
433
+ ` name: ${JSON.stringify(preset.name)},`,
434
+ ` description: ${JSON.stringify(preset.description)},`,
435
+ ` opts: { types: [${types}] },`,
436
+ " },",
437
+ ].join("\n");
438
+ }
439
+
440
+ /** One instance's file, as `add` first writes it: what differs from
441
+ * the shared file, and nothing else. */
442
+ function instanceTemplate(name: string, dir: string, harness: Harness, port: number): string {
443
+ const lines = [
444
+ ` config.name = ${JSON.stringify(name)};`,
445
+ ` config.dir = ${JSON.stringify(dir)};`,
446
+ "",
447
+ " // reverse proxy の後ろに居るなら、peer と人が届く公開 URL (末尾 /) を書く。",
448
+ " // 書かなければ下の待ち受け address がそのまま mesh の一覧に載る。",
449
+ ` // config.endpoint = "https://ccmsg-${name}.<host>/";`,
450
+ "",
451
+ ];
452
+ if (harness !== DEFAULT_HARNESS) lines.push(` config.harness = ${JSON.stringify(harness)};`);
453
+ lines.push(
454
+ ` config.entry = {`,
455
+ ` ...(config.entry ?? { host: "127.0.0.1", source_ips: [], trusted_proxies: [] }),`,
456
+ ` port: ${String(port)},`,
457
+ ` };`,
458
+ );
459
+ return `import type { Instance } from "../${TYPES_FILE.replace(/\.d\.ts$/, "")}";
460
+
461
+ /** ${name}: この instance だけの設定。\`default\` は ${CONFIG_FILE} が返した値
462
+ * (凍結済み)、\`config\` はそのコピーなので、差分だけ書き換えて返す。 */
463
+ const instance: Instance = ({ config }) => {
464
+ ${lines.join("\n")}
465
+
466
+ return config;
467
+ };
468
+
469
+ export default instance;
470
+ `;
190
471
  }
191
472
 
192
473
  /** What an instance is called, whether or not it is running.
@@ -204,20 +485,44 @@ export function rowFor(target: Target): InstanceRow {
204
485
  const running = pid !== undefined && alive(pid);
205
486
  return {
206
487
  id: idOf(target),
488
+ ...(target.name === undefined ? {} : { name: target.name }),
207
489
  dir: target.dir,
208
490
  running,
209
491
  ...(running ? { pid } : {}),
210
492
  };
211
493
  }
212
494
 
213
- export function list(env: Env): InstanceRow[] {
214
- return registered(env).map((target) => rowFor(target));
495
+ /** What `daemon list` answers: one row per instance per cluster it is in.
496
+ *
497
+ * By cluster because that is the unit a person manages — which mesh, which
498
+ * authentication records — and an instance in two of them is in both listings,
499
+ * as the same id with the same process. */
500
+ export async function list(env: Env): Promise<InstanceRow[]> {
501
+ const all = await loadAll(resolvePaths(env).configDir);
502
+ const rows: InstanceRow[] = [];
503
+ for (const cluster of all.clusters) {
504
+ for (const id of cluster.instances) {
505
+ const found = all.instances.find((one) => one.id === id);
506
+ if (found === undefined) continue;
507
+ const target = targetFor(env, found.dir, found.name, found.id, found.clusters);
508
+ rows.push({
509
+ ...rowFor(target),
510
+ cluster_id: cluster.id,
511
+ cluster_name: cluster.name,
512
+ ...(found.config.entry === undefined ? {} : { port: found.config.entry.port }),
513
+ });
514
+ }
515
+ }
516
+ return rows;
215
517
  }
216
518
 
217
519
  /** Ask one instance how it is. A config home with nothing behind it answers the
218
520
  * list's row and nothing more: not running is a state, not a failure. */
219
521
  export async function status(target: Target): Promise<StatusRow> {
220
- const row = { ...rowFor(target), config: loadConfig(target.paths.configFile, target.dir) };
522
+ const row = {
523
+ ...rowFor(target),
524
+ config: await loadConfig(target.paths.configDir, target.dir),
525
+ };
221
526
  const conn = await connect(target.paths.socket);
222
527
  if (conn === undefined) return row;
223
528
  try {
@@ -118,7 +118,18 @@ export class Supervisor {
118
118
  this.#startTimeoutMs = options.startTimeoutMs ?? START_TIMEOUT_MS;
119
119
  this.#stopTimeoutMs = options.stopTimeoutMs ?? STOP_TIMEOUT_MS;
120
120
  this.#log = options.log ?? ((line) => process.stderr.write(`${JSON.stringify(line)}\n`));
121
- for (const target of registered(this.#env)) this.#units.set(target.dir, new Supervised(target));
121
+ }
122
+
123
+ /** Read which config homes there are.
124
+ *
125
+ * In `run` rather than in the constructor because the files are TypeScript
126
+ * and reading one is an import: a caller holding a supervisor that has not
127
+ * run yet is holding one that has not read the list yet, which is the same
128
+ * moment it was already true that nothing had been started. */
129
+ async #adopt(): Promise<void> {
130
+ for (const target of await registered(this.#env)) {
131
+ this.#units.set(target.dir, new Supervised(target));
132
+ }
122
133
  }
123
134
 
124
135
  /** The config homes this supervisor is looking after right now. */
@@ -138,6 +149,7 @@ export class Supervisor {
138
149
  }
139
150
 
140
151
  async #serve(): Promise<void> {
152
+ await this.#adopt();
141
153
  await this.#listen();
142
154
  for (const unit of this.#units.values()) this.#keep(unit);
143
155
  // What ends the run is being asked to, not the children ending: a
@@ -307,7 +319,7 @@ export class Supervisor {
307
319
  * behind it is the state `add` exists to leave behind only when there is no
308
320
  * supervisor to tell. */
309
321
  async addOne(dir: string): Promise<StatusRow> {
310
- const home = configHome(dir, harnessFor(this.#env, dir));
322
+ const home = configHome(dir, await harnessFor(this.#env, dir));
311
323
  if (this.#units.has(home)) {
312
324
  throw new CommandError("file_exists", `${home} は既に見ています`);
313
325
  }