@ccmsg/cli 0.9.1 → 0.10.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/auth/admin.ts +32 -6
- package/src/cli.ts +298 -81
- package/src/daemon/control.ts +61 -9
- package/src/daemon/registry.ts +370 -65
- package/src/daemon/supervise.ts +14 -2
- package/src/instance/ccmsg-config.d.ts +111 -0
- package/src/instance/config.ts +444 -126
- package/src/instance/identity.ts +11 -2
- package/src/instance/instance.ts +9 -2
- package/src/instance/paths.ts +19 -6
- package/src/mesh/mesh.ts +30 -2
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,17 +1,23 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, watch } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, 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
|
+
type ClusterInfo,
|
|
7
|
+
type ClusterSetting,
|
|
8
|
+
CONFIG_FILE,
|
|
9
|
+
CONFIG_NAME,
|
|
6
10
|
type InstanceConfig,
|
|
7
|
-
|
|
11
|
+
instanceFileName,
|
|
12
|
+
loadAll,
|
|
8
13
|
loadConfig,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
loadInstances,
|
|
15
|
+
saveCluster,
|
|
16
|
+
saveClusters,
|
|
17
|
+
TYPES_FILE,
|
|
18
|
+
writeConfigTypes,
|
|
13
19
|
} from "../instance/config.ts";
|
|
14
|
-
import { instanceIdentity } from "../instance/identity.ts";
|
|
20
|
+
import { ID, instanceIdentity, newId } from "../instance/identity.ts";
|
|
15
21
|
import { alive, lockHolder } from "../instance/lock.ts";
|
|
16
22
|
import { type Env, type InstancePaths, resolvePaths, resolvePathsFor } from "../instance/paths.ts";
|
|
17
23
|
import { prepareSocketDir } from "../instance/socket.ts";
|
|
@@ -37,25 +43,33 @@ export function configHome(dir: string, harness: Harness = DEFAULT_HARNESS): str
|
|
|
37
43
|
return path;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
/** Which harness a registered config home runs, as
|
|
46
|
+
/** Which harness a registered config home runs, as its own file says.
|
|
41
47
|
*
|
|
42
|
-
* Read from the same
|
|
48
|
+
* Read from the same file the instance itself will read (§8.2), so a command
|
|
43
49
|
* 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 {
|
|
50
|
+
* own start — reaches the same answer the instance does. A directory no file
|
|
51
|
+
* names runs whatever the defaults say, which is what an unregistered
|
|
52
|
+
* `daemon run` is. */
|
|
53
|
+
export async function harnessFor(env: Env, dir: string): Promise<Harness> {
|
|
48
54
|
const path = isAbsolute(dir) ? dir : resolve(dir);
|
|
49
|
-
|
|
50
|
-
const named = settings["harness"];
|
|
51
|
-
return isHarness(named) ? named : DEFAULT_HARNESS;
|
|
55
|
+
return (await loadConfig(resolvePaths(env).configDir, path)).harness;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/** One row of `daemon list`: which config home, and whether anything answers
|
|
55
59
|
* for it right now. */
|
|
56
60
|
export interface InstanceRow {
|
|
57
61
|
readonly id: InstanceId;
|
|
62
|
+
/** The label this instance is listed under: its own file's `name`, which
|
|
63
|
+
* defaults to its id. A `daemon run` on a config home no cluster lists has
|
|
64
|
+
* none. */
|
|
65
|
+
readonly name?: string;
|
|
66
|
+
/** Which cluster this row was read through. An instance in two clusters is
|
|
67
|
+
* one instance and one process, listed once under each of them. */
|
|
68
|
+
readonly cluster_id?: string;
|
|
69
|
+
readonly cluster_name?: string;
|
|
58
70
|
readonly dir: string;
|
|
71
|
+
/** Where peers reach it, as its file states or as its entry implies. */
|
|
72
|
+
readonly port?: number;
|
|
59
73
|
readonly running: boolean;
|
|
60
74
|
readonly pid?: number;
|
|
61
75
|
}
|
|
@@ -81,18 +95,46 @@ export interface StatusRow extends InstanceRow {
|
|
|
81
95
|
|
|
82
96
|
/** Everything one command needs to reach one config home. */
|
|
83
97
|
export interface Target {
|
|
98
|
+
/** The label this instance is listed under, where a cluster lists it. */
|
|
99
|
+
readonly name?: string;
|
|
100
|
+
/** Its id, which is what its file is called. */
|
|
101
|
+
readonly id?: string;
|
|
102
|
+
/** The clusters it belongs to, as the host writes them down. */
|
|
103
|
+
readonly clusters?: readonly ClusterInfo[];
|
|
84
104
|
readonly dir: string;
|
|
85
105
|
readonly paths: InstancePaths;
|
|
86
106
|
}
|
|
87
107
|
|
|
88
|
-
export function targetFor(
|
|
89
|
-
|
|
108
|
+
export function targetFor(
|
|
109
|
+
env: Env,
|
|
110
|
+
dir: string,
|
|
111
|
+
name?: string,
|
|
112
|
+
id?: string,
|
|
113
|
+
clusters?: readonly ClusterInfo[],
|
|
114
|
+
): Target {
|
|
115
|
+
return {
|
|
116
|
+
...(name === undefined ? {} : { name }),
|
|
117
|
+
...(id === undefined ? {} : { id }),
|
|
118
|
+
...(clusters === undefined ? {} : { clusters }),
|
|
119
|
+
dir,
|
|
120
|
+
paths: resolvePathsFor(dir, env),
|
|
121
|
+
};
|
|
90
122
|
}
|
|
91
123
|
|
|
92
|
-
/** The config homes
|
|
93
|
-
export function registered(env: Env): Target[] {
|
|
124
|
+
/** The config homes this host's clusters list, each once. */
|
|
125
|
+
export async function registered(env: Env): Promise<Target[]> {
|
|
94
126
|
const paths = resolvePaths(env);
|
|
95
|
-
return
|
|
127
|
+
return (await loadInstances(paths.configDir)).map((entry) =>
|
|
128
|
+
targetFor(env, entry.dir, entry.name, entry.id, entry.clusters),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The instance a command was given, by any of the three things a person has
|
|
133
|
+
* to hand: the label it is listed under, its id, or the directory itself. */
|
|
134
|
+
export async function targetNamed(env: Env, ref: string): Promise<Target | undefined> {
|
|
135
|
+
return (await registered(env)).find(
|
|
136
|
+
(target) => target.name === ref || target.id === ref || target.dir === ref,
|
|
137
|
+
);
|
|
96
138
|
}
|
|
97
139
|
|
|
98
140
|
/** The selections the shared file starts with.
|
|
@@ -138,55 +180,294 @@ const STARTING_PRESETS = [
|
|
|
138
180
|
},
|
|
139
181
|
];
|
|
140
182
|
|
|
141
|
-
/**
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
183
|
+
/** What `daemon add` takes: the config home the instance answers for, and the
|
|
184
|
+
* two settings a person would otherwise open the file to write. */
|
|
185
|
+
export interface AddOptions {
|
|
186
|
+
/** Which cluster the instance joins, by id or by name. With none said: the
|
|
187
|
+
* one cluster there is, a new one where there is none, and a refusal where
|
|
188
|
+
* there are several — the last because which management unit an instance
|
|
189
|
+
* belongs to is not something to guess at. An id nothing answers to is a
|
|
190
|
+
* cluster this host has not met yet and is made under that id, which is how
|
|
191
|
+
* a second host joins one. */
|
|
192
|
+
readonly cluster?: string;
|
|
193
|
+
readonly harness?: Harness;
|
|
194
|
+
readonly port?: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** What a config home is called, for a person reading a listing: its own last
|
|
198
|
+
* segment, without the dot a config home is usually hidden by.
|
|
145
199
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
200
|
+
* A label and not an identity — the file and everything the instance issued
|
|
201
|
+
* are keyed by its id, so this may be changed in the file afterwards. Taken
|
|
202
|
+
* from the directory because that is the one of the two that already exists;
|
|
203
|
+
* a directory whose name could not be a label leaves the id as the name, which
|
|
204
|
+
* is what a name defaults to anyway. */
|
|
205
|
+
export function nameFor(dir: string): string {
|
|
206
|
+
const name = basename(dir).replace(/^\.+/, "");
|
|
207
|
+
return CONFIG_NAME.test(name) ? name : "";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Which harness a config home runs, as the directory itself says.
|
|
211
|
+
*
|
|
212
|
+
* The marker file is the evidence: Claude Code keeps `settings.json` and Codex
|
|
213
|
+
* keeps `config.toml`, so a directory that holds one of them is that harness's
|
|
214
|
+
* (§3.8). A directory holding both, or neither, is not answered for — the
|
|
215
|
+
* first is two answers and the second is none, and guessing either way writes
|
|
216
|
+
* down a setting the instance will act on for the whole of its life. */
|
|
217
|
+
export function harnessOf(dir: string): Harness {
|
|
218
|
+
const found = HARNESSES.filter((harness) => existsSync(join(dir, HARNESS[harness].marker)));
|
|
219
|
+
const only = found[0];
|
|
220
|
+
if (found.length !== 1 || only === undefined) {
|
|
221
|
+
throw new CommandError(
|
|
222
|
+
"invalid_args",
|
|
223
|
+
found.length === 0
|
|
224
|
+
? `${dir} がどの harness の config home か分かりません (${HARNESSES.map((one) => HARNESS[one].marker).join(" / ")} がありません)。--harness で指定してください`
|
|
225
|
+
: `${dir} は ${found.join(" と ")} の両方の目印を持っています。--harness で指定してください`,
|
|
226
|
+
);
|
|
156
227
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
228
|
+
return only;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The port the next instance listens on: one past the highest any registered
|
|
232
|
+
* instance holds, or the first of the range when there are none.
|
|
233
|
+
*
|
|
234
|
+
* Counted from what is configured and then confirmed against the kernel,
|
|
235
|
+
* because the two answer different questions — the first is what this host has
|
|
236
|
+
* already handed out, and the second is whether anything else on the machine
|
|
237
|
+
* is on it. A person who wants a particular port says so and gets it or gets
|
|
238
|
+
* the refusal. */
|
|
239
|
+
export const FIRST_PORT = 8643;
|
|
240
|
+
|
|
241
|
+
/** How far the search walks before it says so rather than going on. A run of
|
|
242
|
+
* this many taken ports is a host whose ports are somebody else's business. */
|
|
243
|
+
const PORT_SEARCH = 64;
|
|
244
|
+
|
|
245
|
+
export async function freePort(taken: readonly number[]): Promise<number> {
|
|
246
|
+
const first = taken.length === 0 ? FIRST_PORT : Math.max(...taken) + 1;
|
|
247
|
+
for (let port = first; port < first + PORT_SEARCH; port += 1) {
|
|
248
|
+
if (taken.includes(port)) continue;
|
|
249
|
+
if (await bindable(port)) return port;
|
|
250
|
+
}
|
|
251
|
+
throw new CommandError(
|
|
252
|
+
"internal_error",
|
|
253
|
+
`${String(first)} から ${String(PORT_SEARCH)} 個のポートが全部塞がっています。--port で指定してください`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Whether this host will give out an address, asked by taking it and letting
|
|
258
|
+
* it go again. Nothing else answers it: a port is free when the kernel says
|
|
259
|
+
* so, and every other account of it is out of date the moment it is read. */
|
|
260
|
+
async function bindable(port: number): Promise<boolean> {
|
|
261
|
+
try {
|
|
262
|
+
const server = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response("") });
|
|
263
|
+
await server.stop(true);
|
|
264
|
+
return true;
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Write down one more instance: one file under `instances/`, called by the
|
|
271
|
+
* name the instance is called.
|
|
272
|
+
*
|
|
273
|
+
* A template rather than an empty file, because what the file has to say —
|
|
274
|
+
* which config home, and how a setting is written at all — is exactly what a
|
|
275
|
+
* person adding their second instance does not yet know. It states only what
|
|
276
|
+
* differs from the shared file, which is what makes that file worth having:
|
|
277
|
+
* everything left out is whatever that file returns.
|
|
278
|
+
*
|
|
279
|
+
* The shared file is written the first time, with the dump presets in it, and never
|
|
280
|
+
* again: what a preset names is an interest this instance has no opinion on, so
|
|
281
|
+
* they are examples in a file to edit rather than a default in the code that
|
|
282
|
+
* would come back after being deleted. */
|
|
283
|
+
export async function add(env: Env, dir: string, options: AddOptions = {}): Promise<InstanceRow> {
|
|
284
|
+
const where = isAbsolute(dir) ? dir : resolve(dir);
|
|
285
|
+
const harness = options.harness ?? harnessOf(where);
|
|
286
|
+
const home = configHome(where, harness);
|
|
287
|
+
const paths = resolvePaths(env);
|
|
288
|
+
const all = await loadAll(paths.configDir);
|
|
289
|
+
const taken = all.instances.find((one) => one.dir === home);
|
|
290
|
+
if (taken !== undefined) {
|
|
291
|
+
throw new CommandError("file_exists", `${home} は既に ${taken.name} として登録されています`);
|
|
292
|
+
}
|
|
293
|
+
const cluster = clusterFor(all.clusters, options.cluster);
|
|
294
|
+
// The id the state directory already holds, or a new one written there now:
|
|
295
|
+
// a config home that was registered before keeps the id everything it issued
|
|
296
|
+
// is keyed by, and a fresh one gets its id here rather than at its first
|
|
297
|
+
// start (DR-0001 §2.1).
|
|
166
298
|
const target = targetFor(env, home);
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
299
|
+
const id = instanceIdentity(target.paths.instanceIdFile);
|
|
300
|
+
// Every instance listens, because an instance is in the mesh of its cluster
|
|
301
|
+
// (§7.1) and a mesh is reached over the entry: what `--port` settles is which
|
|
302
|
+
// address, not whether there is one.
|
|
303
|
+
const port =
|
|
304
|
+
options.port ??
|
|
305
|
+
(await freePort(
|
|
306
|
+
all.instances.flatMap((one) =>
|
|
307
|
+
one.config.entry === undefined ? [] : [one.config.entry.port],
|
|
308
|
+
),
|
|
309
|
+
));
|
|
310
|
+
const name = nameFor(home) || id;
|
|
311
|
+
writeConfigTypes(paths.configDir);
|
|
312
|
+
if (!existsSync(paths.configFile)) writeFileSync(paths.configFile, defaultsTemplate());
|
|
313
|
+
mkdirSync(paths.instancesDir, { recursive: true });
|
|
314
|
+
writeFileSync(
|
|
315
|
+
join(paths.instancesDir, instanceFileName(id)),
|
|
316
|
+
instanceTemplate(name, home, harness, port),
|
|
317
|
+
);
|
|
318
|
+
saveCluster(paths.configDir, {
|
|
319
|
+
...cluster,
|
|
320
|
+
instances: cluster.instances.includes(id) ? cluster.instances : [...cluster.instances, id],
|
|
321
|
+
});
|
|
322
|
+
saveClusters(
|
|
323
|
+
paths.configDir,
|
|
324
|
+
all.clusters.some((one) => one.id === cluster.id)
|
|
325
|
+
? all.clusters.map((one) => one.id)
|
|
326
|
+
: [...all.clusters.map((one) => one.id), cluster.id],
|
|
327
|
+
);
|
|
328
|
+
return {
|
|
329
|
+
...rowFor(targetFor(env, home, name, id)),
|
|
330
|
+
cluster_id: cluster.id,
|
|
331
|
+
cluster_name: cluster.name,
|
|
332
|
+
port,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The cluster a command is about.
|
|
337
|
+
*
|
|
338
|
+
* Named or not, the answer has to be one cluster: a command that acted on "the
|
|
339
|
+
* clusters" would be deciding for a person which management unit a thing
|
|
340
|
+
* belongs to. An id this host has not met is a cluster that exists elsewhere —
|
|
341
|
+
* a cluster spans hosts — so it is written down under that id rather than
|
|
342
|
+
* refused, which is what lets a second host join one. */
|
|
343
|
+
export function clusterFor(
|
|
344
|
+
clusters: readonly ClusterSetting[],
|
|
345
|
+
named: string | undefined,
|
|
346
|
+
): ClusterSetting {
|
|
347
|
+
if (named !== undefined) {
|
|
348
|
+
const found = clusters.find((one) => one.id === named || one.name === named);
|
|
349
|
+
if (found !== undefined) return found;
|
|
350
|
+
if (!ID.test(named)) {
|
|
351
|
+
throw new CommandError(
|
|
352
|
+
"not_found",
|
|
353
|
+
`${named} という cluster はありません (新しく作るなら id を渡してください)`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return { id: named, name: named, peers: [], instances: [] };
|
|
357
|
+
}
|
|
358
|
+
const only = clusters[0];
|
|
359
|
+
if (clusters.length === 1 && only !== undefined) return only;
|
|
360
|
+
if (clusters.length === 0) {
|
|
361
|
+
const id = newId();
|
|
362
|
+
return { id, name: id, peers: [], instances: [] };
|
|
363
|
+
}
|
|
364
|
+
throw new CommandError(
|
|
365
|
+
"invalid_args",
|
|
366
|
+
`cluster が ${String(clusters.length)} 個あります。--cluster <id|name> で選んでください (${clusters.map((one) => one.name).join(", ")})`,
|
|
367
|
+
);
|
|
172
368
|
}
|
|
173
369
|
|
|
174
|
-
/** Take
|
|
370
|
+
/** Take one instance's file away.
|
|
175
371
|
*
|
|
176
|
-
* The instance it
|
|
372
|
+
* The instance it named is left alone: what this changes is what the supervisor
|
|
177
373
|
* starts and what `--all` reaches, and an instance already serving a session is
|
|
178
|
-
* not something a
|
|
374
|
+
* not something a file edit should take away from it. `daemon stop` is how one
|
|
179
375
|
* is stopped, and saying so is the point of keeping the two apart. */
|
|
180
|
-
export function remove(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
376
|
+
export async function remove(
|
|
377
|
+
env: Env,
|
|
378
|
+
ref: string,
|
|
379
|
+
): Promise<{ id: string; name: string; dir: string; removed: boolean }> {
|
|
380
|
+
const paths = resolvePaths(env);
|
|
381
|
+
const all = await loadAll(paths.configDir);
|
|
382
|
+
const found = all.instances.find((one) => one.id === ref || one.name === ref || one.dir === ref);
|
|
383
|
+
if (found === undefined) throw new CommandError("not_found", `${ref} は登録されていません`);
|
|
384
|
+
// Out of every cluster that listed it: a person removing an instance is
|
|
385
|
+
// removing it from this host, and leaving it in the second cluster would
|
|
386
|
+
// leave the supervisor starting it.
|
|
387
|
+
for (const cluster of all.clusters) {
|
|
388
|
+
if (!cluster.instances.includes(found.id)) continue;
|
|
389
|
+
saveCluster(paths.configDir, {
|
|
390
|
+
...cluster,
|
|
391
|
+
instances: cluster.instances.filter((one) => one !== found.id),
|
|
392
|
+
});
|
|
187
393
|
}
|
|
188
|
-
|
|
189
|
-
|
|
394
|
+
rmSync(join(paths.instancesDir, instanceFileName(found.id)), { force: true });
|
|
395
|
+
// The state directory stays, its id with it: what the instance issued is
|
|
396
|
+
// keyed by that id, and re-adding the same config home has to answer to the
|
|
397
|
+
// same one.
|
|
398
|
+
return { id: found.id, name: found.name, dir: found.dir, removed: true };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The file every instance's settings start from, as it is first written. */
|
|
402
|
+
function defaultsTemplate(): string {
|
|
403
|
+
return `import type { Defaults } from "./${TYPES_FILE.replace(/\.d\.ts$/, "")}";
|
|
404
|
+
|
|
405
|
+
/** 全 instance に配る値。\`builtin\` は組み込みの既定値 (凍結済み)、\`config\` は
|
|
406
|
+
* そのコピーなので、書き換えて返す。ここに書いた値を各 instance が受け取る。 */
|
|
407
|
+
const defaults: Defaults = ({ config }) => {
|
|
408
|
+
// mesh の相手はここには書かない。cluster 内の instance は各 TS の endpoint /
|
|
409
|
+
// port から、別 host の endpoint は cluster の peers (ccmsg mesh add) から入る。
|
|
410
|
+
|
|
411
|
+
// dump の名前付き選択。prefix は一族を、\`@name\` は他の選択をその場に広げる。
|
|
412
|
+
config.dump.presets = [
|
|
413
|
+
${STARTING_PRESETS.map((preset) => presetLiteral(preset)).join("\n")}
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
return config;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
export default defaults;
|
|
420
|
+
`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** One starting preset as a person would have typed it.
|
|
424
|
+
*
|
|
425
|
+
* Written out rather than stringified, because what this produces is a file
|
|
426
|
+
* somebody edits: JSON's quoted keys in the middle of a TypeScript file are
|
|
427
|
+
* the shape of a thing that was generated, and the next preset a person adds
|
|
428
|
+
* beside it would not look like it. */
|
|
429
|
+
function presetLiteral(preset: (typeof STARTING_PRESETS)[number]): string {
|
|
430
|
+
const types = preset.opts.types.map((type) => JSON.stringify(type)).join(", ");
|
|
431
|
+
return [
|
|
432
|
+
" {",
|
|
433
|
+
` name: ${JSON.stringify(preset.name)},`,
|
|
434
|
+
` description: ${JSON.stringify(preset.description)},`,
|
|
435
|
+
` opts: { types: [${types}] },`,
|
|
436
|
+
" },",
|
|
437
|
+
].join("\n");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** One instance's file, as `add` first writes it: what differs from
|
|
441
|
+
* the shared file, and nothing else. */
|
|
442
|
+
function instanceTemplate(name: string, dir: string, harness: Harness, port: number): string {
|
|
443
|
+
const lines = [
|
|
444
|
+
` config.name = ${JSON.stringify(name)};`,
|
|
445
|
+
` config.dir = ${JSON.stringify(dir)};`,
|
|
446
|
+
"",
|
|
447
|
+
" // reverse proxy の後ろに居るなら、peer と人が届く公開 URL (末尾 /) を書く。",
|
|
448
|
+
" // 書かなければ下の待ち受け address がそのまま mesh の一覧に載る。",
|
|
449
|
+
` // config.endpoint = "https://ccmsg-${name}.<host>/";`,
|
|
450
|
+
"",
|
|
451
|
+
];
|
|
452
|
+
if (harness !== DEFAULT_HARNESS) lines.push(` config.harness = ${JSON.stringify(harness)};`);
|
|
453
|
+
lines.push(
|
|
454
|
+
` config.entry = {`,
|
|
455
|
+
` ...(config.entry ?? { host: "127.0.0.1", source_ips: [], trusted_proxies: [] }),`,
|
|
456
|
+
` port: ${String(port)},`,
|
|
457
|
+
` };`,
|
|
458
|
+
);
|
|
459
|
+
return `import type { Instance } from "../${TYPES_FILE.replace(/\.d\.ts$/, "")}";
|
|
460
|
+
|
|
461
|
+
/** ${name}: この instance だけの設定。\`default\` は ${CONFIG_FILE} が返した値
|
|
462
|
+
* (凍結済み)、\`config\` はそのコピーなので、差分だけ書き換えて返す。 */
|
|
463
|
+
const instance: Instance = ({ config }) => {
|
|
464
|
+
${lines.join("\n")}
|
|
465
|
+
|
|
466
|
+
return config;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
export default instance;
|
|
470
|
+
`;
|
|
190
471
|
}
|
|
191
472
|
|
|
192
473
|
/** What an instance is called, whether or not it is running.
|
|
@@ -204,20 +485,44 @@ export function rowFor(target: Target): InstanceRow {
|
|
|
204
485
|
const running = pid !== undefined && alive(pid);
|
|
205
486
|
return {
|
|
206
487
|
id: idOf(target),
|
|
488
|
+
...(target.name === undefined ? {} : { name: target.name }),
|
|
207
489
|
dir: target.dir,
|
|
208
490
|
running,
|
|
209
491
|
...(running ? { pid } : {}),
|
|
210
492
|
};
|
|
211
493
|
}
|
|
212
494
|
|
|
213
|
-
|
|
214
|
-
|
|
495
|
+
/** What `daemon list` answers: one row per instance per cluster it is in.
|
|
496
|
+
*
|
|
497
|
+
* By cluster because that is the unit a person manages — which mesh, which
|
|
498
|
+
* authentication records — and an instance in two of them is in both listings,
|
|
499
|
+
* as the same id with the same process. */
|
|
500
|
+
export async function list(env: Env): Promise<InstanceRow[]> {
|
|
501
|
+
const all = await loadAll(resolvePaths(env).configDir);
|
|
502
|
+
const rows: InstanceRow[] = [];
|
|
503
|
+
for (const cluster of all.clusters) {
|
|
504
|
+
for (const id of cluster.instances) {
|
|
505
|
+
const found = all.instances.find((one) => one.id === id);
|
|
506
|
+
if (found === undefined) continue;
|
|
507
|
+
const target = targetFor(env, found.dir, found.name, found.id, found.clusters);
|
|
508
|
+
rows.push({
|
|
509
|
+
...rowFor(target),
|
|
510
|
+
cluster_id: cluster.id,
|
|
511
|
+
cluster_name: cluster.name,
|
|
512
|
+
...(found.config.entry === undefined ? {} : { port: found.config.entry.port }),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return rows;
|
|
215
517
|
}
|
|
216
518
|
|
|
217
519
|
/** Ask one instance how it is. A config home with nothing behind it answers the
|
|
218
520
|
* list's row and nothing more: not running is a state, not a failure. */
|
|
219
521
|
export async function status(target: Target): Promise<StatusRow> {
|
|
220
|
-
const row = {
|
|
522
|
+
const row = {
|
|
523
|
+
...rowFor(target),
|
|
524
|
+
config: await loadConfig(target.paths.configDir, target.dir),
|
|
525
|
+
};
|
|
221
526
|
const conn = await connect(target.paths.socket);
|
|
222
527
|
if (conn === undefined) return row;
|
|
223
528
|
try {
|
package/src/daemon/supervise.ts
CHANGED
|
@@ -118,7 +118,18 @@ export class Supervisor {
|
|
|
118
118
|
this.#startTimeoutMs = options.startTimeoutMs ?? START_TIMEOUT_MS;
|
|
119
119
|
this.#stopTimeoutMs = options.stopTimeoutMs ?? STOP_TIMEOUT_MS;
|
|
120
120
|
this.#log = options.log ?? ((line) => process.stderr.write(`${JSON.stringify(line)}\n`));
|
|
121
|
-
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Read which config homes there are.
|
|
124
|
+
*
|
|
125
|
+
* In `run` rather than in the constructor because the files are TypeScript
|
|
126
|
+
* and reading one is an import: a caller holding a supervisor that has not
|
|
127
|
+
* run yet is holding one that has not read the list yet, which is the same
|
|
128
|
+
* moment it was already true that nothing had been started. */
|
|
129
|
+
async #adopt(): Promise<void> {
|
|
130
|
+
for (const target of await registered(this.#env)) {
|
|
131
|
+
this.#units.set(target.dir, new Supervised(target));
|
|
132
|
+
}
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
/** The config homes this supervisor is looking after right now. */
|
|
@@ -138,6 +149,7 @@ export class Supervisor {
|
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
async #serve(): Promise<void> {
|
|
152
|
+
await this.#adopt();
|
|
141
153
|
await this.#listen();
|
|
142
154
|
for (const unit of this.#units.values()) this.#keep(unit);
|
|
143
155
|
// What ends the run is being asked to, not the children ending: a
|
|
@@ -307,7 +319,7 @@ export class Supervisor {
|
|
|
307
319
|
* behind it is the state `add` exists to leave behind only when there is no
|
|
308
320
|
* supervisor to tell. */
|
|
309
321
|
async addOne(dir: string): Promise<StatusRow> {
|
|
310
|
-
const home = configHome(dir, harnessFor(this.#env, dir));
|
|
322
|
+
const home = configHome(dir, await harnessFor(this.#env, dir));
|
|
311
323
|
if (this.#units.has(home)) {
|
|
312
324
|
throw new CommandError("file_exists", `${home} は既に見ています`);
|
|
313
325
|
}
|