@ccmsg/cli 0.9.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/package.json +1 -1
- package/src/auth/admin.ts +32 -6
- package/src/cli.ts +377 -57
- package/src/daemon/control.ts +61 -9
- package/src/daemon/registry.ts +398 -70
- package/src/daemon/supervise.ts +27 -3
- package/src/instance/ccmsg-config.d.ts +112 -0
- package/src/instance/config.ts +513 -131
- package/src/instance/identity.ts +11 -2
- package/src/instance/instance.ts +71 -26
- package/src/instance/paths.ts +37 -6
- package/src/mesh/index.ts +0 -1
- package/src/mesh/mesh.ts +47 -62
- package/src/mesh/wire.ts +1 -29
- package/src/mesh/probe.ts +0 -105
package/src/cli.ts
CHANGED
|
@@ -30,10 +30,19 @@ import {
|
|
|
30
30
|
tailOf,
|
|
31
31
|
type Target,
|
|
32
32
|
targetFor,
|
|
33
|
+
targetNamed,
|
|
33
34
|
} from "./daemon/index.ts";
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
copyFileSync,
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
readdirSync,
|
|
40
|
+
readFileSync,
|
|
41
|
+
statSync,
|
|
42
|
+
writeFileSync,
|
|
43
|
+
} from "node:fs";
|
|
35
44
|
import { homedir } from "node:os";
|
|
36
|
-
import { isAbsolute, join } from "node:path";
|
|
45
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
37
46
|
import { document } from "./transcript/items/index.ts";
|
|
38
47
|
import { currentSession, DEFAULT_HARNESS, HARNESS, HARNESSES, isHarness } from "./harness/index.ts";
|
|
39
48
|
|
|
@@ -42,23 +51,20 @@ import { currentSession, DEFAULT_HARNESS, HARNESS, HARNESSES, isHarness } from "
|
|
|
42
51
|
const SESSION_ENV = HARNESSES.flatMap((harness) => [...HARNESS[harness].sessionEnv]);
|
|
43
52
|
import { hookEvent, type StatedMeta, statedMeta } from "./greeting/index.ts";
|
|
44
53
|
import {
|
|
54
|
+
applied,
|
|
55
|
+
configFiles,
|
|
56
|
+
evaluate,
|
|
45
57
|
isRunning,
|
|
46
|
-
|
|
58
|
+
REJECTED_DIR,
|
|
59
|
+
resolveConfigDir,
|
|
47
60
|
resolveConfigHome,
|
|
48
61
|
resolvePaths,
|
|
49
62
|
resolvePathsFor,
|
|
63
|
+
SATISFIED_FILE,
|
|
64
|
+
type Satisfied,
|
|
65
|
+
STATE_CONFIG_DIR,
|
|
50
66
|
start,
|
|
51
67
|
} 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
68
|
import {
|
|
63
69
|
type Agent,
|
|
64
70
|
AGENTS,
|
|
@@ -151,22 +157,23 @@ const ROOT: Command = {
|
|
|
151
157
|
{
|
|
152
158
|
name: "run",
|
|
153
159
|
summary: "この config home の instance を foreground で起動する (監督者の管理外)",
|
|
154
|
-
usage: "ccmsg daemon run [dir]",
|
|
160
|
+
usage: "ccmsg daemon run [name | id | dir]",
|
|
155
161
|
bare: true,
|
|
156
162
|
run: (args) => runInstance(args[0]),
|
|
157
163
|
},
|
|
158
164
|
{
|
|
159
165
|
name: "supervise",
|
|
160
|
-
summary: "
|
|
166
|
+
summary: "検証済みの設定が挙げる instance を子として起動し、落ちたら上げる",
|
|
161
167
|
usage: "ccmsg daemon supervise",
|
|
162
168
|
bare: true,
|
|
163
169
|
run: () => supervise(),
|
|
164
170
|
},
|
|
165
171
|
{
|
|
166
172
|
name: "add",
|
|
167
|
-
summary: "
|
|
168
|
-
usage: "ccmsg daemon add <dir> [--harness <種別>]",
|
|
173
|
+
summary: "config home を instance として書き留め、監督者が居れば起こさせる",
|
|
174
|
+
usage: "ccmsg daemon add <dir> [--port <番号>] [--harness <種別>]",
|
|
169
175
|
options: [
|
|
176
|
+
["--port <番号>", "entry の待ち受けポート (既定は登録済みの最大 + 1 の空きポート)"],
|
|
170
177
|
[
|
|
171
178
|
"--harness <種別>",
|
|
172
179
|
`config home が動かすもの: ${HARNESSES.join(" | ")} (既定 ${DEFAULT_HARNESS})`,
|
|
@@ -174,48 +181,59 @@ const ROOT: Command = {
|
|
|
174
181
|
],
|
|
175
182
|
notes: [
|
|
176
183
|
{
|
|
177
|
-
title:
|
|
178
|
-
|
|
179
|
-
|
|
184
|
+
title: "何がどのファイルに載るか (載っていないファイルは読まない):",
|
|
185
|
+
docs: [
|
|
186
|
+
["config_v2.ts", "全 instance が受け取る値。`({builtin, config}) => config`"],
|
|
187
|
+
["endpoints.json", "mesh の一覧 `[{id, endpoint}]`。別 host の分は人が足す"],
|
|
188
|
+
["supervisor.json", "この host が起こす instance の id"],
|
|
189
|
+
[
|
|
190
|
+
"instances/instance-<id>.ts",
|
|
191
|
+
"1 instance 分の差分。`({builtin, default, config}) => config`",
|
|
192
|
+
],
|
|
193
|
+
[
|
|
194
|
+
"ccmsg-config_v2.d.ts",
|
|
195
|
+
"設定ファイルが `import type` で参照する型 (ccmsg が置く)",
|
|
196
|
+
],
|
|
197
|
+
],
|
|
180
198
|
},
|
|
181
199
|
],
|
|
182
200
|
run: (args) => added(args),
|
|
183
201
|
},
|
|
184
202
|
{
|
|
185
203
|
name: "remove",
|
|
186
|
-
summary: "instances
|
|
187
|
-
usage: "ccmsg daemon remove <dir>",
|
|
204
|
+
summary: "両 JSON から外して instances/instance-<id>.ts を消す (子は止めない)",
|
|
205
|
+
usage: "ccmsg daemon remove <name | id | dir>",
|
|
188
206
|
run: (args) => removed(args[0]),
|
|
189
207
|
},
|
|
190
208
|
{
|
|
191
209
|
name: "list",
|
|
192
|
-
summary: "
|
|
210
|
+
summary: "この host が起こす instance と、動いているかを並べる",
|
|
193
211
|
usage: "ccmsg daemon list",
|
|
194
212
|
bare: true,
|
|
195
|
-
run: () =>
|
|
213
|
+
run: () => listInstances(process.env),
|
|
196
214
|
},
|
|
197
215
|
{
|
|
198
216
|
name: "start",
|
|
199
217
|
summary: "監督者に、この config home の子を起こさせる",
|
|
200
|
-
usage: "ccmsg daemon start <dir> | --all",
|
|
218
|
+
usage: "ccmsg daemon start <name | dir> | --all",
|
|
201
219
|
run: (args) => supervised("supervise_start", args),
|
|
202
220
|
},
|
|
203
221
|
{
|
|
204
222
|
name: "stop",
|
|
205
223
|
summary: "監督者に、子を止めさせる (instance.shutdown、以後は上げ直さない)",
|
|
206
|
-
usage: "ccmsg daemon stop <dir> | --all",
|
|
224
|
+
usage: "ccmsg daemon stop <name | dir> | --all",
|
|
207
225
|
run: (args) => supervised("supervise_stop", args),
|
|
208
226
|
},
|
|
209
227
|
{
|
|
210
228
|
name: "restart",
|
|
211
229
|
summary: "監督者に、止めてから起こし直させる",
|
|
212
|
-
usage: "ccmsg daemon restart <dir> | --all",
|
|
230
|
+
usage: "ccmsg daemon restart <name | dir> | --all",
|
|
213
231
|
run: (args) => supervised("supervise_restart", args),
|
|
214
232
|
},
|
|
215
233
|
{
|
|
216
234
|
name: "status",
|
|
217
235
|
summary: "監督者が各子に instance.ping して version・network・peers を答える",
|
|
218
|
-
usage: "ccmsg daemon status [dir] | --all",
|
|
236
|
+
usage: "ccmsg daemon status [name | id | dir] | --all",
|
|
219
237
|
bare: true,
|
|
220
238
|
run: (args) => supervised("supervise_status", args, true),
|
|
221
239
|
},
|
|
@@ -229,10 +247,7 @@ const ROOT: Command = {
|
|
|
229
247
|
summary: "登録用 URL と 6 桁コードを 1 組発行する (10 分で失効)",
|
|
230
248
|
usage: "ccmsg daemon passkey add <unit> [endpoint] [--name <ラベル>]",
|
|
231
249
|
options: [
|
|
232
|
-
[
|
|
233
|
-
"[endpoint]",
|
|
234
|
-
"登録先の公開 base URL (末尾 /)。既定はこの instance が確定した endpoint",
|
|
235
|
-
],
|
|
250
|
+
["[endpoint]", "登録先の公開 base URL (末尾 /)。既定はこの instance の endpoint"],
|
|
236
251
|
["--name <ラベル>", "誰宛に発行した URL かの管理ラベル"],
|
|
237
252
|
],
|
|
238
253
|
run: (args) => passkeyAdd(args),
|
|
@@ -255,13 +270,63 @@ const ROOT: Command = {
|
|
|
255
270
|
{
|
|
256
271
|
name: "log",
|
|
257
272
|
summary: "instance の daemon.log を出す (--all は行に id を足して多重化)",
|
|
258
|
-
usage: "ccmsg daemon log [dir] | --all [--follow]",
|
|
273
|
+
usage: "ccmsg daemon log [name | id | dir] | --all [--follow]",
|
|
259
274
|
options: [["--follow", "書き足される行を待ち続ける (Ctrl-C で終わり)"]],
|
|
260
275
|
bare: true,
|
|
261
276
|
run: (args) => daemonLog(args),
|
|
262
277
|
},
|
|
263
278
|
],
|
|
264
279
|
},
|
|
280
|
+
{
|
|
281
|
+
name: "config",
|
|
282
|
+
summary: "設定ファイルの検証と、適用済みとの差を見る",
|
|
283
|
+
usage: "ccmsg config <subcommand> [file] [options]",
|
|
284
|
+
notes: [
|
|
285
|
+
{
|
|
286
|
+
title: "人が編集するのは config dir、instance が読むのは検証済みの写し:",
|
|
287
|
+
docs: [
|
|
288
|
+
[
|
|
289
|
+
"編集用",
|
|
290
|
+
"$CCMSG_CONFIG_DIR (config_v2.ts / endpoints.json / supervisor.json / instances/)",
|
|
291
|
+
],
|
|
292
|
+
["適用用", "$CCMSG_STATE_DIR/config (検証を通った時だけ書かれる写しと satisfied.json)"],
|
|
293
|
+
["通らない時", "適用済みのまま起動する。エラーは log と daemon status に出る"],
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
children: [
|
|
298
|
+
{
|
|
299
|
+
name: "list",
|
|
300
|
+
summary: "設定ファイルごとの検証結果と、適用済みとの差の有無を並べる",
|
|
301
|
+
usage: "ccmsg config list",
|
|
302
|
+
bare: true,
|
|
303
|
+
run: () => configList(),
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: "diff",
|
|
307
|
+
summary: "編集中のファイルと適用済みの写しの差 (--satisfied は適用したら何が変わるか)",
|
|
308
|
+
usage: "ccmsg config diff [file] | --satisfied",
|
|
309
|
+
options: [["--satisfied", "今の satisfied.json と、編集中から評価し直した物の差"]],
|
|
310
|
+
bare: true,
|
|
311
|
+
run: (args) => configDiff(args),
|
|
312
|
+
},
|
|
313
|
+
{
|
|
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),
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
},
|
|
265
330
|
{
|
|
266
331
|
name: "service",
|
|
267
332
|
summary: "監督者 (ccmsg daemon supervise) を launchd / systemd に登録する",
|
|
@@ -568,15 +633,19 @@ function section(lines: string[], title: string, docs: readonly Doc[] | undefine
|
|
|
568
633
|
lines.push("");
|
|
569
634
|
}
|
|
570
635
|
|
|
571
|
-
/** `ccmsg daemon run [dir]`: this config home's instance, in the
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const
|
|
636
|
+
/** `ccmsg daemon run [name | dir]`: this config home's instance, in the
|
|
637
|
+
* foreground. */
|
|
638
|
+
async function runInstance(given: string | undefined): Promise<unknown> {
|
|
639
|
+
const named = (await dirOf(given)) ?? resolveConfigHome();
|
|
640
|
+
const home = configHome(named, await harnessFor(process.env, named));
|
|
575
641
|
// The directory is handed over rather than put in the environment: the
|
|
576
642
|
// instance would otherwise read it back through the question "which session
|
|
577
643
|
// is this process inside", and a `daemon run` issued from a session of
|
|
578
644
|
// another harness would answer for that session's config home (§3.8).
|
|
579
|
-
|
|
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()) });
|
|
580
649
|
if (!isRunning(outcome)) {
|
|
581
650
|
throw new CommandError(
|
|
582
651
|
"file_exists",
|
|
@@ -620,36 +689,268 @@ async function supervise(): Promise<unknown> {
|
|
|
620
689
|
* the supervisor reads the list once (DV-Q8) and would otherwise not know
|
|
621
690
|
* until it is restarted. */
|
|
622
691
|
async function added(args: readonly string[]): Promise<unknown> {
|
|
623
|
-
const { named, rest } = options(args, ["harness"]);
|
|
692
|
+
const { named, rest } = options(args, ["harness", "port"]);
|
|
624
693
|
const dir = rest[0];
|
|
625
694
|
const stated = named.get("harness");
|
|
695
|
+
const port = named.get("port");
|
|
626
696
|
if (dir === undefined) {
|
|
627
|
-
throw new CommandError(
|
|
697
|
+
throw new CommandError(
|
|
698
|
+
"invalid_args",
|
|
699
|
+
"使い方: ccmsg daemon add <dir> [--port <番号>] [--harness <種別>]",
|
|
700
|
+
);
|
|
628
701
|
}
|
|
629
702
|
if (stated !== undefined && !isHarness(stated)) {
|
|
630
703
|
throw new CommandError("invalid_args", `--harness は ${HARNESSES.join(" | ")} のどれかです`);
|
|
631
704
|
}
|
|
632
|
-
|
|
705
|
+
if (port !== undefined && !/^\d{1,5}$/.test(port)) {
|
|
706
|
+
throw new CommandError("invalid_args", "--port は 0 から 65535 の番号です");
|
|
707
|
+
}
|
|
708
|
+
const row = await addToConfig(process.env, dir, {
|
|
709
|
+
...(stated === undefined ? {} : { harness: stated }),
|
|
710
|
+
...(port === undefined ? {} : { port: Number(port) }),
|
|
711
|
+
});
|
|
633
712
|
if (!(await reachable())) return { ...row, supervised: false };
|
|
634
713
|
const started = (await ask({ op: "supervise_add", dir: row.dir })) as Record<string, unknown>;
|
|
635
|
-
return { ...started, supervised: true };
|
|
714
|
+
return { ...started, name: row.name, supervised: true };
|
|
636
715
|
}
|
|
637
716
|
|
|
638
|
-
/** `ccmsg
|
|
717
|
+
/** `ccmsg mesh <what> [endpoint]`: the mesh endpoints this host does not serve
|
|
718
|
+
* itself.
|
|
719
|
+
*
|
|
720
|
+
* Its own command rather than one under `daemon`, because what it edits is not
|
|
721
|
+
* one instance's anything: every instance of this host is in the same mesh
|
|
722
|
+
* (§7.1), so the list is the host's. `peers` is what a session list is called,
|
|
723
|
+
* which is why this is called what the thing itself is called.
|
|
639
724
|
*
|
|
640
|
-
*
|
|
725
|
+
* An addition takes effect when the instances next start, for the reason
|
|
726
|
+
* nothing else reloads either (DV-Q8). A removal is told to whoever is running
|
|
727
|
+
* as well as written down: an endpoint taken off the list is one this host is
|
|
728
|
+
* not to be talking to, and leaving a live link up until the next restart would
|
|
729
|
+
* be leaving exactly the connection that was just revoked. */
|
|
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
|
|
641
734
|
* session already talking to that instance keeps it. `daemon stop` is how one
|
|
642
735
|
* 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 <dir>");
|
|
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>");
|
|
646
739
|
}
|
|
647
|
-
const row = removeFromConfig(process.env,
|
|
740
|
+
const row = await removeFromConfig(process.env, ref);
|
|
648
741
|
if (!(await reachable())) return { ...row, supervised: false };
|
|
649
742
|
await ask({ op: "supervise_remove", dir: row.dir });
|
|
650
743
|
return { ...row, supervised: false };
|
|
651
744
|
}
|
|
652
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"]);
|
|
781
|
+
const configDir = resolveConfigDir();
|
|
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);
|
|
792
|
+
return {
|
|
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
|
+
),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
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;
|
|
826
|
+
}
|
|
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) {
|
|
832
|
+
throw new CommandError(
|
|
833
|
+
"invalid_args",
|
|
834
|
+
read.problems.map((one) => `${one.file}: ${one.msg}`).join("; "),
|
|
835
|
+
);
|
|
836
|
+
}
|
|
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");
|
|
851
|
+
}
|
|
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 }) });
|
|
876
|
+
}
|
|
877
|
+
return { reverted: done };
|
|
878
|
+
}
|
|
879
|
+
|
|
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));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function readOr(file: string): string {
|
|
902
|
+
try {
|
|
903
|
+
return readFileSync(file, "utf8");
|
|
904
|
+
} catch {
|
|
905
|
+
return "";
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
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.
|
|
914
|
+
*
|
|
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
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return lines;
|
|
952
|
+
}
|
|
953
|
+
|
|
653
954
|
/** The four commands that are requests to the supervisor rather than things
|
|
654
955
|
* this process does.
|
|
655
956
|
*
|
|
@@ -666,14 +967,26 @@ async function supervised(
|
|
|
666
967
|
const all = parsed.flags.has("all");
|
|
667
968
|
const named = parsed.rest[0];
|
|
668
969
|
if (all && named !== undefined) {
|
|
669
|
-
throw new CommandError("invalid_args", "--all と
|
|
970
|
+
throw new CommandError("invalid_args", "--all と name は同時に指定できません");
|
|
670
971
|
}
|
|
671
972
|
if (all) return await ask({ op, all: true });
|
|
672
|
-
const dir = named ?? (hereByDefault ? resolveConfigHome() : undefined);
|
|
673
|
-
if (dir === undefined) throw new CommandError("invalid_args", "
|
|
973
|
+
const dir = (await dirOf(named)) ?? (hereByDefault ? resolveConfigHome() : undefined);
|
|
974
|
+
if (dir === undefined) throw new CommandError("invalid_args", "name か --all が要ります");
|
|
674
975
|
return await ask({ op, dir });
|
|
675
976
|
}
|
|
676
977
|
|
|
978
|
+
/** The config home a command was given, by either of the two things a person
|
|
979
|
+
* has to hand: the name they added it under, or the directory itself.
|
|
980
|
+
*
|
|
981
|
+
* A name first, because that is what `daemon add` took and so what a person
|
|
982
|
+
* has written down; anything nobody registered under that name is taken as the
|
|
983
|
+
* directory it looks like, which is what makes `daemon run` on an unregistered
|
|
984
|
+
* config home reachable. */
|
|
985
|
+
async function dirOf(given: string | undefined): Promise<string | undefined> {
|
|
986
|
+
if (given === undefined) return undefined;
|
|
987
|
+
return (await targetNamed(process.env, given))?.dir ?? given;
|
|
988
|
+
}
|
|
989
|
+
|
|
677
990
|
/** `ccmsg service <what>`: the supervisor's registration with the host. */
|
|
678
991
|
async function serviceOp(
|
|
679
992
|
what: "register" | "unregister" | "start" | "stop" | "status",
|
|
@@ -694,7 +1007,7 @@ async function serviceOp(
|
|
|
694
1007
|
kind: service.kind,
|
|
695
1008
|
unit: service.unitFile,
|
|
696
1009
|
...state,
|
|
697
|
-
instances: registered(process.env).map((target) => {
|
|
1010
|
+
instances: (await registered(process.env)).map((target) => {
|
|
698
1011
|
const row = rowFor(target);
|
|
699
1012
|
return { id: row.id, dir: row.dir, running: row.running };
|
|
700
1013
|
}),
|
|
@@ -704,12 +1017,12 @@ async function serviceOp(
|
|
|
704
1017
|
/** The passkey commands, which are asked of the instance itself rather than of
|
|
705
1018
|
* the supervisor.
|
|
706
1019
|
*
|
|
707
|
-
* They travel on
|
|
1020
|
+
* They travel on that instance's unix socket and nowhere else: registration is
|
|
708
1021
|
* local by design (DR-0001 §2.2), and reaching that address is what says the
|
|
709
1022
|
* caller is on the machine. They are not ops of the contract for the same
|
|
710
1023
|
* reason — the contract is what reaches an instance over a network. */
|
|
711
|
-
|
|
712
|
-
|
|
1024
|
+
/** One administrative request, on one instance's own unix socket. */
|
|
1025
|
+
async function askInstance(target: Target, request: Record<string, unknown>) {
|
|
713
1026
|
const conn = await connect(target.paths.socket);
|
|
714
1027
|
if (conn === undefined) {
|
|
715
1028
|
throw new CommandError("not_found", `${target.dir} の instance は動いていません`);
|
|
@@ -727,6 +1040,13 @@ async function passkeyAsk(unit: string | undefined, request: Record<string, unkn
|
|
|
727
1040
|
}
|
|
728
1041
|
}
|
|
729
1042
|
|
|
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
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
730
1050
|
/** `ccmsg daemon passkey add`: one registration URL, and the code that goes
|
|
731
1051
|
* with it.
|
|
732
1052
|
*
|
|
@@ -772,7 +1092,7 @@ async function daemonLog(args: readonly string[]): Promise<undefined> {
|
|
|
772
1092
|
// shape whether the host runs one or five.
|
|
773
1093
|
const many = parsed.flags.has("all");
|
|
774
1094
|
const targets = many
|
|
775
|
-
? registered(process.env)
|
|
1095
|
+
? await registered(process.env)
|
|
776
1096
|
: [targetFor(process.env, parsed.rest[0] ?? resolveConfigHome())];
|
|
777
1097
|
const write = (target: Target, lines: readonly string[]): void => {
|
|
778
1098
|
for (const line of lines) {
|
|
@@ -1191,7 +1511,7 @@ async function plugin(
|
|
|
1191
1511
|
}
|
|
1192
1512
|
// `status` with no agent named answers for the config home this process
|
|
1193
1513
|
// belongs to, which is what the instance there runs.
|
|
1194
|
-
const which = agent ?? harnessFor(process.env, resolvePaths().configHome);
|
|
1514
|
+
const which = agent ?? (await harnessFor(process.env, resolvePaths().configHome));
|
|
1195
1515
|
// The config home is that agent's own, and not whichever variable happens to
|
|
1196
1516
|
// be set: a Codex session started from a Claude Code session carries both,
|
|
1197
1517
|
// and an install that read the wrong one would write Codex's hooks into
|