@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/daemon/control.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { PROTOCOL_VERSION } from "@ccmsg/protocol";
|
|
|
2
2
|
|
|
3
3
|
/** One short exchange over an instance's unix socket.
|
|
4
4
|
*
|
|
5
|
-
* The CLI asks one thing at a time, so a `request_id` is a counter and the
|
|
6
|
-
* answer is the
|
|
7
|
-
*
|
|
5
|
+
* The CLI asks one thing at a time, so a `request_id` is a counter — and the
|
|
6
|
+
* answer is the frame carrying it, not the next one to arrive: an instance
|
|
7
|
+
* pushes topic frames of its own accord, and one landing mid-exchange would
|
|
8
|
+
* otherwise be read as the reply. */
|
|
8
9
|
export interface Conn {
|
|
9
10
|
ask(request: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
10
11
|
/** The next frame the instance sends without having been asked for one: a
|
|
@@ -35,8 +36,12 @@ export async function connect(path: string): Promise<Conn | undefined> {
|
|
|
35
36
|
return {
|
|
36
37
|
ask(request) {
|
|
37
38
|
counter += 1;
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
const id = `${counter}`;
|
|
40
|
+
// Registered before the write, so a reply that arrives in the same turn
|
|
41
|
+
// is the one this call settles on.
|
|
42
|
+
const answer = replies.answer(id);
|
|
43
|
+
socket.write(`${JSON.stringify({ request_id: id, ...request })}\n`);
|
|
44
|
+
return answer;
|
|
40
45
|
},
|
|
41
46
|
next() {
|
|
42
47
|
return replies.next();
|
|
@@ -55,9 +60,18 @@ export function greetAsUser(conn: Conn): Promise<Record<string, unknown>> {
|
|
|
55
60
|
return conn.ask({ op: "hello.user", protocol_version: PROTOCOL_VERSION });
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
/**
|
|
63
|
+
/** Sort what arrives on one connection into the answer somebody is waiting for
|
|
64
|
+
* and everything else.
|
|
65
|
+
*
|
|
66
|
+
* By `request_id` rather than by arrival order, because the two are not the
|
|
67
|
+
* same stream: an instance pushes topic frames of its own accord (§6), and one
|
|
68
|
+
* of those landing between a request and its reply would otherwise be read as
|
|
69
|
+
* the reply. It is not a rare window — greeting an instance that has mesh peers
|
|
70
|
+
* is enough, since a peer connecting moves a row on `instances`. */
|
|
59
71
|
class Replies {
|
|
60
|
-
readonly #
|
|
72
|
+
readonly #unasked: Record<string, unknown>[] = [];
|
|
73
|
+
readonly #answers = new Map<string, (frame: Record<string, unknown>) => void>();
|
|
74
|
+
readonly #ready = new Map<string, Record<string, unknown>>();
|
|
61
75
|
#waiting: ((frame: Record<string, unknown>) => void) | undefined;
|
|
62
76
|
#buffer = "";
|
|
63
77
|
|
|
@@ -69,8 +83,33 @@ class Replies {
|
|
|
69
83
|
this.#buffer = this.#buffer.slice(at + 1);
|
|
70
84
|
if (line.trim() === "") continue;
|
|
71
85
|
const frame = JSON.parse(line) as Record<string, unknown>;
|
|
86
|
+
const id = frame["request_id"];
|
|
87
|
+
if (typeof id === "string") {
|
|
88
|
+
const asked = this.#answers.get(id);
|
|
89
|
+
if (asked !== undefined) {
|
|
90
|
+
this.#answers.delete(id);
|
|
91
|
+
asked(frame);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (this.#answers.size > 0) {
|
|
95
|
+
// A reply to something still being waited for by somebody who has not
|
|
96
|
+
// got here yet: held by its id rather than queued as unasked-for.
|
|
97
|
+
this.#ready.set(id, frame);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
} else if (frame["ev"] === undefined && this.#answers.size === 1) {
|
|
101
|
+
// An answer that named nothing, while exactly one thing is waiting for
|
|
102
|
+
// one: it is that one. Matching only by id would leave a caller waiting
|
|
103
|
+
// for ever on a refusal raised before the request could be read, which
|
|
104
|
+
// is the moment an answer is most needed.
|
|
105
|
+
for (const [pending, settle] of this.#answers) {
|
|
106
|
+
this.#answers.delete(pending);
|
|
107
|
+
settle(frame);
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
72
111
|
const waiting = this.#waiting;
|
|
73
|
-
if (waiting === undefined) this.#
|
|
112
|
+
if (waiting === undefined) this.#unasked.push(frame);
|
|
74
113
|
else {
|
|
75
114
|
this.#waiting = undefined;
|
|
76
115
|
waiting(frame);
|
|
@@ -78,8 +117,21 @@ class Replies {
|
|
|
78
117
|
}
|
|
79
118
|
}
|
|
80
119
|
|
|
120
|
+
/** The reply to one request, whenever it lands. */
|
|
121
|
+
answer(id: string): Promise<Record<string, unknown>> {
|
|
122
|
+
const already = this.#ready.get(id);
|
|
123
|
+
if (already !== undefined) {
|
|
124
|
+
this.#ready.delete(id);
|
|
125
|
+
return Promise.resolve(already);
|
|
126
|
+
}
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
this.#answers.set(id, resolve);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The next frame nobody asked for. */
|
|
81
133
|
next(): Promise<Record<string, unknown>> {
|
|
82
|
-
const first = this.#
|
|
134
|
+
const first = this.#unasked.shift();
|
|
83
135
|
if (first !== undefined) return Promise.resolve(first);
|
|
84
136
|
return new Promise((resolve) => {
|
|
85
137
|
this.#waiting = resolve;
|
package/src/daemon/registry.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, watch } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import type { Endpoint, InstanceId, InstancePingResult } from "@ccmsg/protocol";
|
|
4
|
-
import { DEFAULT_HARNESS, type Harness, HARNESS,
|
|
4
|
+
import { DEFAULT_HARNESS, type Harness, HARNESS, HARNESSES } from "../harness/index.ts";
|
|
5
5
|
import {
|
|
6
|
+
applied,
|
|
7
|
+
type ConfigProblem,
|
|
8
|
+
CONFIG_FILE,
|
|
9
|
+
CONFIG_NAME,
|
|
10
|
+
configOf,
|
|
11
|
+
DEFAULT_CONFIG,
|
|
12
|
+
type EndpointRow,
|
|
13
|
+
ENDPOINTS_FILE,
|
|
14
|
+
evaluate,
|
|
6
15
|
type InstanceConfig,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
instanceFileName,
|
|
17
|
+
type InstanceSetting,
|
|
18
|
+
type Satisfied,
|
|
19
|
+
settle,
|
|
20
|
+
SUPERVISOR_FILE,
|
|
21
|
+
TYPES_FILE,
|
|
22
|
+
writeConfigTypes,
|
|
13
23
|
} from "../instance/config.ts";
|
|
14
24
|
import { instanceIdentity } from "../instance/identity.ts";
|
|
15
25
|
import { alive, lockHolder } from "../instance/lock.ts";
|
|
@@ -37,25 +47,61 @@ export function configHome(dir: string, harness: Harness = DEFAULT_HARNESS): str
|
|
|
37
47
|
return path;
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
/** Which harness a registered config home runs, as
|
|
50
|
+
/** Which harness a registered config home runs, as its own file says.
|
|
41
51
|
*
|
|
42
|
-
* Read from the same
|
|
52
|
+
* Read from the same file the instance itself will read (§8.2), so a command
|
|
43
53
|
* that has to know before anything is running — `run`, and the supervisor's
|
|
44
|
-
* own start — reaches the same answer the instance does. A directory
|
|
45
|
-
*
|
|
46
|
-
* is. */
|
|
47
|
-
export function harnessFor(env: Env, dir: string): Harness {
|
|
54
|
+
* own start — reaches the same answer the instance does. A directory no file
|
|
55
|
+
* names runs whatever the defaults say, which is what an unregistered
|
|
56
|
+
* `daemon run` is. */
|
|
57
|
+
export async function harnessFor(env: Env, dir: string): Promise<Harness> {
|
|
48
58
|
const path = isAbsolute(dir) ? dir : resolve(dir);
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
const found = (await known(env)).instances.find((one) => one.dir === path);
|
|
60
|
+
return found?.config.harness ?? DEFAULT_HARNESS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What this host runs: the settings that are applied, read again from the
|
|
64
|
+
* files when they check out.
|
|
65
|
+
*
|
|
66
|
+
* Every command goes through the one path — read, check, apply — so what a
|
|
67
|
+
* command acts on is what a start would run. A config that does not check out
|
|
68
|
+
* leaves the applied one standing, which is what keeps a command about one
|
|
69
|
+
* instance working while another instance's file is being edited. */
|
|
70
|
+
export async function known(env: Env): Promise<Satisfied> {
|
|
71
|
+
const paths = resolvePaths(env);
|
|
72
|
+
const read = await evaluate(paths.configDir);
|
|
73
|
+
return read.satisfied ?? applied(paths.stateRoot) ?? EMPTY_SATISFIED;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const EMPTY_SATISFIED: Satisfied = {
|
|
77
|
+
endpoints: [],
|
|
78
|
+
supervisor: { instances: [] },
|
|
79
|
+
instances: [],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Read the files, check them, and write down what holds — the one thing a
|
|
83
|
+
* start, a reload, an `add` and a `remove` all do. */
|
|
84
|
+
export async function reload(env: Env): Promise<{
|
|
85
|
+
satisfied: Satisfied;
|
|
86
|
+
problems: readonly ConfigProblem[];
|
|
87
|
+
}> {
|
|
88
|
+
const paths = resolvePaths(env);
|
|
89
|
+
const settled = await settle(paths.configDir, paths.stateRoot);
|
|
90
|
+
return { satisfied: settled.satisfied, problems: settled.problems };
|
|
52
91
|
}
|
|
53
92
|
|
|
54
93
|
/** One row of `daemon list`: which config home, and whether anything answers
|
|
55
94
|
* for it right now. */
|
|
56
95
|
export interface InstanceRow {
|
|
57
96
|
readonly id: InstanceId;
|
|
97
|
+
/** The label this instance is listed under: its own file's `name`, which
|
|
98
|
+
* defaults to its id. A `daemon run` on a config home nothing states
|
|
99
|
+
* settings for has none. */
|
|
100
|
+
readonly name?: string;
|
|
58
101
|
readonly dir: string;
|
|
102
|
+
/** The address it binds, and the one its peers dial (§7.1). */
|
|
103
|
+
readonly port?: number;
|
|
104
|
+
readonly endpoint?: string;
|
|
59
105
|
readonly running: boolean;
|
|
60
106
|
readonly pid?: number;
|
|
61
107
|
}
|
|
@@ -63,6 +109,9 @@ export interface InstanceRow {
|
|
|
63
109
|
/** One row of `daemon status`: the list's row, plus what the instance itself
|
|
64
110
|
* says when there is one to ask. */
|
|
65
111
|
export interface StatusRow extends InstanceRow {
|
|
112
|
+
/** What was wrong with the files, where the applied settings are older than
|
|
113
|
+
* what is written. */
|
|
114
|
+
readonly config_problems?: readonly ConfigProblem[];
|
|
66
115
|
/** What this config home's instance is configured with, after the shared
|
|
67
116
|
* file's defaults and its own entry are merged (§8.2).
|
|
68
117
|
*
|
|
@@ -81,18 +130,36 @@ export interface StatusRow extends InstanceRow {
|
|
|
81
130
|
|
|
82
131
|
/** Everything one command needs to reach one config home. */
|
|
83
132
|
export interface Target {
|
|
133
|
+
/** The label this instance is listed under, where it is registered. */
|
|
134
|
+
readonly name?: string;
|
|
135
|
+
/** Its id, which is what its file is called. */
|
|
136
|
+
readonly id?: string;
|
|
84
137
|
readonly dir: string;
|
|
85
138
|
readonly paths: InstancePaths;
|
|
86
139
|
}
|
|
87
140
|
|
|
88
|
-
export function targetFor(env: Env, dir: string): Target {
|
|
89
|
-
return {
|
|
141
|
+
export function targetFor(env: Env, dir: string, name?: string, id?: string): Target {
|
|
142
|
+
return {
|
|
143
|
+
...(name === undefined ? {} : { name }),
|
|
144
|
+
...(id === undefined ? {} : { id }),
|
|
145
|
+
dir,
|
|
146
|
+
paths: resolvePathsFor(dir, env),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The config homes this host starts, in the order the supervisor lists them. */
|
|
151
|
+
export async function registered(env: Env): Promise<Target[]> {
|
|
152
|
+
return (await known(env)).instances.map((entry) =>
|
|
153
|
+
targetFor(env, entry.dir, entry.name, entry.id),
|
|
154
|
+
);
|
|
90
155
|
}
|
|
91
156
|
|
|
92
|
-
/** The
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return
|
|
157
|
+
/** The instance a command was given, by any of the three things a person has
|
|
158
|
+
* to hand: the label it is listed under, its id, or the directory itself. */
|
|
159
|
+
export async function targetNamed(env: Env, ref: string): Promise<Target | undefined> {
|
|
160
|
+
return (await registered(env)).find(
|
|
161
|
+
(target) => target.name === ref || target.id === ref || target.dir === ref,
|
|
162
|
+
);
|
|
96
163
|
}
|
|
97
164
|
|
|
98
165
|
/** The selections the shared file starts with.
|
|
@@ -138,55 +205,293 @@ const STARTING_PRESETS = [
|
|
|
138
205
|
},
|
|
139
206
|
];
|
|
140
207
|
|
|
141
|
-
/**
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
208
|
+
/** What `daemon add` takes: the config home the instance answers for, and the
|
|
209
|
+
* two settings a person would otherwise open the file to write. */
|
|
210
|
+
export interface AddOptions {
|
|
211
|
+
readonly harness?: Harness;
|
|
212
|
+
readonly port?: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** What a config home is called, for a person reading a listing: its own last
|
|
216
|
+
* segment, without the dot a config home is usually hidden by.
|
|
217
|
+
*
|
|
218
|
+
* A label and not an identity — the file and everything the instance issued
|
|
219
|
+
* are keyed by its id, so this may be changed in the file afterwards. Taken
|
|
220
|
+
* from the directory because that is the one of the two that already exists;
|
|
221
|
+
* a directory whose name could not be a label leaves the id as the name, which
|
|
222
|
+
* is what a name defaults to anyway. */
|
|
223
|
+
export function nameFor(dir: string): string {
|
|
224
|
+
const name = basename(dir).replace(/^\.+/, "");
|
|
225
|
+
return CONFIG_NAME.test(name) ? name : "";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Which harness a config home runs, as the directory itself says.
|
|
229
|
+
*
|
|
230
|
+
* The marker file is the evidence: Claude Code keeps `settings.json` and Codex
|
|
231
|
+
* keeps `config.toml`, so a directory that holds one of them is that harness's
|
|
232
|
+
* (§3.8). A directory holding both, or neither, is not answered for — the
|
|
233
|
+
* first is two answers and the second is none, and guessing either way writes
|
|
234
|
+
* down a setting the instance will act on for the whole of its life. */
|
|
235
|
+
export function harnessOf(dir: string): Harness {
|
|
236
|
+
const found = HARNESSES.filter((harness) => existsSync(join(dir, HARNESS[harness].marker)));
|
|
237
|
+
const only = found[0];
|
|
238
|
+
if (found.length !== 1 || only === undefined) {
|
|
239
|
+
throw new CommandError(
|
|
240
|
+
"invalid_args",
|
|
241
|
+
found.length === 0
|
|
242
|
+
? `${dir} がどの harness の config home か分かりません (${HARNESSES.map((one) => HARNESS[one].marker).join(" / ")} がありません)。--harness で指定してください`
|
|
243
|
+
: `${dir} は ${found.join(" と ")} の両方の目印を持っています。--harness で指定してください`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return only;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The port the next instance listens on: one past the highest any registered
|
|
250
|
+
* instance holds, or the first of the range when there are none.
|
|
145
251
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
252
|
+
* Counted from what is configured and then confirmed against the kernel,
|
|
253
|
+
* because the two answer different questions — the first is what this host has
|
|
254
|
+
* already handed out, and the second is whether anything else on the machine
|
|
255
|
+
* is on it. A person who wants a particular port says so and gets it or gets
|
|
256
|
+
* the refusal. */
|
|
257
|
+
export const FIRST_PORT = 8643;
|
|
258
|
+
|
|
259
|
+
/** How far the search walks before it says so rather than going on. A run of
|
|
260
|
+
* this many taken ports is a host whose ports are somebody else's business. */
|
|
261
|
+
const PORT_SEARCH = 64;
|
|
262
|
+
|
|
263
|
+
export async function freePort(taken: readonly number[]): Promise<number> {
|
|
264
|
+
const first = taken.length === 0 ? FIRST_PORT : Math.max(...taken) + 1;
|
|
265
|
+
for (let port = first; port < first + PORT_SEARCH; port += 1) {
|
|
266
|
+
if (taken.includes(port)) continue;
|
|
267
|
+
if (await bindable(port)) return port;
|
|
156
268
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
269
|
+
throw new CommandError(
|
|
270
|
+
"internal_error",
|
|
271
|
+
`${String(first)} から ${String(PORT_SEARCH)} 個のポートが全部塞がっています。--port で指定してください`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Whether this host will give out an address, asked by taking it and letting
|
|
276
|
+
* it go again. Nothing else answers it: a port is free when the kernel says
|
|
277
|
+
* so, and every other account of it is out of date the moment it is read. */
|
|
278
|
+
async function bindable(port: number): Promise<boolean> {
|
|
279
|
+
try {
|
|
280
|
+
const server = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response("") });
|
|
281
|
+
await server.stop(true);
|
|
282
|
+
return true;
|
|
283
|
+
} catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Write down one more instance: one file under `instances/`, called by the
|
|
289
|
+
* name the instance is called.
|
|
290
|
+
*
|
|
291
|
+
* A template rather than an empty file, because what the file has to say —
|
|
292
|
+
* which config home, and how a setting is written at all — is exactly what a
|
|
293
|
+
* person adding their second instance does not yet know. It states only what
|
|
294
|
+
* differs from the shared file, which is what makes that file worth having:
|
|
295
|
+
* everything left out is whatever that file returns.
|
|
175
296
|
*
|
|
176
|
-
* The
|
|
297
|
+
* The shared file is written the first time, with the dump presets in it, and never
|
|
298
|
+
* again: what a preset names is an interest this instance has no opinion on, so
|
|
299
|
+
* they are examples in a file to edit rather than a default in the code that
|
|
300
|
+
* would come back after being deleted. */
|
|
301
|
+
export async function add(env: Env, dir: string, options: AddOptions = {}): Promise<InstanceRow> {
|
|
302
|
+
const where = isAbsolute(dir) ? dir : resolve(dir);
|
|
303
|
+
const harness = options.harness ?? harnessOf(where);
|
|
304
|
+
const home = configHome(where, harness);
|
|
305
|
+
const paths = resolvePaths(env);
|
|
306
|
+
const held = await known(env);
|
|
307
|
+
const taken = held.instances.find((one) => one.dir === home);
|
|
308
|
+
if (taken !== undefined) {
|
|
309
|
+
throw new CommandError("file_exists", `${home} は既に ${taken.name} として登録されています`);
|
|
310
|
+
}
|
|
311
|
+
// The id the state directory already holds, or a new one written there now:
|
|
312
|
+
// a config home that was registered before keeps the id everything it issued
|
|
313
|
+
// is keyed by, and a fresh one gets its id here rather than at its first
|
|
314
|
+
// start (DR-0001 §2.1).
|
|
315
|
+
const id = instanceIdentity(targetFor(env, home).paths.instanceIdFile);
|
|
316
|
+
// Every instance listens, because an instance is an entry of the mesh (§7.1)
|
|
317
|
+
// and a mesh is reached over the entry: what `--port` settles is which
|
|
318
|
+
// address, not whether there is one.
|
|
319
|
+
const port =
|
|
320
|
+
options.port ??
|
|
321
|
+
(await freePort(
|
|
322
|
+
held.instances.flatMap((one) =>
|
|
323
|
+
one.config.entry === undefined ? [] : [one.config.entry.port],
|
|
324
|
+
),
|
|
325
|
+
));
|
|
326
|
+
const name = nameFor(home) || id;
|
|
327
|
+
writeConfigTypes(paths.configDir);
|
|
328
|
+
if (!existsSync(paths.configFile)) writeFileSync(paths.configFile, defaultsTemplate());
|
|
329
|
+
mkdirSync(paths.instancesDir, { recursive: true });
|
|
330
|
+
writeFileSync(
|
|
331
|
+
join(paths.instancesDir, instanceFileName(id)),
|
|
332
|
+
instanceTemplate(name, home, harness, port),
|
|
333
|
+
);
|
|
334
|
+
// The loopback address, because that is the one this host is certainly
|
|
335
|
+
// reached at. A proxy in front of it is a deployment fact nothing here can
|
|
336
|
+
// see, so an operator who has one edits this row (§8.2).
|
|
337
|
+
saveEndpoints(paths.configDir, [
|
|
338
|
+
...readEndpointRows(paths.configDir).filter((row) => row.id !== id),
|
|
339
|
+
{ id, endpoint: `http://127.0.0.1:${String(port)}/` as EndpointRow["endpoint"] },
|
|
340
|
+
]);
|
|
341
|
+
saveSupervisor(paths.configDir, [
|
|
342
|
+
...readSupervised(paths.configDir).filter((one) => one !== id),
|
|
343
|
+
id,
|
|
344
|
+
]);
|
|
345
|
+
const settled = await reload(env);
|
|
346
|
+
const written = configOf(settled.satisfied, home);
|
|
347
|
+
if (written === undefined) {
|
|
348
|
+
throw new CommandError(
|
|
349
|
+
"internal_error",
|
|
350
|
+
`${home} を書きましたが設定が通りませんでした: ${settled.problems
|
|
351
|
+
.map((one) => `${one.file}: ${one.msg}`)
|
|
352
|
+
.join("; ")}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return rowOf(env, written);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The mesh as the file holds it right now, for a command that is about to
|
|
359
|
+
* edit it. Read as data rather than through the checks, because a command that
|
|
360
|
+
* adds a row has to be able to fix a file that does not check out yet. */
|
|
361
|
+
function readEndpointRows(configDir: string): EndpointRow[] {
|
|
362
|
+
try {
|
|
363
|
+
const parsed = JSON.parse(readFileSync(join(configDir, ENDPOINTS_FILE), "utf8")) as unknown;
|
|
364
|
+
return Array.isArray(parsed) ? (parsed as EndpointRow[]) : [];
|
|
365
|
+
} catch {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function readSupervised(configDir: string): string[] {
|
|
371
|
+
try {
|
|
372
|
+
const parsed = JSON.parse(readFileSync(join(configDir, SUPERVISOR_FILE), "utf8")) as {
|
|
373
|
+
instances?: unknown;
|
|
374
|
+
};
|
|
375
|
+
return Array.isArray(parsed.instances) ? (parsed.instances as string[]) : [];
|
|
376
|
+
} catch {
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function saveEndpoints(configDir: string, rows: readonly EndpointRow[]): void {
|
|
382
|
+
mkdirSync(configDir, { recursive: true });
|
|
383
|
+
writeFileSync(join(configDir, ENDPOINTS_FILE), `${JSON.stringify(rows, null, 2)}\n`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function saveSupervisor(configDir: string, ids: readonly string[]): void {
|
|
387
|
+
mkdirSync(configDir, { recursive: true });
|
|
388
|
+
writeFileSync(
|
|
389
|
+
join(configDir, SUPERVISOR_FILE),
|
|
390
|
+
`${JSON.stringify({ instances: ids }, null, 2)}\n`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Take one instance's file away.
|
|
395
|
+
*
|
|
396
|
+
* The instance it named is left alone: what this changes is what the supervisor
|
|
177
397
|
* starts and what `--all` reaches, and an instance already serving a session is
|
|
178
|
-
* not something a
|
|
398
|
+
* not something a file edit should take away from it. `daemon stop` is how one
|
|
179
399
|
* is stopped, and saying so is the point of keeping the two apart. */
|
|
180
|
-
export function remove(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
400
|
+
export async function remove(
|
|
401
|
+
env: Env,
|
|
402
|
+
ref: string,
|
|
403
|
+
): Promise<{ id: string; name: string; dir: string; removed: boolean }> {
|
|
404
|
+
const paths = resolvePaths(env);
|
|
405
|
+
const found = (await known(env)).instances.find(
|
|
406
|
+
(one) => one.id === ref || one.name === ref || one.dir === ref,
|
|
407
|
+
);
|
|
408
|
+
if (found === undefined) throw new CommandError("not_found", `${ref} は登録されていません`);
|
|
409
|
+
saveSupervisor(
|
|
410
|
+
paths.configDir,
|
|
411
|
+
readSupervised(paths.configDir).filter((one) => one !== found.id),
|
|
412
|
+
);
|
|
413
|
+
saveEndpoints(
|
|
414
|
+
paths.configDir,
|
|
415
|
+
readEndpointRows(paths.configDir).filter((row) => row.id !== found.id),
|
|
416
|
+
);
|
|
417
|
+
rmSync(join(paths.instancesDir, instanceFileName(found.id)), { force: true });
|
|
418
|
+
// The state directory stays, its id with it: what the instance issued is
|
|
419
|
+
// keyed by that id, and re-adding the same config home has to answer to the
|
|
420
|
+
// same one.
|
|
421
|
+
await reload(env);
|
|
422
|
+
return { id: found.id, name: found.name, dir: found.dir, removed: true };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The file every instance's settings start from, as it is first written. */
|
|
426
|
+
function defaultsTemplate(): string {
|
|
427
|
+
return `import type { Defaults } from "./${TYPES_FILE.replace(/\.d\.ts$/, "")}";
|
|
428
|
+
|
|
429
|
+
/** 全 instance に配る値。\`builtin\` は組み込みの既定値 (凍結済み)、\`config\` は
|
|
430
|
+
* そのコピーなので、書き換えて返す。ここに書いた値を各 instance が受け取る。 */
|
|
431
|
+
const defaults: Defaults = ({ config }) => {
|
|
432
|
+
// mesh はここには書かない。誰が居てどこで届くかは endpoints.json が正で、
|
|
433
|
+
// この関数は読めるが変えられない。
|
|
434
|
+
|
|
435
|
+
// dump の名前付き選択。prefix は一族を、\`@name\` は他の選択をその場に広げる。
|
|
436
|
+
config.dump.presets = [
|
|
437
|
+
${STARTING_PRESETS.map((preset) => presetLiteral(preset)).join("\n")}
|
|
438
|
+
];
|
|
439
|
+
|
|
440
|
+
return config;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
export default defaults;
|
|
444
|
+
`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** One starting preset as a person would have typed it.
|
|
448
|
+
*
|
|
449
|
+
* Written out rather than stringified, because what this produces is a file
|
|
450
|
+
* somebody edits: JSON's quoted keys in the middle of a TypeScript file are
|
|
451
|
+
* the shape of a thing that was generated, and the next preset a person adds
|
|
452
|
+
* beside it would not look like it. */
|
|
453
|
+
function presetLiteral(preset: (typeof STARTING_PRESETS)[number]): string {
|
|
454
|
+
const types = preset.opts.types.map((type) => JSON.stringify(type)).join(", ");
|
|
455
|
+
return [
|
|
456
|
+
" {",
|
|
457
|
+
` name: ${JSON.stringify(preset.name)},`,
|
|
458
|
+
` description: ${JSON.stringify(preset.description)},`,
|
|
459
|
+
` opts: { types: [${types}] },`,
|
|
460
|
+
" },",
|
|
461
|
+
].join("\n");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** One instance's file, as `add` first writes it: what differs from
|
|
465
|
+
* the shared file, and nothing else. */
|
|
466
|
+
function instanceTemplate(name: string, dir: string, harness: Harness, port: number): string {
|
|
467
|
+
const lines = [
|
|
468
|
+
` config.name = ${JSON.stringify(name)};`,
|
|
469
|
+
` config.dir = ${JSON.stringify(dir)};`,
|
|
470
|
+
"",
|
|
471
|
+
" // reverse proxy の後ろに居るなら、peer と人が届く公開 URL (末尾 /) を書く。",
|
|
472
|
+
" // 書かなければ下の待ち受け address がそのまま mesh の一覧に載る。",
|
|
473
|
+
` // config.endpoint = "https://ccmsg-${name}.<host>/";`,
|
|
474
|
+
"",
|
|
475
|
+
];
|
|
476
|
+
if (harness !== DEFAULT_HARNESS) lines.push(` config.harness = ${JSON.stringify(harness)};`);
|
|
477
|
+
lines.push(
|
|
478
|
+
` config.entry = {`,
|
|
479
|
+
` ...(config.entry ?? { host: "127.0.0.1", source_ips: [], trusted_proxies: [] }),`,
|
|
480
|
+
` port: ${String(port)},`,
|
|
481
|
+
` };`,
|
|
482
|
+
);
|
|
483
|
+
return `import type { Instance } from "../${TYPES_FILE.replace(/\.d\.ts$/, "")}";
|
|
484
|
+
|
|
485
|
+
/** ${name}: この instance だけの設定。\`default\` は ${CONFIG_FILE} が返した値
|
|
486
|
+
* (凍結済み)、\`config\` はそのコピーなので、差分だけ書き換えて返す。 */
|
|
487
|
+
const instance: Instance = ({ config }) => {
|
|
488
|
+
${lines.join("\n")}
|
|
489
|
+
|
|
490
|
+
return config;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
export default instance;
|
|
494
|
+
`;
|
|
190
495
|
}
|
|
191
496
|
|
|
192
497
|
/** What an instance is called, whether or not it is running.
|
|
@@ -204,20 +509,43 @@ export function rowFor(target: Target): InstanceRow {
|
|
|
204
509
|
const running = pid !== undefined && alive(pid);
|
|
205
510
|
return {
|
|
206
511
|
id: idOf(target),
|
|
512
|
+
...(target.name === undefined ? {} : { name: target.name }),
|
|
207
513
|
dir: target.dir,
|
|
208
514
|
running,
|
|
209
515
|
...(running ? { pid } : {}),
|
|
210
516
|
};
|
|
211
517
|
}
|
|
212
518
|
|
|
213
|
-
|
|
214
|
-
|
|
519
|
+
/** What `daemon list` answers: the instances this host starts, and whether
|
|
520
|
+
* anything answers for each right now. */
|
|
521
|
+
export async function list(env: Env): Promise<InstanceRow[]> {
|
|
522
|
+
return (await known(env)).instances.map((one) => rowOf(env, one));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function rowOf(env: Env, one: InstanceSetting): InstanceRow {
|
|
526
|
+
return {
|
|
527
|
+
...rowFor(targetFor(env, one.dir, one.name, one.id)),
|
|
528
|
+
...(one.config.entry === undefined ? {} : { port: one.config.entry.port }),
|
|
529
|
+
...(one.config.endpoint === undefined ? {} : { endpoint: one.config.endpoint }),
|
|
530
|
+
};
|
|
215
531
|
}
|
|
216
532
|
|
|
217
533
|
/** Ask one instance how it is. A config home with nothing behind it answers the
|
|
218
534
|
* list's row and nothing more: not running is a state, not a failure. */
|
|
219
535
|
export async function status(target: Target): Promise<StatusRow> {
|
|
220
|
-
const
|
|
536
|
+
const read = await evaluate(target.paths.configDir);
|
|
537
|
+
const satisfied = read.satisfied ?? applied(target.paths.stateRoot) ?? EMPTY_SATISFIED;
|
|
538
|
+
const own = configOf(satisfied, target.dir);
|
|
539
|
+
const row = {
|
|
540
|
+
...rowFor(target),
|
|
541
|
+
...(own?.config.entry === undefined ? {} : { port: own.config.entry.port }),
|
|
542
|
+
...(own?.config.endpoint === undefined ? {} : { endpoint: own.config.endpoint }),
|
|
543
|
+
config: own?.config ?? DEFAULT_CONFIG,
|
|
544
|
+
// What a person has to be told even though the instance is running: an
|
|
545
|
+
// edit that did not check out is not applied, and the only sign of it
|
|
546
|
+
// otherwise is a setting that did not take (§8.3).
|
|
547
|
+
...(read.problems.length === 0 ? {} : { config_problems: read.problems }),
|
|
548
|
+
};
|
|
221
549
|
const conn = await connect(target.paths.socket);
|
|
222
550
|
if (conn === undefined) return row;
|
|
223
551
|
try {
|