@ccmsg/cli 0.3.4 → 0.3.5
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 +23 -0
- package/src/daemon/registry.ts +11 -1
- package/src/instance/config.ts +59 -4
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -34,11 +34,22 @@ const SESSION_ENV = HARNESSES.flatMap((harness) => [...HARNESS[harness].sessionE
|
|
|
34
34
|
import { hookEvent, type StatedMeta, statedMeta } from "./greeting/index.ts";
|
|
35
35
|
import {
|
|
36
36
|
isRunning,
|
|
37
|
+
MERGE_RULES,
|
|
37
38
|
resolveConfigHome,
|
|
38
39
|
resolvePaths,
|
|
39
40
|
resolvePathsFor,
|
|
40
41
|
start,
|
|
41
42
|
} from "./instance/index.ts";
|
|
43
|
+
|
|
44
|
+
/** The merge rules as the help prints them: one line per field path, in the
|
|
45
|
+
* order the schema declares them, so the table a person reads is the table the
|
|
46
|
+
* merge runs on. */
|
|
47
|
+
const MERGE_DOCS: readonly Doc[] = Object.entries(MERGE_RULES).map(([path, rule]) => [
|
|
48
|
+
path,
|
|
49
|
+
rule === "merge"
|
|
50
|
+
? "instances[] 側にある field だけを defaults に重ねる"
|
|
51
|
+
: "instances[] 側にあれば丸ごと置換する (追加・和にはならない)",
|
|
52
|
+
]);
|
|
42
53
|
import {
|
|
43
54
|
type Agent,
|
|
44
55
|
AGENTS,
|
|
@@ -98,6 +109,10 @@ interface Command {
|
|
|
98
109
|
readonly usage?: string;
|
|
99
110
|
readonly options?: readonly Doc[];
|
|
100
111
|
readonly env?: readonly Doc[];
|
|
112
|
+
/** Anything else this level has to state as a list of names, under a title of
|
|
113
|
+
* its own. Options and environment are the two every level shares; this is
|
|
114
|
+
* for what only one of them has. */
|
|
115
|
+
readonly notes?: readonly { readonly title: string; readonly docs: readonly Doc[] }[];
|
|
101
116
|
readonly children?: readonly Command[];
|
|
102
117
|
readonly run?: (args: readonly string[]) => Promise<unknown>;
|
|
103
118
|
/** Whether running it with nothing after it is a command rather than a
|
|
@@ -148,6 +163,13 @@ const ROOT: Command = {
|
|
|
148
163
|
`config home が動かすもの: ${HARNESSES.join(" | ")} (既定 ${DEFAULT_HARNESS})`,
|
|
149
164
|
],
|
|
150
165
|
],
|
|
166
|
+
notes: [
|
|
167
|
+
{
|
|
168
|
+
title:
|
|
169
|
+
"共通 config で instances[] の値が defaults に重なる規則 (掲載の無いパスは丸ごと置換):",
|
|
170
|
+
docs: MERGE_DOCS,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
151
173
|
run: (args) => added(args),
|
|
152
174
|
},
|
|
153
175
|
{
|
|
@@ -488,6 +510,7 @@ function help(path: readonly Command[]): string {
|
|
|
488
510
|
}
|
|
489
511
|
lines.push("");
|
|
490
512
|
}
|
|
513
|
+
for (const note of at.notes ?? []) section(lines, note.title, note.docs);
|
|
491
514
|
section(lines, "このレベルのオプション:", at.options);
|
|
492
515
|
section(lines, "グローバルオプション:", GLOBAL_OPTIONS);
|
|
493
516
|
section(lines, "環境変数:", [...(at.env ?? []), ...GLOBAL_ENV]);
|
package/src/daemon/registry.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { basename, isAbsolute, join, resolve } from "node:path";
|
|
|
3
3
|
import type { Endpoint, InstanceId, InstancePingResult } from "@ccmsg/protocol";
|
|
4
4
|
import { DEFAULT_HARNESS, type Harness, HARNESS, isHarness } from "../harness/index.ts";
|
|
5
5
|
import {
|
|
6
|
+
type InstanceConfig,
|
|
6
7
|
type InstanceEntry,
|
|
8
|
+
loadConfig,
|
|
7
9
|
loadShared,
|
|
8
10
|
saveShared,
|
|
9
11
|
settingsFor,
|
|
@@ -61,6 +63,14 @@ export interface InstanceRow {
|
|
|
61
63
|
/** One row of `daemon status`: the list's row, plus what the instance itself
|
|
62
64
|
* says when there is one to ask. */
|
|
63
65
|
export interface StatusRow extends InstanceRow {
|
|
66
|
+
/** What this config home's instance is configured with, after the shared
|
|
67
|
+
* file's defaults and its own entry are merged (§8.2).
|
|
68
|
+
*
|
|
69
|
+
* Answered whether or not anything is running, and read from the file rather
|
|
70
|
+
* than asked of the instance: this is what a restart would apply, which is
|
|
71
|
+
* the question an operator who just edited the file has. It carries no
|
|
72
|
+
* secret — the gateway's token is named by the path it is kept at. */
|
|
73
|
+
readonly config: InstanceConfig;
|
|
64
74
|
readonly version?: string;
|
|
65
75
|
readonly network?: InstancePingResult["network"];
|
|
66
76
|
/** The other instances this one names, each with where it is dialled: the id
|
|
@@ -155,7 +165,7 @@ export function list(env: Env): InstanceRow[] {
|
|
|
155
165
|
/** Ask one instance how it is. A config home with nothing behind it answers the
|
|
156
166
|
* list's row and nothing more: not running is a state, not a failure. */
|
|
157
167
|
export async function status(target: Target): Promise<StatusRow> {
|
|
158
|
-
const row = rowFor(target);
|
|
168
|
+
const row = { ...rowFor(target), config: loadConfig(target.paths.configFile, target.dir) };
|
|
159
169
|
const conn = await connect(target.paths.socket);
|
|
160
170
|
if (conn === undefined) return row;
|
|
161
171
|
try {
|
package/src/instance/config.ts
CHANGED
|
@@ -230,13 +230,68 @@ export function saveShared(file: string, shared: SharedConfig): void {
|
|
|
230
230
|
writeFileSync(file, `${JSON.stringify({ defaults: shared.defaults, instances }, null, 2)}\n`);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/** How one field of the shared file combines an instance's entry with the
|
|
234
|
+
* defaults.
|
|
235
|
+
*
|
|
236
|
+
* `merge` takes the two field by field, so an instance states only what it
|
|
237
|
+
* differs in; `replace` takes the instance's value whole. */
|
|
238
|
+
export type MergeRule = "merge" | "replace";
|
|
239
|
+
|
|
240
|
+
/** The rule for every field path that holds an object or an array, which are
|
|
241
|
+
* the only ones where "combine" could mean more than one thing.
|
|
242
|
+
*
|
|
243
|
+
* Declared beside the parsers rather than derived from the values, because
|
|
244
|
+
* whether a list is a sequence or a set is a fact about what the field means
|
|
245
|
+
* and every list looks the same without it. A path not named here replaces:
|
|
246
|
+
* that is what a scalar can do, and it is what an array does until some field
|
|
247
|
+
* is a set and says so. */
|
|
248
|
+
export const MERGE_RULES: Readonly<Record<string, MergeRule>> = {
|
|
249
|
+
// The same finished list goes to every instance (§7.1), so an instance that
|
|
250
|
+
// writes its own means to run with that one and no other.
|
|
251
|
+
peers: "replace",
|
|
252
|
+
entry: "merge",
|
|
253
|
+
"entry.source_ips": "replace",
|
|
254
|
+
"entry.trusted_proxies": "replace",
|
|
255
|
+
upstream: "merge",
|
|
256
|
+
"upstream.launcher": "merge",
|
|
257
|
+
"upstream.launcher.root_dirs": "replace",
|
|
258
|
+
"upstream.launcher.templates": "replace",
|
|
259
|
+
"upstream.launcher.clean_env": "replace",
|
|
260
|
+
"upstream.launcher.keep_env": "replace",
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
function ruleFor(path: string): MergeRule {
|
|
264
|
+
return MERGE_RULES[path] ?? "replace";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function plainObject(raw: unknown): raw is Record<string, unknown> {
|
|
268
|
+
return typeof raw === "object" && raw !== null && !Array.isArray(raw);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function merged(
|
|
272
|
+
base: Record<string, unknown>,
|
|
273
|
+
over: Record<string, unknown>,
|
|
274
|
+
at: string,
|
|
275
|
+
): Record<string, unknown> {
|
|
276
|
+
const out: Record<string, unknown> = { ...base };
|
|
277
|
+
for (const [name, value] of Object.entries(over)) {
|
|
278
|
+
const path = at === "" ? name : `${at}.${name}`;
|
|
279
|
+
const under = out[name];
|
|
280
|
+
out[name] =
|
|
281
|
+
ruleFor(path) === "merge" && plainObject(under) && plainObject(value)
|
|
282
|
+
? merged(under, value, path)
|
|
283
|
+
: value;
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
233
288
|
/** What one config home's instance is configured with: its own entry over the
|
|
234
|
-
* shared defaults,
|
|
235
|
-
* resolves — `daemon run` on an unregistered directory is
|
|
236
|
-
* the built-ins. */
|
|
289
|
+
* shared defaults, by the rule each field path declares. A config home the file
|
|
290
|
+
* does not list still resolves — `daemon run` on an unregistered directory is
|
|
291
|
+
* the defaults plus the built-ins. */
|
|
237
292
|
export function settingsFor(shared: SharedConfig, dir: string): Record<string, unknown> {
|
|
238
293
|
const entry = shared.instances.find((one) => one.dir === dir);
|
|
239
|
-
return
|
|
294
|
+
return merged(shared.defaults, entry?.settings ?? {}, "");
|
|
240
295
|
}
|
|
241
296
|
|
|
242
297
|
/** One instance's settings, read at the shape the instance uses them. */
|