@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
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]);
@@ -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 {
@@ -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, key by key. A config home the file does not list still
235
- * resolves — `daemon run` on an unregistered directory is the defaults plus
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 { ...shared.defaults, ...entry?.settings };
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. */