@ccmsg/cli 0.10.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.
package/src/cli.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
- type Endpoint,
4
3
  type MessageSendArgs,
5
4
  type NotifySendArgs,
6
5
  PROTOCOL_VERSION,
@@ -27,16 +26,23 @@ import {
27
26
  rowFor,
28
27
  snapshots,
29
28
  type SuperviseOp,
30
- clusterFor,
31
29
  Supervisor,
32
30
  tailOf,
33
31
  type Target,
34
32
  targetFor,
35
33
  targetNamed,
36
34
  } from "./daemon/index.ts";
37
- import { readFileSync, writeFileSync } from "node:fs";
35
+ import {
36
+ copyFileSync,
37
+ existsSync,
38
+ mkdirSync,
39
+ readdirSync,
40
+ readFileSync,
41
+ statSync,
42
+ writeFileSync,
43
+ } from "node:fs";
38
44
  import { homedir } from "node:os";
39
- import { isAbsolute, join } from "node:path";
45
+ import { dirname, isAbsolute, join, relative } from "node:path";
40
46
  import { document } from "./transcript/items/index.ts";
41
47
  import { currentSession, DEFAULT_HARNESS, HARNESS, HARNESSES, isHarness } from "./harness/index.ts";
42
48
 
@@ -45,14 +51,18 @@ import { currentSession, DEFAULT_HARNESS, HARNESS, HARNESSES, isHarness } from "
45
51
  const SESSION_ENV = HARNESSES.flatMap((harness) => [...HARNESS[harness].sessionEnv]);
46
52
  import { hookEvent, type StatedMeta, statedMeta } from "./greeting/index.ts";
47
53
  import {
54
+ applied,
55
+ configFiles,
56
+ evaluate,
48
57
  isRunning,
49
- loadClusters,
58
+ REJECTED_DIR,
50
59
  resolveConfigDir,
51
60
  resolveConfigHome,
52
61
  resolvePaths,
53
62
  resolvePathsFor,
54
- saveCluster,
55
- saveClusters,
63
+ SATISFIED_FILE,
64
+ type Satisfied,
65
+ STATE_CONFIG_DIR,
56
66
  start,
57
67
  } from "./instance/index.ts";
