@ccmsg/cli 0.1.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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute } from "node:path";
|
|
3
|
+
import type { Endpoint } from "@ccmsg/protocol";
|
|
4
|
+
|
|
5
|
+
/** Where the instance accepts WebSocket connections, and from whom.
|
|
6
|
+
*
|
|
7
|
+
* The two allowlists are the entry check of §3.1: what transport asks before a
|
|
8
|
+
* request is upgraded. They are config-driven because who may reach an
|
|
9
|
+
* instance is a deployment fact, not a property of the code. */
|
|
10
|
+
export interface EntryConfig {
|
|
11
|
+
readonly host: string;
|
|
12
|
+
/** 0 asks the kernel for a free port. */
|
|
13
|
+
readonly port: number;
|
|
14
|
+
/** Source addresses allowed to connect. Empty means every address the bind
|
|
15
|
+
* itself already permits, which for the default loopback bind is this host. */
|
|
16
|
+
readonly source_ips: readonly string[];
|
|
17
|
+
/** `Origin` values a browser connection may present. Empty admits no
|
|
18
|
+
* browser at all: a request carrying no `Origin` is not a browser's and is
|
|
19
|
+
* judged on the address and the token alone, so the list only ever widens
|
|
20
|
+
* what reaches the socket, and an unlisted webui is refused rather than
|
|
21
|
+
* let in by default. */
|
|
22
|
+
readonly origins: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One value a launch recipe's command reads, as the operator declares it. */
|
|
26
|
+
export interface LauncherParamConfig {
|
|
27
|
+
/** A shell identifier: the launcher defines a variable of this name, so a
|
|
28
|
+
* name the shell could not carry is a config the launcher cannot honour. */
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly default: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One launch recipe. The shell is the instance's business and is not reported
|
|
34
|
+
* to a client, which is why the contract's template carries no such field. */
|
|
35
|
+
export interface LauncherTemplateConfig {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly command: string;
|
|
38
|
+
readonly params: readonly LauncherParamConfig[];
|
|
39
|
+
readonly shell: "bash" | "zsh";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the launcher may start, and where.
|
|
43
|
+
*
|
|
44
|
+
* Structured rather than a string because it is a form: the roots bound where a
|
|
45
|
+
* session may run, and the recipes are what `launcher_config_read` answers
|
|
46
|
+
* with. Present is what gives this instance the `launcher` capability. */
|
|
47
|
+
export interface LauncherConfig {
|
|
48
|
+
/** Absolute directories a session may be started in. A launch or a walk
|
|
49
|
+
* elsewhere reaches nothing. */
|
|
50
|
+
readonly root_dirs: readonly string[];
|
|
51
|
+
/** In configured order; the first is the default recipe. */
|
|
52
|
+
readonly templates: readonly LauncherTemplateConfig[];
|
|
53
|
+
/** How deep `dir_tree` walks when a request names no depth. */
|
|
54
|
+
readonly depth: number;
|
|
55
|
+
/** How long a launch may run before it is stopped. */
|
|
56
|
+
readonly timeout_secs: number;
|
|
57
|
+
/** Environment names the launched shell does not inherit, as patterns where
|
|
58
|
+
* `*` stands for any run of characters. What a session must not inherit is a
|
|
59
|
+
* deployment fact: an instance's own config home reaching the session it
|
|
60
|
+
* starts would point that session back at this instance's settings. */
|
|
61
|
+
readonly clean_env: readonly string[];
|
|
62
|
+
/** Names kept despite matching `clean_env`, which is what lets one broad
|
|
63
|
+
* pattern be written beside the few exceptions to it. */
|
|
64
|
+
readonly keep_env: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The upstreams an instance reaches, and the ones it only writes down.
|
|
68
|
+
*
|
|
69
|
+
* The two gateway fields are read: the address is where its service report,
|
|
70
|
+
* quota and spend are asked for, and the source is the path segment it posts
|
|
71
|
+
* what it saw to. The rest are stated as a type so a config carrying them is
|
|
72
|
+
* accepted rather than rejected as unknown, and so what is missing is missing
|
|
73
|
+
* in one visible place. */
|
|
74
|
+
export interface UpstreamConfig {
|
|
75
|
+
readonly gateway_url?: string;
|
|
76
|
+
readonly gateway_webhook_source?: string;
|
|
77
|
+
/** Where the secret the gateway presents is kept. Absent uses the path the
|
|
78
|
+
* gateway itself defaults to, which is the one it wrote. */
|
|
79
|
+
readonly gateway_webhook_token_file?: string;
|
|
80
|
+
readonly terminal_gateway?: string;
|
|
81
|
+
readonly launcher?: LauncherConfig;
|
|
82
|
+
/** The program that translates a batch on this host, as an absolute path. It
|
|
83
|
+
* is an upstream like any other: this instance speaks to it and does not
|
|
84
|
+
* build it. */
|
|
85
|
+
readonly translate_helper?: string;
|
|
86
|
+
readonly sandbox_origin?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface InstanceConfig {
|
|
90
|
+
/** Where other instances reach this one (§7.1). Stated rather than
|
|
91
|
+
* discovered: an instance may be reached through a proxy or an alias, so the
|
|
92
|
+
* URL a peer is to dial is not a thing the process can read off its own
|
|
93
|
+
* socket. Required of an instance that has a mesh.
|
|
94
|
+
*
|
|
95
|
+
* Its `ws(s)://` form is what the mesh handshake compares as `aud`; the
|
|
96
|
+
* `http(s)://` URL with the same host and path is where the mesh's own
|
|
97
|
+
* routes hang. */
|
|
98
|
+
readonly self?: Endpoint;
|
|
99
|
+
/** Mesh endpoints to dial (§7.2). The same list goes to every instance, and
|
|
100
|
+
* it may name this one: an instance takes itself out of the list it dials,
|
|
101
|
+
* so one file can be copied to every host unchanged (§8.2). */
|
|
102
|
+
readonly peers: readonly Endpoint[];
|
|
103
|
+
/** Absent when this instance serves the unix socket only. */
|
|
104
|
+
readonly entry?: EntryConfig;
|
|
105
|
+
readonly upstream: UpstreamConfig;
|
|
106
|
+
/** Whether delivery tries the harness's messaging socket before the `inbox`
|
|
107
|
+
* topic (§4.1 condition 0). On, because the protocol has been read off a
|
|
108
|
+
* running harness; off is for a harness generation that turns out to speak
|
|
109
|
+
* something else, and costs only the reach route (b) never had. */
|
|
110
|
+
readonly direct_delivery: boolean;
|
|
111
|
+
/** Whether this instance answers where a forked session's copy of its
|
|
112
|
+
* ancestor ends. Off, because the answer is found by reading whole sibling
|
|
113
|
+
* transcripts and it decorates a divider: a host that wants it says so, and
|
|
114
|
+
* one that does not never pays for it. The `fork` capability follows this,
|
|
115
|
+
* so a client learns which it is from `hello`. */
|
|
116
|
+
readonly fork_origin: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A config file that could not be understood.
|
|
120
|
+
*
|
|
121
|
+
* Its own class so startup can tell "the operator wrote something wrong" from
|
|
122
|
+
* any other failure, and refuse to run rather than continuing with the feature
|
|
123
|
+
* that setting was for silently off (§8.3, DV-Q9). */
|
|
124
|
+
export class ConfigError extends Error {
|
|
125
|
+
constructor(
|
|
126
|
+
readonly file: string,
|
|
127
|
+
msg: string,
|
|
128
|
+
) {
|
|
129
|
+
super(`${file}: ${msg}`);
|
|
130
|
+
this.name = "ConfigError";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** An instance with no config file: the unix socket, no peers, no upstreams.
|
|
135
|
+
*
|
|
136
|
+
* Absent is not broken. A config that is not there states nothing wrong, while
|
|
137
|
+
* one that is there and unreadable states something wrong — only the second is
|
|
138
|
+
* the fail-fast case. */
|
|
139
|
+
export const DEFAULT_CONFIG: InstanceConfig = {
|
|
140
|
+
peers: [],
|
|
141
|
+
upstream: {},
|
|
142
|
+
direct_delivery: true,
|
|
143
|
+
fork_origin: false,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Read the config, once, at startup (DV-Q8).
|
|
147
|
+
*
|
|
148
|
+
* There is no watch and no reload: the file is small, an instance is cheap to
|
|
149
|
+
* restart because almost nothing it holds is persistent (§3.6), and restarting
|
|
150
|
+
* is therefore the whole of "apply a config change" (§8.2). */
|
|
151
|
+
export function loadConfig(file: string, dir: string): InstanceConfig {
|
|
152
|
+
return parseConfig(file, settingsFor(loadShared(file), dir));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One config home the shared file knows about.
|
|
156
|
+
*
|
|
157
|
+
* `dir` is the config home itself, which is what an instance is (A2); the rest
|
|
158
|
+
* is whatever that instance sets differently from `defaults`, held raw because
|
|
159
|
+
* it is merged before it is read. */
|
|
160
|
+
export interface InstanceEntry {
|
|
161
|
+
readonly dir: string;
|
|
162
|
+
readonly settings: Record<string, unknown>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The one file a person edits: what every instance gets, and which config
|
|
166
|
+
* homes run one.
|
|
167
|
+
*
|
|
168
|
+
* One file rather than one per config home because both of the things it
|
|
169
|
+
* carries are facts about the set — the peer list is the same for every
|
|
170
|
+
* instance (§7.1), and "which config homes run an instance" is a question no
|
|
171
|
+
* single instance can answer about itself. */
|
|
172
|
+
export interface SharedConfig {
|
|
173
|
+
readonly defaults: Record<string, unknown>;
|
|
174
|
+
readonly instances: readonly InstanceEntry[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const EMPTY_SHARED: SharedConfig = { defaults: {}, instances: [] };
|
|
178
|
+
|
|
179
|
+
/** Read the shared file. Absent is not broken, for `DEFAULT_CONFIG`'s reason,
|
|
180
|
+
* so it reads as the empty one; present and wrong ends the read (DV-Q9). */
|
|
181
|
+
export function loadShared(file: string): SharedConfig {
|
|
182
|
+
let text: string;
|
|
183
|
+
try {
|
|
184
|
+
text = readFileSync(file, "utf8");
|
|
185
|
+
} catch {
|
|
186
|
+
return EMPTY_SHARED;
|
|
187
|
+
}
|
|
188
|
+
let parsed: unknown;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(text);
|
|
191
|
+
} catch (cause) {
|
|
192
|
+
throw new ConfigError(file, `not valid JSON (${String(cause)})`);
|
|
193
|
+
}
|
|
194
|
+
const top = objectOf(file, "the top level", parsed);
|
|
195
|
+
for (const name of Object.keys(top)) {
|
|
196
|
+
if (name !== "defaults" && name !== "instances") {
|
|
197
|
+
throw new ConfigError(file, `unknown top-level key ${name}; expected defaults or instances`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const raw = top["instances"];
|
|
201
|
+
if (raw !== undefined && !Array.isArray(raw)) {
|
|
202
|
+
throw new ConfigError(file, "instances must be an array of config homes");
|
|
203
|
+
}
|
|
204
|
+
const seen = new Set<string>();
|
|
205
|
+
const instances = ((raw ?? []) as unknown[]).map((entry, index) => {
|
|
206
|
+
const fields = objectOf(file, `instances[${index}]`, entry);
|
|
207
|
+
const { dir, ...settings } = fields;
|
|
208
|
+
if (typeof dir !== "string" || !isAbsolute(dir)) {
|
|
209
|
+
throw new ConfigError(file, `instances[${index}].dir must be an absolute config home`);
|
|
210
|
+
}
|
|
211
|
+
if (seen.has(dir)) throw new ConfigError(file, `instances[${index}].dir repeats ${dir}`);
|
|
212
|
+
seen.add(dir);
|
|
213
|
+
return { dir, settings };
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
defaults: top["defaults"] === undefined ? {} : objectOf(file, "defaults", top["defaults"]),
|
|
217
|
+
instances,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Write the shared file back, at the shape a person reads it in. */
|
|
222
|
+
export function saveShared(file: string, shared: SharedConfig): void {
|
|
223
|
+
const instances = shared.instances.map((entry) => ({ dir: entry.dir, ...entry.settings }));
|
|
224
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
225
|
+
writeFileSync(file, `${JSON.stringify({ defaults: shared.defaults, instances }, null, 2)}\n`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** What one config home's instance is configured with: its own entry over the
|
|
229
|
+
* shared defaults, key by key. A config home the file does not list still
|
|
230
|
+
* resolves — `daemon run` on an unregistered directory is the defaults plus
|
|
231
|
+
* the built-ins. */
|
|
232
|
+
export function settingsFor(shared: SharedConfig, dir: string): Record<string, unknown> {
|
|
233
|
+
const entry = shared.instances.find((one) => one.dir === dir);
|
|
234
|
+
return { ...shared.defaults, ...entry?.settings };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One instance's settings, read at the shape the instance uses them. */
|
|
238
|
+
export function parseConfig(file: string, fields: Record<string, unknown>): InstanceConfig {
|
|
239
|
+
const config = {
|
|
240
|
+
...(fields["self"] === undefined ? {} : { self: endpointOf(file, "self", fields["self"]) }),
|
|
241
|
+
peers: peersOf(file, fields["peers"]),
|
|
242
|
+
...(fields["entry"] === undefined ? {} : { entry: entryOf(file, fields["entry"]) }),
|
|
243
|
+
upstream: upstreamOf(file, fields["upstream"]),
|
|
244
|
+
direct_delivery: flagOf(
|
|
245
|
+
file,
|
|
246
|
+
"direct_delivery",
|
|
247
|
+
fields["direct_delivery"],
|
|
248
|
+
DEFAULT_CONFIG.direct_delivery,
|
|
249
|
+
),
|
|
250
|
+
fork_origin: flagOf(file, "fork_origin", fields["fork_origin"], DEFAULT_CONFIG.fork_origin),
|
|
251
|
+
};
|
|
252
|
+
// A mesh instance is dialled by its peers, and where they dial it is the one
|
|
253
|
+
// thing it cannot work out for itself. Refused here rather than at the first
|
|
254
|
+
// handshake, for the reason any broken setting is (DV-Q9).
|
|
255
|
+
if (config.self === undefined && config.peers.length > 0 && config.entry !== undefined) {
|
|
256
|
+
throw new ConfigError(file, "self must name this instance's endpoint URL when peers are set");
|
|
257
|
+
}
|
|
258
|
+
return config;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function flagOf(file: string, at: string, raw: unknown, fallback: boolean): boolean {
|
|
262
|
+
if (raw === undefined) return fallback;
|
|
263
|
+
if (typeof raw !== "boolean") throw new ConfigError(file, `${at} must be true or false`);
|
|
264
|
+
return raw;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const ENDPOINT = /^wss?:\/\/[^\s?#]+$/;
|
|
268
|
+
|
|
269
|
+
function endpointOf(file: string, at: string, raw: unknown): Endpoint {
|
|
270
|
+
if (typeof raw !== "string" || !ENDPOINT.test(raw)) {
|
|
271
|
+
throw new ConfigError(file, `${at} must be a ws:// or wss:// URL, got ${String(raw)}`);
|
|
272
|
+
}
|
|
273
|
+
return raw;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function peersOf(file: string, raw: unknown): readonly Endpoint[] {
|
|
277
|
+
if (raw === undefined) return [];
|
|
278
|
+
if (!Array.isArray(raw)) throw new ConfigError(file, "peers must be an array of endpoint URLs");
|
|
279
|
+
return raw.map((peer, index) => endpointOf(file, `peers[${index}]`, peer));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function entryOf(file: string, raw: unknown): EntryConfig {
|
|
283
|
+
const fields = objectOf(file, "entry", raw);
|
|
284
|
+
const port = fields["port"];
|
|
285
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
286
|
+
throw new ConfigError(file, "entry.port must be a port number, or 0 to be assigned one");
|
|
287
|
+
}
|
|
288
|
+
const host = fields["host"] ?? "127.0.0.1";
|
|
289
|
+
if (typeof host !== "string" || host === "") {
|
|
290
|
+
throw new ConfigError(file, "entry.host must be an address to bind");
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
host,
|
|
294
|
+
port,
|
|
295
|
+
source_ips: stringsOf(file, "entry.source_ips", fields["source_ips"]),
|
|
296
|
+
origins: stringsOf(file, "entry.origins", fields["origins"]),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function upstreamOf(file: string, raw: unknown): UpstreamConfig {
|
|
301
|
+
if (raw === undefined) return {};
|
|
302
|
+
const fields = objectOf(file, "upstream", raw);
|
|
303
|
+
const config: Record<string, string> = {};
|
|
304
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
305
|
+
// The launcher is the one upstream that is a form rather than an address,
|
|
306
|
+
// so it is the one read at its own shape; everything else is a string.
|
|
307
|
+
if (name === "launcher") continue;
|
|
308
|
+
if (typeof value !== "string") {
|
|
309
|
+
throw new ConfigError(file, `upstream.${name} must be a string`);
|
|
310
|
+
}
|
|
311
|
+
config[name] = value;
|
|
312
|
+
}
|
|
313
|
+
const launcher = fields["launcher"];
|
|
314
|
+
return {
|
|
315
|
+
...(config as UpstreamConfig),
|
|
316
|
+
...(launcher === undefined ? {} : { launcher: launcherOf(file, launcher) }),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** How deep `dir_tree` walks, and how long a launch may take, when the config
|
|
321
|
+
* says neither. */
|
|
322
|
+
const DEFAULT_DEPTH = 2;
|
|
323
|
+
const DEFAULT_TIMEOUT_SECS = 10;
|
|
324
|
+
|
|
325
|
+
/** A shell identifier, which is what a launch parameter's name has to be: the
|
|
326
|
+
* launcher defines a variable of that name for the command to read. */
|
|
327
|
+
const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
328
|
+
|
|
329
|
+
function launcherOf(file: string, raw: unknown): LauncherConfig {
|
|
330
|
+
const fields = objectOf(file, "upstream.launcher", raw);
|
|
331
|
+
const roots = stringsOf(file, "upstream.launcher.root_dirs", fields["root_dirs"]);
|
|
332
|
+
if (roots.length === 0 || roots.some((root) => !isAbsolute(root))) {
|
|
333
|
+
throw new ConfigError(
|
|
334
|
+
file,
|
|
335
|
+
"upstream.launcher.root_dirs must name at least one absolute directory",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const templates = templatesOf(file, fields["templates"]);
|
|
339
|
+
if (templates.length === 0) {
|
|
340
|
+
throw new ConfigError(file, "upstream.launcher.templates must hold at least one recipe");
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
root_dirs: roots,
|
|
344
|
+
templates,
|
|
345
|
+
depth: countOf(file, "upstream.launcher.depth", fields["depth"], DEFAULT_DEPTH),
|
|
346
|
+
timeout_secs: countOf(
|
|
347
|
+
file,
|
|
348
|
+
"upstream.launcher.timeout_secs",
|
|
349
|
+
fields["timeout_secs"],
|
|
350
|
+
DEFAULT_TIMEOUT_SECS,
|
|
351
|
+
),
|
|
352
|
+
clean_env: stringsOf(file, "upstream.launcher.clean_env", fields["clean_env"]),
|
|
353
|
+
keep_env: stringsOf(file, "upstream.launcher.keep_env", fields["keep_env"]),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function templatesOf(file: string, raw: unknown): LauncherTemplateConfig[] {
|
|
358
|
+
if (!Array.isArray(raw)) {
|
|
359
|
+
throw new ConfigError(file, "upstream.launcher.templates must be an array of recipes");
|
|
360
|
+
}
|
|
361
|
+
const names = new Set<string>();
|
|
362
|
+
return raw.map((entry, index) => {
|
|
363
|
+
const at = `upstream.launcher.templates[${index}]`;
|
|
364
|
+
const fields = objectOf(file, at, entry);
|
|
365
|
+
const name = fields["name"];
|
|
366
|
+
const command = fields["command"];
|
|
367
|
+
if (typeof name !== "string" || name === "") {
|
|
368
|
+
throw new ConfigError(file, `${at}.name must be a name for the recipe`);
|
|
369
|
+
}
|
|
370
|
+
if (names.has(name)) throw new ConfigError(file, `${at}.name repeats ${name}`);
|
|
371
|
+
names.add(name);
|
|
372
|
+
if (typeof command !== "string" || command === "") {
|
|
373
|
+
throw new ConfigError(file, `${at}.command must be a shell program`);
|
|
374
|
+
}
|
|
375
|
+
const shell = fields["shell"] ?? "bash";
|
|
376
|
+
if (shell !== "bash" && shell !== "zsh") {
|
|
377
|
+
throw new ConfigError(file, `${at}.shell must be bash or zsh`);
|
|
378
|
+
}
|
|
379
|
+
return { name, command, shell, params: paramsOf(file, at, fields["params"]) };
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function paramsOf(file: string, at: string, raw: unknown): LauncherParamConfig[] {
|
|
384
|
+
if (raw === undefined) return [];
|
|
385
|
+
if (!Array.isArray(raw)) {
|
|
386
|
+
throw new ConfigError(file, `${at}.params must be an array of parameters`);
|
|
387
|
+
}
|
|
388
|
+
return raw.map((entry, index) => {
|
|
389
|
+
const where = `${at}.params[${index}]`;
|
|
390
|
+
const fields = objectOf(file, where, entry);
|
|
391
|
+
const name = fields["name"];
|
|
392
|
+
const fallback = fields["default"] ?? "";
|
|
393
|
+
if (typeof name !== "string" || !SHELL_NAME.test(name)) {
|
|
394
|
+
throw new ConfigError(file, `${where}.name must be a shell identifier`);
|
|
395
|
+
}
|
|
396
|
+
if (typeof fallback !== "string") {
|
|
397
|
+
throw new ConfigError(file, `${where}.default must be a string`);
|
|
398
|
+
}
|
|
399
|
+
return { name, default: fallback };
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function countOf(file: string, at: string, raw: unknown, fallback: number): number {
|
|
404
|
+
if (raw === undefined) return fallback;
|
|
405
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) {
|
|
406
|
+
throw new ConfigError(file, `${at} must be a positive whole number`);
|
|
407
|
+
}
|
|
408
|
+
return raw;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function objectOf(file: string, at: string, raw: unknown): Record<string, unknown> {
|
|
412
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
413
|
+
throw new ConfigError(file, `${at} must be an object`);
|
|
414
|
+
}
|
|
415
|
+
return raw as Record<string, unknown>;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function stringsOf(file: string, at: string, raw: unknown): readonly string[] {
|
|
419
|
+
if (raw === undefined) return [];
|
|
420
|
+
if (!Array.isArray(raw) || raw.some((entry) => typeof entry !== "string")) {
|
|
421
|
+
throw new ConfigError(file, `${at} must be an array of strings`);
|
|
422
|
+
}
|
|
423
|
+
return raw as string[];
|
|
424
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { OP_NAMES, type OpName } from "@ccmsg/protocol";
|
|
2
|
+
import { type Handlers, type OpHandler, OpError } from "../dispatch/index.ts";
|
|
3
|
+
|
|
4
|
+
/** The answer for an op the contract defines and this instance does not
|
|
5
|
+
* implement yet.
|
|
6
|
+
*
|
|
7
|
+
* One handler for all of them rather than one per op: what they have in common
|
|
8
|
+
* is the only thing they say. `not_found` is the contract's code for a subject
|
|
9
|
+
* that is not there, and the subject of an unimplemented op never is — the
|
|
10
|
+
* message is what tells a caller that the gap is the instance's rather than
|
|
11
|
+
* their arguments'. */
|
|
12
|
+
export function unimplemented(op: OpName): OpHandler {
|
|
13
|
+
return () => {
|
|
14
|
+
throw new OpError("not_found", `${op} is not implemented by this instance yet`);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One handler per op in the contract (M1).
|
|
19
|
+
*
|
|
20
|
+
* The record is built by walking the op names, so an op added to the attribute
|
|
21
|
+
* table arrives here as unimplemented rather than as a dispatch that finds no
|
|
22
|
+
* handler. What is given wins over that default. */
|
|
23
|
+
export function completeHandlers(implemented: Partial<Handlers>): Handlers {
|
|
24
|
+
const entries = OP_NAMES.map(
|
|
25
|
+
(op) => [op, implemented[op] ?? unimplemented(op)] as const satisfies [OpName, OpHandler],
|
|
26
|
+
);
|
|
27
|
+
return Object.fromEntries(entries) as Handlers;
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import type { InstanceId } from "@ccmsg/protocol";
|
|
5
|
+
|
|
6
|
+
/** How much randomness an instance id carries. Sixteen bytes is the width at
|
|
7
|
+
* which two independently generated ids do not collide for any number of
|
|
8
|
+
* instances a person runs, and it is what the contract's fixed-width hex
|
|
9
|
+
* spelling is sized for. */
|
|
10
|
+
const ID_BYTES = 16;
|
|
11
|
+
|
|
12
|
+
const ID = /^[0-9a-f]{32}$/;
|
|
13
|
+
|
|
14
|
+
/** This instance's identity, read from the state directory and generated there
|
|
15
|
+
* the first time it is asked for.
|
|
16
|
+
*
|
|
17
|
+
* It is written down rather than derived because everything that has to survive
|
|
18
|
+
* the instance moving is keyed by it — `mid`, the store's keys, `last_live`,
|
|
19
|
+
* the issuer of a credential record — and a value derived from where the
|
|
20
|
+
* instance currently is would change exactly when those must not. Moving an
|
|
21
|
+
* instance is moving this directory.
|
|
22
|
+
*
|
|
23
|
+
* Not a secret: it names the instance to every peer and to every client, and
|
|
24
|
+
* the file's mode says so. What it is worth protecting from is loss, which is
|
|
25
|
+
* the state directory's concern rather than this file's. */
|
|
26
|
+
export function instanceIdentity(file: string): InstanceId {
|
|
27
|
+
const held = read(file);
|
|
28
|
+
if (held !== undefined) return held;
|
|
29
|
+
const made = randomBytes(ID_BYTES).toString("hex");
|
|
30
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
31
|
+
writeFileSync(file, `${made}\n`);
|
|
32
|
+
return made;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function read(file: string): InstanceId | undefined {
|
|
36
|
+
let text: string;
|
|
37
|
+
try {
|
|
38
|
+
text = readFileSync(file, "utf8");
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const id = text.trim();
|
|
43
|
+
return ID.test(id) ? id : undefined;
|
|
44
|
+
}
|