@ccmsg/cli 0.10.1 → 0.11.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/cli.ts +301 -198
- package/src/daemon/registry.ts +160 -130
- package/src/daemon/supervise.ts +50 -11
- package/src/instance/ccmsg-config.d.ts +10 -9
- package/src/instance/config.ts +374 -310
- package/src/instance/identity.ts +3 -3
- package/src/instance/instance.ts +63 -25
- package/src/instance/paths.ts +24 -6
- package/src/mesh/index.ts +0 -1
- package/src/mesh/mesh.ts +18 -61
- package/src/mesh/wire.ts +1 -29
- package/src/transcript/items/document.ts +8 -32
- package/src/mesh/probe.ts +0 -105
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 {
|
|
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
|
-
|
|
58
|
+
REJECTED_DIR,
|
|
50
59
|
resolveConfigDir,
|
|
51
60
|
resolveConfigHome,
|
|
52
61
|
resolvePaths,
|
|
53
62
|
resolvePathsFor,
|
|
54
|
-
|
|
55
|
-
|
|
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: "
|
|
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 を
|
|
164
|
-
usage: "ccmsg daemon add <dir> [--
|
|
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
|
-
["
|
|
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: "
|
|
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: "
|
|
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: "
|
|
240
|
-
usage: "ccmsg daemon passkey <subcommand>
|
|
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] [--
|
|
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 [
|
|
258
|
+
usage: "ccmsg daemon passkey list [unit]",
|
|
270
259
|
bare: true,
|
|
271
|
-
run: (args) =>
|
|
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> [
|
|
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: "
|
|
303
|
-
summary: "
|
|
304
|
-
usage: "ccmsg
|
|
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: "
|
|
286
|
+
title: "人が編集するのは config dir、instance が読むのは検証済みの写し:",
|
|
309
287
|
docs: [
|
|
310
288
|
[
|
|
311
|
-
"
|
|
312
|
-
"
|
|
289
|
+
"編集用",
|
|
290
|
+
"$CCMSG_CONFIG_DIR (config_v2.ts / endpoints.json / supervisor.json / instances/)",
|
|
313
291
|
],
|
|
314
|
-
["
|
|
315
|
-
["
|
|
292
|
+
["適用用", "$CCMSG_STATE_DIR/config (検証を通った時だけ書かれる写しと satisfied.json)"],
|
|
293
|
+
["通らない時", "適用済みのまま起動する。エラーは log と daemon status に出る"],
|
|
316
294
|
],
|
|
317
295
|
},
|
|
318
296
|
],
|
|
319
297
|
children: [
|
|
320
298
|
{
|
|
321
|
-
name: "
|
|
322
|
-
summary: "
|
|
323
|
-
usage: "ccmsg
|
|
324
|
-
|
|
299
|
+
name: "list",
|
|
300
|
+
summary: "設定ファイルごとの検証結果と、適用済みとの差の有無を並べる",
|
|
301
|
+
usage: "ccmsg config list",
|
|
302
|
+
bare: true,
|
|
303
|
+
run: () => configList(),
|
|
325
304
|
},
|
|
326
305
|
{
|
|
327
|
-
name: "
|
|
328
|
-
summary: "
|
|
329
|
-
usage: "ccmsg
|
|
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) =>
|
|
311
|
+
run: (args) => configDiff(args),
|
|
332
312
|
},
|
|
333
313
|
{
|
|
334
|
-
name: "
|
|
335
|
-
summary: "
|
|
336
|
-
usage: "ccmsg
|
|
337
|
-
|
|
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
|
-
|
|
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"
|
|
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> [--
|
|
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
|
-
|
|
740
|
-
|
|
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
|
|
743
|
-
if (
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
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
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
755
|
-
|
|
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
|
-
|
|
834
|
+
read.problems.map((one) => `${one.file}: ${one.msg}`).join("; "),
|
|
759
835
|
);
|
|
760
836
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
/**
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
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
|
|
905
|
-
*
|
|
1017
|
+
/** The passkey commands, which are asked of the instance itself rather than of
|
|
1018
|
+
* the supervisor.
|
|
906
1019
|
*
|
|
907
|
-
* They travel on
|
|
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
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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`
|