@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 +1 -1
- package/src/auth/admin.ts +32 -6
- package/src/cli.ts +298 -81
- package/src/daemon/control.ts +61 -9
- package/src/daemon/registry.ts +370 -65
- package/src/daemon/supervise.ts +14 -2
- package/src/instance/ccmsg-config.d.ts +111 -0
- package/src/instance/config.ts +444 -126
- package/src/instance/identity.ts +11 -2
- package/src/instance/instance.ts +9 -2
- package/src/instance/paths.ts +19 -6
- package/src/mesh/mesh.ts +30 -2
- package/src/upstream/events.ts +108 -10
- package/src/upstream/gateway.ts +7 -2
- package/src/upstream/requests.ts +102 -8
package/package.json
CHANGED
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
|
-
|
|
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(
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
179
|
-
|
|
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: "
|
|
207
|
+
summary: "cluster ごとに instance と、動いているかを並べる",
|
|
193
208
|
usage: "ccmsg daemon list",
|
|
194
209
|
bare: true,
|
|
195
|
-
run: () =>
|
|
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: "
|
|
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
|
|
255
|
+
usage: "ccmsg daemon passkey add [endpoint] [--cluster <id|name>] [--name <ラベル>]",
|
|
231
256
|
options: [
|
|
232
257
|
[
|
|
233
258
|
"[endpoint]",
|
|
234
|
-
"登録先の公開 base URL (末尾 /)
|
|
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 [
|
|
269
|
+
usage: "ccmsg daemon passkey list [--cluster <id|name>]",
|
|
244
270
|
bare: true,
|
|
245
|
-
run: (args) =>
|
|
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> [
|
|
251
|
-
run: (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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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(
|
|
644
|
-
if (
|
|
645
|
-
throw new CommandError("invalid_args", "使い方: ccmsg daemon remove <
|
|
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,
|
|
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 と
|
|
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", "
|
|
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
|
|
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
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|