58
68
  import {
@@ -153,20 +163,16 @@ const ROOT: Command = {
153
163
  },
154
164
  {
155
165
  name: "supervise",
156
- summary: "共通 config の instance を子プロセスとして起動し、落ちたら上げる",
166
+ summary: "検証済みの設定が挙げる instance を子として起動し、落ちたら上げる",
157
167
  usage: "ccmsg daemon supervise",
158
168
  bare: true,
159
169
  run: () => supervise(),
160
170
  },
161
171
  {
162
172
  name: "add",
163
- summary: "config home を cluster に足し、instances/instance-<id>.ts を書く",
164
- usage: "ccmsg daemon add <dir> [--cluster <id|name>] [--port <番号>] [--harness <種別>]",
173
+ summary: "config home を instance として書き留め、監督者が居れば起こさせる",
174
+ usage: "ccmsg daemon add <dir> [--port <番号>] [--harness <種別>]",
165
175
  options: [
166
- [
167
- "--cluster <id|name>",
168
- "入れる cluster (既定: 1 つならそれ、無ければ新規。知らない id ならその id で作る)",
169
- ],
170
176
  ["--port <番号>", "entry の待ち受けポート (既定は登録済みの最大 + 1 の空きポート)"],
171
177
  [
172
178
  "--harness <種別>",
@@ -178,11 +184,8 @@ const ROOT: Command = {
178
184
  title: "何がどのファイルに載るか (載っていないファイルは読まない):",
179
185
  docs: [
180
186
  ["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
- ],
187
+ ["endpoints.json", "mesh の一覧 `[{id, endpoint}]`。別 host の分は人が足す"],
188
+ ["supervisor.json", "この host が起こす instance の id"],
186
189
  [
187
190
  "instances/instance-<id>.ts",
188
191
  "1 instance 分の差分。`({builtin, default, config}) => config`",
@@ -198,13 +201,13 @@ const ROOT: Command = {
198
201
  },
199
202
  {
200
203
  name: "remove",
201
- summary: "cluster から外して instances/instance-<id>.ts を消す (子は止めない)",
204
+ summary: " JSON から外して instances/instance-<id>.ts を消す (子は止めない)",
202
205
  usage: "ccmsg daemon remove <name | id | dir>",
203
206
  run: (args) => removed(args[0]),
204
207
  },
205
208
  {
206
209
  name: "list",
207
- summary: "cluster ごとに instance と、動いているかを並べる",
210
+ summary: "この host が起こす instance と、動いているかを並べる",
208
211
  usage: "ccmsg daemon list",
209
212
  bare: true,
210
213
  run: () => listInstances(process.env),
@@ -236,55 +239,31 @@ const ROOT: Command = {
236
239
  },
237
240
  {
238
241
  name: "passkey",
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
- ],
242
+ summary: "この config home の instance に登録された passkey を扱う",
243
+ usage: "ccmsg daemon passkey <subcommand>",
251
244
  children: [
252
245
  {
253
246
  name: "add",
254
247
  summary: "登録用 URL と 6 桁コードを 1 組発行する (10 分で失効)",
255
- usage: "ccmsg daemon passkey add [endpoint] [--cluster <id|name>] [--name <ラベル>]",
248
+ usage: "ccmsg daemon passkey add <unit> [endpoint] [--name <ラベル>]",
256
249
  options: [
257
- [
258
- "[endpoint]",
259
- "登録先の公開 base URL (末尾 /)。既定は答えた instance が確定した endpoint",
260
- ],
250
+ ["[endpoint]", "登録先の公開 base URL (末尾 /)。既定はこの instance の endpoint"],
261
251
  ["--name <ラベル>", "誰宛に発行した URL かの管理ラベル"],
262
252
  ],
263
- bare: true,
264
253
  run: (args) => passkeyAdd(args),
265
254
  },
266
255
  {
267
256
  name: "list",
268
257
  summary: "登録済みの credential を、新しい順に並べる",
269
- usage: "ccmsg daemon passkey list [--cluster <id|name>]",
258
+ usage: "ccmsg daemon passkey list [unit]",
270
259
  bare: true,
271
- run: (args) => passkeyCommand(args, () => ({ admin: "passkey_list" })),
260
+ run: (args) => passkeyAsk(args[0], { admin: "passkey_list" }),
272
261
  },
273
262
  {
274
263
  name: "remove",
275
264
  summary: "利用者を消す (credential と token を失効させ、その WS を切る)",
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
- }),
265
+ usage: "ccmsg daemon passkey remove <sub> [unit]",
266
+ run: (args) => passkeyRemove(args),
288
267
  },
289
268
  ],
290
269
  },
@@ -299,42 +278,52 @@ const ROOT: Command = {
299
278
  ],
300
279
  },
301
280
  {
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 つならそれ)"]],
281
+ name: "config",
282
+ summary: "設定ファイルの検証と、適用済みとの差を見る",
283
+ usage: "ccmsg config <subcommand> [file] [options]",
306
284
  notes: [
307
285
  {
308
- title: "cluster 内の instance 同士は自動で mesh に入る (各 TS の endpoint / port):",
286
+ title: "人が編集するのは config dir、instance が読むのは検証済みの写し:",
309
287
  docs: [
310
288
  [
311
- "cluster の peers",
312
- " host endpoint だけを並べる。instance id handshake で伝わる",
289
+ "編集用",
290
+ "$CCMSG_CONFIG_DIR (config_v2.ts / endpoints.json / supervisor.json / instances/)",
313
291
  ],
314
- ["add の反映", "次に instance が起動した時 (config は起動時に 1 回だけ読む)"],
315
- ["remove の反映", "即時。繋がっている相手なら切る"],
292
+ ["適用用", "$CCMSG_STATE_DIR/config (検証を通った時だけ書かれる写しと satisfied.json)"],
293
+ ["通らない時", "適用済みのまま起動する。エラーは log と daemon status に出る"],
316
294
  ],
317
295
  },
318
296
  ],
319
297
  children: [
320
298
  {
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),
299
+ name: "list",
300
+ summary: "設定ファイルごとの検証結果と、適用済みとの差の有無を並べる",
301
+ usage: "ccmsg config list",
302
+ bare: true,
303
+ run: () => configList(),
325
304
  },
326
305
  {
327
- name: "list",
328
- summary: "cluster の peer を並べる (--cluster 無しで cluster が複数なら全部)",
329
- usage: "ccmsg mesh list [--cluster <id|name>]",
306
+ name: "diff",
307
+ summary: "編集中のファイルと適用済みの写しの差 (--satisfied は適用したら何が変わるか)",
308
+ usage: "ccmsg config diff [file] | --satisfied",
309
+ options: [["--satisfied", "今の satisfied.json と、編集中から評価し直した物の差"]],
330
310
  bare: true,
331
- run: (args) => meshPeers("list", args),
311
+ run: (args) => configDiff(args),
332
312
  },
333
313
  {
334
- name: "remove",
335
- summary: "endpoint cluster の peer から外し、繋がっていれば切る",
336
- usage: "ccmsg mesh remove <endpoint> [--cluster <id|name>]",
337
- run: (args) => meshPeers("remove", args),
314
+ name: "show",
315
+ summary: "編集中を検証・評価して satisfied の形で表示する (書き込まない)",
316
+ usage: "ccmsg config show [--applied]",
317
+ options: [["--applied", "今使っている satisfied.json を表示する"]],
318
+ bare: true,
319
+ run: (args) => configShow(args),
320
+ },
321
+ {
322
+ name: "revert",
323
+ summary: "適用済みの写しで編集中を上書きする (壊れた方は退避してパスを出す)",
324
+ usage: "ccmsg config revert <file> | --all",
325
+ options: [["--all", "差のあるファイルを全部戻す"]],
326
+ run: (args) => configRevert(args),
338
327
  },
339
328
  ],
340
329
  },
@@ -653,7 +642,10 @@ async function runInstance(given: string | undefined): Promise<unknown> {
653
642
  // instance would otherwise read it back through the question "which session
654
643
  // is this process inside", and a `daemon run` issued from a session of
655
644
  // another harness would answer for that session's config home (§3.8).
656
- const outcome = await start({ configHome: home });
645
+ // With a supervisor up, it is the one that reads the files and writes down
646
+ // what held (§8.2); this start reads what it applied. With none, this
647
+ // process is the only one there is, so it does both.
648
+ const outcome = await start({ configHome: home, settle: !(await reachable()) });
657
649
  if (!isRunning(outcome)) {
658
650
  throw new CommandError(
659
651
  "file_exists",
@@ -697,14 +689,14 @@ async function supervise(): Promise<unknown> {
697
689
  * the supervisor reads the list once (DV-Q8) and would otherwise not know
698
690
  * until it is restarted. */
699
691
  async function added(args: readonly string[]): Promise<unknown> {
700
- const { named, rest } = options(args, ["harness", "port", "cluster"]);
692
+ const { named, rest } = options(args, ["harness", "port"]);
701
693
  const dir = rest[0];
702
694
  const stated = named.get("harness");
703
695
  const port = named.get("port");
704
696
  if (dir === undefined) {
705
697
  throw new CommandError(
706
698
  "invalid_args",
707
- "使い方: ccmsg daemon add <dir> [--cluster <id|name>] [--port <番号>] [--harness <種別>]",
699
+ "使い方: ccmsg daemon add <dir> [--port <番号>] [--harness <種別>]",
708
700
  );
709
701
  }
710
702
  if (stated !== undefined && !isHarness(stated)) {
@@ -714,7 +706,6 @@ async function added(args: readonly string[]): Promise<unknown> {
714
706
  throw new CommandError("invalid_args", "--port は 0 から 65535 の番号です");
715
707
  }
716
708
  const row = await addToConfig(process.env, dir, {
717
- ...(named.get("cluster") === undefined ? {} : { cluster: named.get("cluster") as string }),
718
709
  ...(stated === undefined ? {} : { harness: stated }),
719
710
  ...(port === undefined ? {} : { port: Number(port) }),
720
711
  });
@@ -736,106 +727,228 @@ async function added(args: readonly string[]): Promise<unknown> {
736
727
  * as well as written down: an endpoint taken off the list is one this host is
737
728
  * not to be talking to, and leaving a live link up until the next restart would
738
729
  * 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"]);
730
+ /** `ccmsg daemon remove <name | id | dir>`: take its file away, and stop
731
+ * looking after it.
732
+ *
733
+ * The instance itself is left alone: removing the file is not a shutdown, and a
734
+ * session already talking to that instance keeps it. `daemon stop` is how one
735
+ * is stopped, and keeping the two apart is what makes that true. */
736
+ async function removed(ref: string | undefined): Promise<unknown> {
737
+ if (ref === undefined) {
738
+ throw new CommandError("invalid_args", "使い方: ccmsg daemon remove <name | id | dir>");
739
+ }
740
+ const row = await removeFromConfig(process.env, ref);
741
+ if (!(await reachable())) return { ...row, supervised: false };
742
+ await ask({ op: "supervise_remove", dir: row.dir });
743
+ return { ...row, supervised: false };
744
+ }
745
+
746
+ async function configList(): Promise<unknown> {
747
+ const configDir = resolveConfigDir();
748
+ const stateRoot = resolvePaths().stateRoot;
749
+ const read = await evaluate(configDir);
750
+ const ids = (read.satisfied ?? applied(stateRoot))?.supervisor.instances ?? [];
751
+ const files = configFiles(configDir, ids).map((file) => {
752
+ const copy = join(stateRoot, STATE_CONFIG_DIR, relative(configDir, file));
753
+ return {
754
+ file,
755
+ present: existsSync(file),
756
+ // What is wrong with this one, as the check said it: a person reading
757
+ // this is about to open the file, so the line they need is beside it.
758
+ problems: read.problems.filter((one) => one.file === file).map((one) => one.msg),
759
+ applied: existsSync(copy),
760
+ differs: existsSync(file) && existsSync(copy) ? !same(file, copy) : existsSync(file),
761
+ };
762
+ });
763
+ const rejected = rejectedFiles(stateRoot);
764
+ return {
765
+ ok: read.satisfied !== undefined,
766
+ files,
767
+ ...(read.problems.length === 0 ? {} : { problems: read.problems }),
768
+ rejected: {
769
+ count: rejected.length,
770
+ ...(rejected[0] === undefined
771
+ ? {}
772
+ : { latest: rejected[0].at, dir: join(stateRoot, REJECTED_DIR) }),
773
+ },
774
+ };
775
+ }
776
+
777
+ /** What one edited file and its checked copy differ by, or what applying the
778
+ * edits would change about the whole. */
779
+ async function configDiff(args: readonly string[]): Promise<unknown> {
780
+ const parsed = options(args, [], ["satisfied"]);
741
781
  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.
782
+ const stateRoot = resolvePaths().stateRoot;
783
+ if (parsed.flags.has("satisfied")) {
784
+ const read = await evaluate(configDir);
785
+ if (read.satisfied === undefined) {
786
+ throw new CommandError(
787
+ "invalid_args",
788
+ `設定が通らないので比べられません: ${read.problems.map((one) => `${one.file}: ${one.msg}`).join("; ")}`,
789
+ );
790
+ }
791
+ const standing = applied(stateRoot);
746
792
  return {
747
- clusters: clusters.map((one) => ({ id: one.id, name: one.name, peers: one.peers })),
793
+ file: join(stateRoot, STATE_CONFIG_DIR, SATISFIED_FILE),
794
+ diff: unified(
795
+ standing === undefined ? "" : `${JSON.stringify(standing, null, 2)}\n`,
796
+ `${JSON.stringify(read.satisfied, null, 2)}\n`,
797
+ ),
748
798
  };
749
799
  }
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 };
800
+ const named = parsed.rest[0];
801
+ const ids =
802
+ (await evaluate(configDir)).satisfied?.supervisor.instances ??
803
+ applied(stateRoot)?.supervisor.instances ??
804
+ [];
805
+ const wanted =
806
+ named === undefined
807
+ ? configFiles(configDir, ids)
808
+ : [isAbsolute(named) ? named : join(configDir, named)];
809
+ const diffs = wanted.flatMap((file) => {
810
+ const copy = join(stateRoot, STATE_CONFIG_DIR, relative(configDir, file));
811
+ const before = readOr(copy);
812
+ const after = readOr(file);
813
+ if (before === after) return [];
814
+ return [{ file, applied: copy, diff: unified(before, after) }];
815
+ });
816
+ return { files: diffs };
817
+ }
818
+
819
+ async function configShow(args: readonly string[]): Promise<unknown> {
820
+ const parsed = options(args, [], ["applied"]);
821
+ const stateRoot = resolvePaths().stateRoot;
822
+ if (parsed.flags.has("applied")) {
823
+ const standing = applied(stateRoot);
824
+ if (standing === undefined) throw new CommandError("not_found", "まだ何も適用されていません");
825
+ return standing;
753
826
  }
754
- const given = parsed.rest[0];
755
- if (given === undefined) {
827
+ // A dry run: the files are read and the settings functions are called, and
828
+ // nothing is written. They are expected to have no side effects for exactly
829
+ // this reason (§8.2).
830
+ const read = await evaluate(resolveConfigDir());
831
+ if (read.satisfied === undefined) {
756
832
  throw new CommandError(
757
833
  "invalid_args",
758
- `使い方: ccmsg mesh ${what} <endpoint> [--cluster <id|name>]`,
834
+ read.problems.map((one) => `${one.file}: ${one.msg}`).join("; "),
759
835
  );
760
836
  }
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 ではありません`);
837
+ return read.satisfied as Satisfied;
838
+ }
839
+
840
+ /** Put the checked copy back over what is being edited.
841
+ *
842
+ * What is overwritten is kept, under the time it was taken, and its path is
843
+ * printed: a person reverting has just lost an edit, and the one thing they
844
+ * need is where it went. */
845
+ async function configRevert(args: readonly string[]): Promise<unknown> {
846
+ const parsed = options(args, [], ["all"]);
847
+ const named = parsed.rest[0];
848
+ const all = parsed.flags.has("all");
849
+ if (named === undefined && !all) {
850
+ throw new CommandError("invalid_args", "使い方: ccmsg config revert <file> | --all");
782
851
  }
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);
852
+ const configDir = resolveConfigDir();
853
+ const stateRoot = resolvePaths().stateRoot;
854
+ const ids = applied(stateRoot)?.supervisor.instances ?? [];
855
+ const wanted = all
856
+ ? configFiles(configDir, ids)
857
+ : [isAbsolute(named as string) ? (named as string) : join(configDir, named as string)];
858
+ const at = new Date().toISOString().replace(/[:.]/g, "-");
859
+ const done: { file: string; kept?: string }[] = [];
860
+ for (const file of wanted) {
861
+ const copy = join(stateRoot, STATE_CONFIG_DIR, relative(configDir, file));
862
+ if (!existsSync(copy)) {
863
+ if (!all) throw new CommandError("not_found", `${file} の検証済みの写しがありません`);
864
+ continue;
865
+ }
866
+ if (readOr(file) === readOr(copy)) continue;
867
+ let kept: string | undefined;
868
+ if (existsSync(file)) {
869
+ kept = join(stateRoot, REJECTED_DIR, `${relative(configDir, file)}.${at}`);
870
+ mkdirSync(dirname(kept), { recursive: true });
871
+ copyFileSync(file, kept);
872
+ }
873
+ mkdirSync(dirname(file), { recursive: true });
874
+ copyFileSync(copy, file);
875
+ done.push({ file, ...(kept === undefined ? {} : { kept }) });
793
876
  }
794
- return {
795
- cluster_id: cluster.id,
796
- cluster_name: cluster.name,
797
- endpoint,
798
- removed: true,
799
- disconnected: cut,
800
- };
877
+ return { reverted: done };
801
878
  }
802
879
 
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
- );
880
+ /** What was put aside by a revert, newest first. */
881
+ function rejectedFiles(stateRoot: string): { file: string; at: string }[] {
882
+ const dir = join(stateRoot, REJECTED_DIR);
883
+ const found: { file: string; at: string }[] = [];
884
+ const walk = (at: string): void => {
885
+ let entries: string[];
886
+ try {
887
+ entries = readdirSync(at);
888
+ } catch {
889
+ return;
890
+ }
891
+ for (const entry of entries) {
892
+ const path = join(at, entry);
893
+ if (statSync(path).isDirectory()) walk(path);
894
+ else found.push({ file: path, at: statSync(path).mtime.toISOString() });
895
+ }
896
+ };
897
+ walk(dir);
898
+ return found.sort((one, two) => (one.at < two.at ? 1 : -1));
809
899
  }
810
900
 
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
- );
901
+ function readOr(file: string): string {
902
+ try {
903
+ return readFileSync(file, "utf8");
904
+ } catch {
905
+ return "";
822
906
  }
823
- return text as Endpoint;
824
907
  }
825
908
 
826
- /** `ccmsg daemon remove <name>`: take its file away, and stop looking after it.
909
+ function same(one: string, two: string): boolean {
910
+ return readOr(one) === readOr(two);
911
+ }
912
+
913
+ /** A diff a person can read, line by line.
827
914
  *
828
- * The instance itself is left alone: removing the file is not a shutdown, and a
829
- * session already talking to that instance keeps it. `daemon stop` is how one
830
- * is stopped, and keeping the two apart is what makes that true. */
831
- async function removed(name: string | undefined): Promise<unknown> {
832
- if (name === undefined) {
833
- throw new CommandError("invalid_args", "使い方: ccmsg daemon remove <name>");
915
+ * Written here rather than shelled out to: what this compares is two files
916
+ * this process already has, and a command that answers in JSON cannot hand its
917
+ * caller a pager. */
918
+ function unified(before: string, after: string): string[] {
919
+ const from = before === "" ? [] : before.split("\n");
920
+ const to = after === "" ? [] : after.split("\n");
921
+ const lines: string[] = [];
922
+ let at = 0;
923
+ let here = 0;
924
+ while (at < from.length || here < to.length) {
925
+ const left = from[at];
926
+ const right = to[here];
927
+ if (left === right) {
928
+ at += 1;
929
+ here += 1;
930
+ continue;
931
+ }
932
+ if (right !== undefined && !from.slice(at).includes(right)) {
933
+ lines.push(`+ ${right}`);
934
+ here += 1;
935
+ continue;
936
+ }
937
+ if (left !== undefined && !to.slice(here).includes(left)) {
938
+ lines.push(`- ${left}`);
939
+ at += 1;
940
+ continue;
941
+ }
942
+ if (left !== undefined) {
943
+ lines.push(`- ${left}`);
944
+ at += 1;
945
+ }
946
+ if (right !== undefined) {
947
+ lines.push(`+ ${right}`);
948
+ here += 1;
949
+ }
834
950
  }
835
- const row = await removeFromConfig(process.env, name);
836
- if (!(await reachable())) return { ...row, supervised: false };
837
- await ask({ op: "supervise_remove", dir: row.dir });
838
- return { ...row, supervised: false };
951
+ return lines;
839
952
  }
840
953
 
841
954
  /** The four commands that are requests to the supervisor rather than things
@@ -901,17 +1014,13 @@ async function serviceOp(
901
1014
  };
902
1015
  }
903
1016
 
904
- /** The passkey commands, which are asked of an instance of the cluster rather
905
- * than of the supervisor.
1017
+ /** The passkey commands, which are asked of the instance itself rather than of
1018
+ * the supervisor.
906
1019
  *
907
- * They travel on an instance's unix socket and nowhere else: registration is
1020
+ * They travel on that instance's unix socket and nowhere else: registration is
908
1021
  * local by design (DR-0001 §2.2), and reaching that address is what says the
909
1022
  * caller is on the machine. They are not ops of the contract for the same
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. */
1023
+ * reason — the contract is what reaches an instance over a network. */
915
1024
  /** One administrative request, on one instance's own unix socket. */
916
1025
  async function askInstance(target: Target, request: Record<string, unknown>) {
917
1026
  const conn = await connect(target.paths.socket);
@@ -931,29 +1040,11 @@ async function askInstance(target: Target, request: Record<string, unknown>) {
931
1040
  }
932
1041
  }
933
1042
 
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
- };
1043
+ async function passkeyAsk(unit: string | undefined, request: Record<string, unknown>) {
1044
+ return await askInstance(
1045
+ targetFor(process.env, (await dirOf(unit)) ?? resolveConfigHome()),
1046
+ request,
1047
+ );
957
1048
  }
958
1049
 
959
1050
  /** `ccmsg daemon passkey add`: one registration URL, and the code that goes
@@ -963,18 +1054,30 @@ async function passkeyCommand(
963
1054
  * anything the instance hands out — so that holding the URL is not enough to
964
1055
  * register (DR-0001 §2.2). */
965
1056
  async function passkeyAdd(args: readonly string[]): Promise<unknown> {
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
- };
1057
+ const parsed = options(args, ["name"]);
1058
+ const [unit, endpoint] = parsed.rest;
1059
+ if (unit === undefined) {
1060
+ throw new CommandError(
1061
+ "invalid_args",
1062
+ "使い方: ccmsg daemon passkey add <unit> [endpoint] [--name <ラベル>]",
1063
+ );
1064
+ }
1065
+ const name = parsed.named.get("name");
1066
+ return await passkeyAsk(unit, {
1067
+ admin: "passkey_add",
1068
+ ...(endpoint === undefined ? {} : { endpoint }),
1069
+ ...(name === undefined ? {} : { name }),
975
1070
  });
976
1071
  }
977
1072
 
1073
+ async function passkeyRemove(args: readonly string[]): Promise<unknown> {
1074
+ const [sub, unit] = args;
1075
+ if (sub === undefined) {
1076
+ throw new CommandError("invalid_args", "使い方: ccmsg daemon passkey remove <sub> [unit]");
1077
+ }
1078
+ return await passkeyAsk(unit, { admin: "passkey_remove", sub });
1079
+ }
1080
+
978
1081
  /** `ccmsg daemon log`: what one instance wrote down, or what all of them did.
979
1082
  *
980
1083
  * JSON lines rather than one document, because a log is a stream and `--follow`