@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
package/src/auth/admin.ts CHANGED
@@ -22,23 +22,49 @@ export type AdminRequest =
22
22
  readonly sub?: Subject;
23
23
  }
24
24
  | { readonly admin: "passkey_list"; readonly request_id: string }
25
- | { readonly admin: "passkey_remove"; readonly request_id: string; readonly sub: Subject };
25
+ | { readonly admin: "passkey_remove"; readonly request_id: string; readonly sub: Subject }
26
+ /** Stop being a peer of this endpoint, now rather than at the next start.
27
+ *
28
+ * Here with the passkey requests because it is the same kind of thing: what
29
+ * this host is prepared to talk to, said from the machine it runs on, on the
30
+ * socket where reaching the address is the permission. `ccmsg mesh remove`
31
+ * has already taken it off the list; this is the running instance being told
32
+ * so, because a revocation that waited for a restart would leave the link it
33
+ * revoked standing. */
34
+ | { readonly admin: "mesh_forget"; readonly request_id: string; readonly endpoint: Endpoint };
35
+
36
+ const ADMIN_NAMES = ["passkey_add", "passkey_list", "passkey_remove", "mesh_forget"];
26
37
 
27
38
  /** Whether a frame is one of these, without deciding anything about it. */
28
39
  export function adminRequestOf(frame: unknown): AdminRequest | undefined {
29
40
  if (typeof frame !== "object" || frame === null) return undefined;
30
41
  const fields = frame as Record<string, unknown>;
31
- const name = fields["admin"];
32
- if (name !== "passkey_add" && name !== "passkey_list" && name !== "passkey_remove") {
33
- return undefined;
34
- }
42
+ if (!ADMIN_NAMES.includes(fields["admin"] as string)) return undefined;
35
43
  return typeof fields["request_id"] === "string" ? (frame as AdminRequest) : undefined;
36
44
  }
37
45
 
46
+ /** What an administrative request is asked of: the passkeys, and the mesh on an
47
+ * instance that has one. */
48
+ export interface Administered {
49
+ readonly auth: Auth;
50
+ readonly mesh?: { forget(peer: Endpoint): boolean };
51
+ }
52
+
38
53
  /** Run one administrative request. */
39
- export function handleAdmin(auth: Auth, request: AdminRequest): DispatchResult {
54
+ export function handleAdmin(at: Administered, request: AdminRequest): DispatchResult {
55
+ const auth = at.auth;
40
56
  try {
41
57
  switch (request.admin) {
58
+ case "mesh_forget": {
59
+ const mesh = at.mesh;
60
+ if (mesh === undefined) {
61
+ return reply(request.request_id, { endpoint: request.endpoint, dropped: false });
62
+ }
63
+ return reply(request.request_id, {
64
+ endpoint: request.endpoint,
65
+ dropped: mesh.forget(request.endpoint),
66
+ });
67
+ }
42
68
  case "passkey_add":
43
69
  return reply(
44
70
  request.request_id,
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
+ type Endpoint,
3
4
  type MessageSendArgs,
4
5
  type NotifySendArgs,
5
6
  PROTOCOL_VERSION,
@@ -26,10 +27,12 @@ import {
26
27
  rowFor,
27
28
  snapshots,
28
29
  type SuperviseOp,
30
+ clusterFor,
29
31
  Supervisor,
30
32
  tailOf,
31
33
  type Target,
32
34
  targetFor,
35
+ targetNamed,
33
36
  } from "./daemon/index.ts";
34
37
  import { readFileSync, writeFileSync } from "node:fs";
35
38
  import { homedir } from "node:os";
@@ -43,22 +46,15 @@ const SESSION_ENV = HARNESSES.flatMap((harness) => [...HARNESS[harness].sessionE
43
46
  import { hookEvent, type StatedMeta, statedMeta } from "./greeting/index.ts";
44
47
  import {
45
48
  isRunning,
46
- MERGE_RULES,
49
+ loadClusters,
50
+ resolveConfigDir,
47
51
  resolveConfigHome,
48
52
  resolvePaths,
49
53
  resolvePathsFor,
54
+ saveCluster,
55
+ saveClusters,
50
56
  start,
51
57
  } from "./instance/index.ts";
52
-
53
- /** The merge rules as the help prints them: one line per field path, in the
54
- * order the schema declares them, so the table a person reads is the table the
55
- * merge runs on. */
56
- const MERGE_DOCS: readonly Doc[] = Object.entries(MERGE_RULES).map(([path, rule]) => [
57
- path,
58
- rule === "merge"
59
- ? "instances[] 側にある field だけを defaults に重ねる"
60
- : "instances[] 側にあれば丸ごと置換する (追加・和にはならない)",
61
- ]);
62
58
  import {
63
59
  type Agent,
64
60
  AGENTS,
@@ -151,7 +147,7 @@ const ROOT: Command = {
151
147
  {
152
148
  name: "run",
153
149
  summary: "この config home の instance を foreground で起動する (監督者の管理外)",
154
- usage: "ccmsg daemon run [dir]",
150
+ usage: "ccmsg daemon run [name | id | dir]",
155
151
  bare: true,
156
152
  run: (args) => runInstance(args[0]),
157
153
  },
@@ -164,9 +160,14 @@ const ROOT: Command = {
164
160
  },
165
161
  {
166
162
  name: "add",
167
- summary: "共通 config instances[] に足し、監督者が居れば起こさせる",
168
- usage: "ccmsg daemon add <dir> [--harness <種別>]",
163
+ summary: "config home を cluster に足し、instances/instance-<id>.ts を書く",
164
+ usage: "ccmsg daemon add <dir> [--cluster <id|name>] [--port <番号>] [--harness <種別>]",
169
165
  options: [
166
+ [
167
+ "--cluster <id|name>",
168
+ "入れる cluster (既定: 1 つならそれ、無ければ新規。知らない id ならその id で作る)",
169
+ ],
170
+ ["--port <番号>", "entry の待ち受けポート (既定は登録済みの最大 + 1 の空きポート)"],
170
171
  [
171
172
  "--harness <種別>",
172
173
  `config home が動かすもの: ${HARNESSES.join(" | ")} (既定 ${DEFAULT_HARNESS})`,
@@ -174,94 +175,169 @@ const ROOT: Command = {
174
175
  ],
175
176
  notes: [
176
177
  {
177
- title:
178
- "共通 config で instances[] の値が defaults に重なる規則 (掲載の無いパスは丸ごと置換):",
179
- docs: MERGE_DOCS,
178
+ title: "何がどのファイルに載るか (載っていないファイルは読まない):",
179
+ docs: [
180
+ ["config_v2.ts", "全 instance が受け取る値。`({builtin, config}) => config`"],
181
+ ["clusters.json", "この host が知る cluster の id 一覧"],
182
+ [
183
+ "clusters/cluster-<id>.json",
184
+ "name / peers (別 host の endpoint) / instances (id)",
185
+ ],
186
+ [
187
+ "instances/instance-<id>.ts",
188
+ "1 instance 分の差分。`({builtin, default, config}) => config`",
189
+ ],
190
+ [
191
+ "ccmsg-config_v2.d.ts",
192
+ "設定ファイルが `import type` で参照する型 (ccmsg が置く)",
193
+ ],
194
+ ],
180
195
  },
181
196
  ],
182
197
  run: (args) => added(args),
183
198
  },
184
199
  {
185
200
  name: "remove",
186
- summary: "instances[] から外す (監督者は以後見ないが、子は止めない)",
187
- usage: "ccmsg daemon remove <dir>",
201
+ summary: "cluster から外して instances/instance-<id>.ts を消す (子は止めない)",
202
+ usage: "ccmsg daemon remove <name | id | dir>",
188
203
  run: (args) => removed(args[0]),
189
204
  },
190
205
  {
191
206
  name: "list",
192
- summary: "登録されている config home と、動いているかを並べる",
207
+ summary: "cluster ごとに instance と、動いているかを並べる",
193
208
  usage: "ccmsg daemon list",
194
209
  bare: true,
195
- run: () => Promise.resolve(listInstances(process.env)),
210
+ run: () => listInstances(process.env),
196
211
  },
197
212
  {
198
213
  name: "start",
199
214
  summary: "監督者に、この config home の子を起こさせる",
200
- usage: "ccmsg daemon start <dir> | --all",
215
+ usage: "ccmsg daemon start <name | dir> | --all",
201
216
  run: (args) => supervised("supervise_start", args),
202
217
  },
203
218
  {
204
219
  name: "stop",
205
220
  summary: "監督者に、子を止めさせる (instance.shutdown、以後は上げ直さない)",
206
- usage: "ccmsg daemon stop <dir> | --all",
221
+ usage: "ccmsg daemon stop <name | dir> | --all",
207
222
  run: (args) => supervised("supervise_stop", args),
208
223
  },
209
224
  {
210
225
  name: "restart",
211
226
  summary: "監督者に、止めてから起こし直させる",
212
- usage: "ccmsg daemon restart <dir> | --all",
227
+ usage: "ccmsg daemon restart <name | dir> | --all",
213
228
  run: (args) => supervised("supervise_restart", args),
214
229
  },
215
230
  {
216
231
  name: "status",
217
232
  summary: "監督者が各子に instance.ping して version・network・peers を答える",
218
- usage: "ccmsg daemon status [dir] | --all",
233
+ usage: "ccmsg daemon status [name | id | dir] | --all",
219
234
  bare: true,
220
235
  run: (args) => supervised("supervise_status", args, true),
221
236
  },
222
237
  {
223
238
  name: "passkey",
224
- summary: "この config home の instance に登録された passkey を扱う",
225
- usage: "ccmsg daemon passkey <subcommand>",
239
+ summary: "cluster に登録された利用者の passkey を扱う",
240
+ usage: "ccmsg daemon passkey <subcommand> [--cluster <id|name>]",
241
+ options: [["--cluster <id|name>", "どの cluster か (既定: cluster が 1 つならそれ)"]],
242
+ notes: [
243
+ {
244
+ title: "passkey は cluster のもの (記録は cluster 内に複製される):",
245
+ docs: [
246
+ ["どこへ問うか", "その cluster で動いている instance のどれか (どれでも同じ答え)"],
247
+ ["動いていない時", "instance を起動してから (登録は動いている instance が行う)"],
248
+ ],
249
+ },
250
+ ],
226
251
  children: [
227
252
  {
228
253
  name: "add",
229
254
  summary: "登録用 URL と 6 桁コードを 1 組発行する (10 分で失効)",
230
- usage: "ccmsg daemon passkey add <unit> [endpoint] [--name <ラベル>]",
255
+ usage: "ccmsg daemon passkey add [endpoint] [--cluster <id|name>] [--name <ラベル>]",
231
256
  options: [
232
257
  [
233
258
  "[endpoint]",
234
- "登録先の公開 base URL (末尾 /)。既定はこの instance が確定した endpoint",
259
+ "登録先の公開 base URL (末尾 /)。既定は答えた instance が確定した endpoint",
235
260
  ],
236
261
  ["--name <ラベル>", "誰宛に発行した URL かの管理ラベル"],
237
262
  ],
263
+ bare: true,
238
264
  run: (args) => passkeyAdd(args),
239
265
  },
240
266
  {
241
267
  name: "list",
242
268
  summary: "登録済みの credential を、新しい順に並べる",
243
- usage: "ccmsg daemon passkey list [unit]",
269
+ usage: "ccmsg daemon passkey list [--cluster <id|name>]",
244
270
  bare: true,
245
- run: (args) => passkeyAsk(args[0], { admin: "passkey_list" }),
271
+ run: (args) => passkeyCommand(args, () => ({ admin: "passkey_list" })),
246
272
  },
247
273
  {
248
274
  name: "remove",
249
275
  summary: "利用者を消す (credential と token を失効させ、その WS を切る)",
250
- usage: "ccmsg daemon passkey remove <sub> [unit]",
251
- run: (args) => passkeyRemove(args),
276
+ usage: "ccmsg daemon passkey remove <sub> [--cluster <id|name>]",
277
+ run: (args) =>
278
+ passkeyCommand(args, (rest) => {
279
+ const sub = rest[0];
280
+ if (sub === undefined) {
281
+ throw new CommandError(
282
+ "invalid_args",
283
+ "使い方: ccmsg daemon passkey remove <sub> [--cluster <id|name>]",
284
+ );
285
+ }
286
+ return { admin: "passkey_remove", sub };
287
+ }),
252
288
  },
253
289
  ],
254
290
  },
255
291
  {
256
292
  name: "log",
257
293
  summary: "instance の daemon.log を出す (--all は行に id を足して多重化)",
258
- usage: "ccmsg daemon log [dir] | --all [--follow]",
294
+ usage: "ccmsg daemon log [name | id | dir] | --all [--follow]",
259
295
  options: [["--follow", "書き足される行を待ち続ける (Ctrl-C で終わり)"]],
260
296
  bare: true,
261
297
  run: (args) => daemonLog(args),
262
298
  },
263
299
  ],
264
300
  },
301
+ {
302
+ name: "mesh",
303
+ summary: "cluster の mesh に別 host の endpoint を出し入れする",
304
+ usage: "ccmsg mesh <subcommand> [endpoint] [--cluster <id|name>]",
305
+ options: [["--cluster <id|name>", "どの cluster の mesh か (既定: cluster が 1 つならそれ)"]],
306
+ notes: [
307
+ {
308
+ title: "cluster 内の instance 同士は自動で mesh に入る (各 TS の endpoint / port):",
309
+ docs: [
310
+ [
311
+ "cluster の peers",
312
+ "別 host の endpoint だけを並べる。instance id は handshake で伝わる",
313
+ ],
314
+ ["add の反映", "次に instance が起動した時 (config は起動時に 1 回だけ読む)"],
315
+ ["remove の反映", "即時。繋がっている相手なら切る"],
316
+ ],
317
+ },
318
+ ],
319
+ children: [
320
+ {
321
+ name: "add",
322
+ summary: "endpoint を cluster の peer に足す (知らない cluster id ならその id で作る)",
323
+ usage: "ccmsg mesh add <endpoint> [--cluster <id|name>]",
324
+ run: (args) => meshPeers("add", args),
325
+ },
326
+ {
327
+ name: "list",
328
+ summary: "cluster の peer を並べる (--cluster 無しで cluster が複数なら全部)",
329
+ usage: "ccmsg mesh list [--cluster <id|name>]",
330
+ bare: true,
331
+ run: (args) => meshPeers("list", args),
332
+ },
333
+ {
334
+ name: "remove",
335
+ summary: "endpoint を cluster の peer から外し、繋がっていれば切る",
336
+ usage: "ccmsg mesh remove <endpoint> [--cluster <id|name>]",
337
+ run: (args) => meshPeers("remove", args),
338
+ },
339
+ ],
340
+ },
265
341
  {
266
342
  name: "service",
267
343
  summary: "監督者 (ccmsg daemon supervise) を launchd / systemd に登録する",
@@ -568,10 +644,11 @@ function section(lines: string[], title: string, docs: readonly Doc[] | undefine
568
644
  lines.push("");
569
645
  }
570
646
 
571
- /** `ccmsg daemon run [dir]`: this config home's instance, in the foreground. */
572
- async function runInstance(dir: string | undefined): Promise<unknown> {
573
- const named = dir ?? resolveConfigHome();
574
- const home = configHome(named, harnessFor(process.env, named));
647
+ /** `ccmsg daemon run [name | dir]`: this config home's instance, in the
648
+ * foreground. */
649
+ async function runInstance(given: string | undefined): Promise<unknown> {
650
+ const named = (await dirOf(given)) ?? resolveConfigHome();
651
+ const home = configHome(named, await harnessFor(process.env, named));
575
652
  // The directory is handed over rather than put in the environment: the
576
653
  // instance would otherwise read it back through the question "which session
577
654
  // is this process inside", and a `daemon run` issued from a session of
@@ -620,31 +697,142 @@ async function supervise(): Promise<unknown> {
620
697
  * the supervisor reads the list once (DV-Q8) and would otherwise not know
621
698
  * until it is restarted. */
622
699
  async function added(args: readonly string[]): Promise<unknown> {
623
- const { named, rest } = options(args, ["harness"]);
700
+ const { named, rest } = options(args, ["harness", "port", "cluster"]);
624
701
  const dir = rest[0];
625
702
  const stated = named.get("harness");
703
+ const port = named.get("port");
626
704
  if (dir === undefined) {
627
- throw new CommandError("invalid_args", "使い方: ccmsg daemon add <dir> [--harness <種別>]");
705
+ throw new CommandError(
706
+ "invalid_args",
707
+ "使い方: ccmsg daemon add <dir> [--cluster <id|name>] [--port <番号>] [--harness <種別>]",
708
+ );
628
709
  }
629
710
  if (stated !== undefined && !isHarness(stated)) {
630
711
  throw new CommandError("invalid_args", `--harness は ${HARNESSES.join(" | ")} のどれかです`);
631
712
  }
632
- const row = addToConfig(process.env, dir, stated ?? DEFAULT_HARNESS);
713
+ if (port !== undefined && !/^\d{1,5}$/.test(port)) {
714
+ throw new CommandError("invalid_args", "--port は 0 から 65535 の番号です");
715
+ }
716
+ const row = await addToConfig(process.env, dir, {
717
+ ...(named.get("cluster") === undefined ? {} : { cluster: named.get("cluster") as string }),
718
+ ...(stated === undefined ? {} : { harness: stated }),
719
+ ...(port === undefined ? {} : { port: Number(port) }),
720
+ });
633
721
  if (!(await reachable())) return { ...row, supervised: false };
634
722
  const started = (await ask({ op: "supervise_add", dir: row.dir })) as Record<string, unknown>;
635
- return { ...started, supervised: true };
723
+ return { ...started, name: row.name, supervised: true };
636
724
  }
637
725
 
638
- /** `ccmsg daemon remove <dir>`: take it off the list, and stop looking after it.
726
+ /** `ccmsg mesh <what> [endpoint]`: the mesh endpoints this host does not serve
727
+ * itself.
728
+ *
729
+ * Its own command rather than one under `daemon`, because what it edits is not
730
+ * one instance's anything: every instance of this host is in the same mesh
731
+ * (§7.1), so the list is the host's. `peers` is what a session list is called,
732
+ * which is why this is called what the thing itself is called.
639
733
  *
640
- * The instance itself is left alone: a list edit is not a shutdown, and a
734
+ * An addition takes effect when the instances next start, for the reason
735
+ * nothing else reloads either (DV-Q8). A removal is told to whoever is running
736
+ * as well as written down: an endpoint taken off the list is one this host is
737
+ * not to be talking to, and leaving a live link up until the next restart would
738
+ * be leaving exactly the connection that was just revoked. */
739
+ async function meshPeers(what: "add" | "list" | "remove", args: readonly string[]) {
740
+ const parsed = options(args, ["cluster"]);
741
+ const configDir = resolveConfigDir();
742
+ const clusters = loadClusters(configDir);
743
+ if (what === "list" && parsed.named.get("cluster") === undefined && clusters.length !== 1) {
744
+ // Every cluster's own, because a list of addresses with no cluster beside
745
+ // them would not say which mesh each belongs to.
746
+ return {
747
+ clusters: clusters.map((one) => ({ id: one.id, name: one.name, peers: one.peers })),
748
+ };
749
+ }
750
+ const cluster = clusterFor(clusters, parsed.named.get("cluster"));
751
+ if (what === "list") {
752
+ return { cluster_id: cluster.id, cluster_name: cluster.name, peers: cluster.peers };
753
+ }
754
+ const given = parsed.rest[0];
755
+ if (given === undefined) {
756
+ throw new CommandError(
757
+ "invalid_args",
758
+ `使い方: ccmsg mesh ${what} <endpoint> [--cluster <id|name>]`,
759
+ );
760
+ }
761
+ const endpoint = endpointGiven(given);
762
+ const known = clusters.some((one) => one.id === cluster.id);
763
+ if (what === "add") {
764
+ if (cluster.peers.includes(endpoint)) {
765
+ throw new CommandError("file_exists", `${endpoint} は既に ${cluster.name} の peer です`);
766
+ }
767
+ saveCluster(configDir, { ...cluster, peers: [...cluster.peers, endpoint] });
768
+ if (!known) saveClusters(configDir, [...clusters.map((one) => one.id), cluster.id]);
769
+ // Said rather than left to be noticed: the running instances read the list
770
+ // when they started, so the one thing a person wants to know here is that
771
+ // this peer is not dialled yet.
772
+ return {
773
+ cluster_id: cluster.id,
774
+ cluster_name: cluster.name,
775
+ endpoint,
776
+ added: true,
777
+ restart_needed: (await runningIn(cluster.id)).length > 0,
778
+ };
779
+ }
780
+ if (!cluster.peers.includes(endpoint)) {
781
+ throw new CommandError("not_found", `${endpoint} は ${cluster.name} の peer ではありません`);
782
+ }
783
+ saveCluster(configDir, {
784
+ ...cluster,
785
+ peers: cluster.peers.filter((peer) => peer !== endpoint),
786
+ });
787
+ const cut: string[] = [];
788
+ for (const target of await runningIn(cluster.id)) {
789
+ const answer = (await askInstance(target, { admin: "mesh_forget", endpoint })) as {
790
+ dropped?: boolean;
791
+ };
792
+ if (answer.dropped === true) cut.push(target.name ?? target.dir);
793
+ }
794
+ return {
795
+ cluster_id: cluster.id,
796
+ cluster_name: cluster.name,
797
+ endpoint,
798
+ removed: true,
799
+ disconnected: cut,
800
+ };
801
+ }
802
+
803
+ /** The instances of one cluster that have something answering right now, which
804
+ * are the ones a change to that cluster's mesh has to reach. */
805
+ async function runningIn(cluster: string): Promise<Target[]> {
806
+ return (await registered(process.env)).filter(
807
+ (target) => (target.clusters ?? []).some((one) => one.id === cluster) && rowFor(target).running,
808
+ );
809
+ }
810
+
811
+ /** An endpoint as the contract spells it, from what a person typed: the base
812
+ * URL of an instance, ending in the slash everything it serves hangs off. The
813
+ * slash is added rather than demanded — a person pasting an address from a
814
+ * browser has one without it, and the two are the same address. */
815
+ function endpointGiven(given: string): Endpoint {
816
+ const text = given.endsWith("/") ? given : `${given}/`;
817
+ if (!/^https?:\/\/[^\s?#]*\/$/.test(text)) {
818
+ throw new CommandError(
819
+ "invalid_args",
820
+ `${given} は endpoint になりません (http:// か https:// で始まる base URL)`,
821
+ );
822
+ }
823
+ return text as Endpoint;
824
+ }
825
+
826
+ /** `ccmsg daemon remove <name>`: take its file away, and stop looking after it.
827
+ *
828
+ * The instance itself is left alone: removing the file is not a shutdown, and a
641
829
  * session already talking to that instance keeps it. `daemon stop` is how one
642
830
  * is stopped, and keeping the two apart is what makes that true. */
643
- async function removed(dir: string | undefined): Promise<unknown> {
644
- if (dir === undefined) {
645
- throw new CommandError("invalid_args", "使い方: ccmsg daemon remove <dir>");
831
+ async function removed(name: string | undefined): Promise<unknown> {
832
+ if (name === undefined) {
833
+ throw new CommandError("invalid_args", "使い方: ccmsg daemon remove <name>");
646
834
  }
647
- const row = removeFromConfig(process.env, dir);
835
+ const row = await removeFromConfig(process.env, name);
648
836
  if (!(await reachable())) return { ...row, supervised: false };
649
837
  await ask({ op: "supervise_remove", dir: row.dir });
650
838
  return { ...row, supervised: false };
@@ -666,14 +854,26 @@ async function supervised(
666
854
  const all = parsed.flags.has("all");
667
855
  const named = parsed.rest[0];
668
856
  if (all && named !== undefined) {
669
- throw new CommandError("invalid_args", "--all と dir は同時に指定できません");
857
+ throw new CommandError("invalid_args", "--all と name は同時に指定できません");
670
858
  }
671
859
  if (all) return await ask({ op, all: true });
672
- const dir = named ?? (hereByDefault ? resolveConfigHome() : undefined);
673
- if (dir === undefined) throw new CommandError("invalid_args", "dir か --all が要ります");
860
+ const dir = (await dirOf(named)) ?? (hereByDefault ? resolveConfigHome() : undefined);
861
+ if (dir === undefined) throw new CommandError("invalid_args", "name か --all が要ります");
674
862
  return await ask({ op, dir });
675
863
  }
676
864
 
865
+ /** The config home a command was given, by either of the two things a person
866
+ * has to hand: the name they added it under, or the directory itself.
867
+ *
868
+ * A name first, because that is what `daemon add` took and so what a person
869
+ * has written down; anything nobody registered under that name is taken as the
870
+ * directory it looks like, which is what makes `daemon run` on an unregistered
871
+ * config home reachable. */
872
+ async function dirOf(given: string | undefined): Promise<string | undefined> {
873
+ if (given === undefined) return undefined;
874
+ return (await targetNamed(process.env, given))?.dir ?? given;
875
+ }
876
+
677
877
  /** `ccmsg service <what>`: the supervisor's registration with the host. */
678
878
  async function serviceOp(
679
879
  what: "register" | "unregister" | "start" | "stop" | "status",
@@ -694,22 +894,26 @@ async function serviceOp(
694
894
  kind: service.kind,
695
895
  unit: service.unitFile,
696
896
  ...state,
697
- instances: registered(process.env).map((target) => {
897
+ instances: (await registered(process.env)).map((target) => {
698
898
  const row = rowFor(target);
699
899
  return { id: row.id, dir: row.dir, running: row.running };
700
900
  }),
701
901
  };
702
902
  }
703
903
 
704
- /** The passkey commands, which are asked of the instance itself rather than of
705
- * the supervisor.
904
+ /** The passkey commands, which are asked of an instance of the cluster rather
905
+ * than of the supervisor.
706
906
  *
707
- * They travel on the instance's unix socket and nowhere else: registration is
907
+ * They travel on an instance's unix socket and nowhere else: registration is
708
908
  * local by design (DR-0001 §2.2), and reaching that address is what says the
709
909
  * caller is on the machine. They are not ops of the contract for the same
710
- * reason — the contract is what reaches an instance over a network. */
711
- async function passkeyAsk(unit: string | undefined, request: Record<string, unknown>) {
712
- const target = targetFor(process.env, unit ?? resolveConfigHome());
910
+ * reason — the contract is what reaches an instance over a network.
911
+ *
912
+ * Which instance is asked does not matter, and that is the point of the
913
+ * cluster: a credential registered at one is replicated to the others, so the
914
+ * cluster is what a passkey belongs to and any instance of it can answer. */
915
+ /** One administrative request, on one instance's own unix socket. */
916
+ async function askInstance(target: Target, request: Record<string, unknown>) {
713
917
  const conn = await connect(target.paths.socket);
714
918
  if (conn === undefined) {
715
919
  throw new CommandError("not_found", `${target.dir} の instance は動いていません`);
@@ -727,6 +931,31 @@ async function passkeyAsk(unit: string | undefined, request: Record<string, unkn
727
931
  }
728
932
  }
729
933
 
934
+ async function passkeyCommand(
935
+ args: readonly string[],
936
+ request: (rest: readonly string[]) => Record<string, unknown>,
937
+ ): Promise<unknown> {
938
+ const parsed = options(args, ["cluster", "name"]);
939
+ const asked = request(parsed.rest);
940
+ const cluster = clusterFor(loadClusters(resolveConfigDir()), parsed.named.get("cluster"));
941
+ const reachable = await runningIn(cluster.id);
942
+ const target = reachable[0];
943
+ if (target === undefined) {
944
+ throw new CommandError(
945
+ "instance_unreachable",
946
+ `${cluster.name} で動いている instance がありません (ccmsg daemon start で起こしてください)`,
947
+ );
948
+ }
949
+ // Any one of them: what is registered is replicated across the cluster
950
+ // (DR-0001 §2.6), so which instance answered is not part of the answer.
951
+ return {
952
+ cluster_id: cluster.id,
953
+ cluster_name: cluster.name,
954
+ instance: target.name ?? target.dir,
955
+ ...((await askInstance(target, asked)) as Record<string, unknown>),
956
+ };
957
+ }
958
+
730
959
  /** `ccmsg daemon passkey add`: one registration URL, and the code that goes
731
960
  * with it.
732
961
  *
@@ -734,30 +963,18 @@ async function passkeyAsk(unit: string | undefined, request: Record<string, unkn
734
963
  * anything the instance hands out — so that holding the URL is not enough to
735
964
  * register (DR-0001 §2.2). */
736
965
  async function passkeyAdd(args: readonly string[]): Promise<unknown> {
737
- const parsed = options(args, ["name"]);
738
- const [unit, endpoint] = parsed.rest;
739
- if (unit === undefined) {
740
- throw new CommandError(
741
- "invalid_args",
742
- "使い方: ccmsg daemon passkey add <unit> [endpoint] [--name <ラベル>]",
743
- );
744
- }
745
- const name = parsed.named.get("name");
746
- return await passkeyAsk(unit, {
747
- admin: "passkey_add",
748
- ...(endpoint === undefined ? {} : { endpoint }),
749
- ...(name === undefined ? {} : { name }),
966
+ return await passkeyCommand(args, (rest) => {
967
+ const endpoint = rest[0];
968
+ const parsed = options(args, ["cluster", "name"]);
969
+ const name = parsed.named.get("name");
970
+ return {
971
+ admin: "passkey_add",
972
+ ...(endpoint === undefined ? {} : { endpoint }),
973
+ ...(name === undefined ? {} : { name }),
974
+ };
750
975
  });
751
976
  }
752
977
 
753
- async function passkeyRemove(args: readonly string[]): Promise<unknown> {
754
- const [sub, unit] = args;
755
- if (sub === undefined) {
756
- throw new CommandError("invalid_args", "使い方: ccmsg daemon passkey remove <sub> [unit]");
757
- }
758
- return await passkeyAsk(unit, { admin: "passkey_remove", sub });
759
- }
760
-
761
978
  /** `ccmsg daemon log`: what one instance wrote down, or what all of them did.
762
979
  *
763
980
  * JSON lines rather than one document, because a log is a stream and `--follow`
@@ -772,7 +989,7 @@ async function daemonLog(args: readonly string[]): Promise<undefined> {
772
989
  // shape whether the host runs one or five.
773
990
  const many = parsed.flags.has("all");
774
991
  const targets = many
775
- ? registered(process.env)
992
+ ? await registered(process.env)
776
993
  : [targetFor(process.env, parsed.rest[0] ?? resolveConfigHome())];
777
994
  const write = (target: Target, lines: readonly string[]): void => {
778
995
  for (const line of lines) {
@@ -1191,7 +1408,7 @@ async function plugin(
1191
1408
  }
1192
1409
  // `status` with no agent named answers for the config home this process
1193
1410
  // belongs to, which is what the instance there runs.
1194
- const which = agent ?? harnessFor(process.env, resolvePaths().configHome);
1411
+ const which = agent ?? (await harnessFor(process.env, resolvePaths().configHome));
1195
1412
  // The config home is that agent's own, and not whichever variable happens to
1196
1413
  // be set: a Codex session started from a Claude Code session carries both,
1197
1414
  // and an install that read the wrong one would write Codex's hooks into