@scp-dev/cli 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,255 @@
1
+ import {
2
+ activeRelay,
3
+ checkRelayUrl,
4
+ configDir,
5
+ relayCredential,
6
+ writePrivateJson
7
+ } from "./chunk-4QYUPPGV.js";
8
+ import {
9
+ RelayUsage
10
+ } from "./chunk-7KP6TAJW.js";
11
+ import {
12
+ note,
13
+ printTable
14
+ } from "./chunk-DA3EB2YF.js";
15
+
16
+ // src/remote.ts
17
+ import { existsSync, readFileSync, rmSync } from "fs";
18
+ import { join } from "path";
19
+ import { CLI_NAME, parsePolicy, PROTOCOL, RelayError } from "@scp-dev/core";
20
+ import { MACHINE_NAME } from "@scp-dev/hub";
21
+ import { relaySocketUrl, thisHostname } from "@scp-dev/broker";
22
+ var CloudUsage = class extends RelayUsage {
23
+ };
24
+ var CloudRelayClient = class _CloudRelayClient {
25
+ constructor(ws) {
26
+ this.ws = ws;
27
+ }
28
+ ws;
29
+ nextId = 1;
30
+ pending = /* @__PURE__ */ new Map();
31
+ listeners = [];
32
+ closedError;
33
+ static async connect(origin, token) {
34
+ const url = relaySocketUrl(origin);
35
+ const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } });
36
+ const client = new _CloudRelayClient(ws);
37
+ await new Promise((resolve, reject) => {
38
+ ws.addEventListener("open", () => resolve());
39
+ ws.addEventListener("error", () => reject(new RelayError("unavailable", `could not connect to the relay at ${url} (check SCP_RELAY and your login)`)));
40
+ });
41
+ ws.addEventListener("message", (event) => client.onFrame(String(event.data)));
42
+ ws.addEventListener("close", (event) => {
43
+ const reason = event.reason ? `: ${event.reason}` : "";
44
+ client.fail(
45
+ event.code === 1008 ? new RelayError("unauthorized", `the relay closed the connection${reason}`) : new RelayError("unavailable", `the relay closed the connection (${event.code}${reason})`)
46
+ );
47
+ });
48
+ return client;
49
+ }
50
+ onNotification(fn) {
51
+ this.listeners.push(fn);
52
+ }
53
+ request(method, params = {}) {
54
+ if (this.closedError) return Promise.reject(this.closedError);
55
+ const id = this.nextId++;
56
+ return new Promise((resolve, reject) => {
57
+ this.pending.set(id, { resolve, reject });
58
+ this.ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
59
+ });
60
+ }
61
+ close() {
62
+ this.fail(new RelayError("unavailable", "the connection is closed"));
63
+ if (this.ws.readyState <= 1) this.ws.close(1e3, "done");
64
+ }
65
+ onFrame(text) {
66
+ let frame;
67
+ try {
68
+ frame = JSON.parse(text);
69
+ } catch {
70
+ return;
71
+ }
72
+ if (frame.method) {
73
+ for (const fn of this.listeners) fn(frame.method, frame.params);
74
+ return;
75
+ }
76
+ const waiter = frame.id !== void 0 ? this.pending.get(frame.id) : void 0;
77
+ if (!waiter) return;
78
+ this.pending.delete(frame.id);
79
+ if (frame.error) waiter.reject(new RelayError(frame.error.data?.code ?? "invalid_params", frame.error.message, frame.error.data?.retry_after));
80
+ else waiter.resolve(frame.result);
81
+ }
82
+ fail(error) {
83
+ if (this.closedError) return;
84
+ this.closedError = error;
85
+ for (const waiter of this.pending.values()) waiter.reject(error);
86
+ this.pending.clear();
87
+ }
88
+ };
89
+ function hostMachine() {
90
+ const name = thisHostname().split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
91
+ return MACHINE_NAME.test(name) && name !== "me" ? name : "machine";
92
+ }
93
+ function machineName(flag, env = process.env) {
94
+ const name = flag ?? env.SCP_MACHINE ?? hostMachine();
95
+ if (!MACHINE_NAME.test(name) || name === "me")
96
+ throw new CloudUsage(`machine ${JSON.stringify(name)} is not valid: lowercase letters, digits and -, at most 32, not "me"`);
97
+ return name;
98
+ }
99
+ async function openCloud(options) {
100
+ const machine = machineName(options.machine);
101
+ const { url } = activeRelay();
102
+ const { token, problem } = relayCredential(process.env, url);
103
+ if (!token) throw new CloudUsage(problem);
104
+ const client = await CloudRelayClient.connect(url, token);
105
+ try {
106
+ const hello = await client.request(
107
+ "hello",
108
+ options.handle ? { protocol: PROTOCOL, machine, claim: options.claim } : options.observe ? { protocol: PROTOCOL, observe: true, machine } : { protocol: PROTOCOL, machine, human: true, tty: options.tty }
109
+ );
110
+ return { client, me: hello.endpoint ?? null, where: `the relay at ${url}, account ${hello.account}, machine ${machine}` };
111
+ } catch (error) {
112
+ client.close();
113
+ throw error;
114
+ }
115
+ }
116
+ var out = (line = "") => process.stdout.write(`${line}
117
+ `);
118
+ var json = (value) => out(JSON.stringify(value, null, 2));
119
+ async function machinesVerb(client, positionals, asJson) {
120
+ const [action, name] = positionals;
121
+ if (action === "rm") {
122
+ if (!name || positionals.length !== 2) throw new CloudUsage(`${CLI_NAME} machines rm needs one machine name`);
123
+ const result = await client.request("machines_rm", { name });
124
+ if (asJson) json(result);
125
+ else out(`released ${result.removed} (${result.endpoints} endpoints dropped)`);
126
+ return 0;
127
+ }
128
+ if (action !== void 0) throw new CloudUsage(`${CLI_NAME} machines takes no argument, or rm NAME`);
129
+ const { machines } = await client.request("machines");
130
+ if (asJson) json({ machines });
131
+ else
132
+ printTable([
133
+ ["MACHINE", "KIND", "ONLINE", "BRIDGE", "LAST SEEN"],
134
+ ...machines.map((m) => [m.name, m.kind, m.online ? "yes" : "no", m.bridge ? "yes" : "no", m.last_seen ?? ""])
135
+ ]);
136
+ return 0;
137
+ }
138
+ function cloudFilePath(env = process.env) {
139
+ return join(configDir(env), "cloud.json");
140
+ }
141
+ function readCloudFile(path, env = process.env) {
142
+ const value = JSON.parse(readFileSync(path, "utf8"));
143
+ if (value.bridge !== true) throw new Error(`${path}: bridge is not true`);
144
+ if (typeof value.machine !== "string" || !MACHINE_NAME.test(value.machine) || value.machine === "me") throw new Error(`${path}: machine is not a valid name`);
145
+ if (value.url !== void 0 && typeof value.url !== "string") throw new Error(`${path}: url must be a URL`);
146
+ const url = value.url === void 0 ? activeRelay(env).url : checkRelayUrl(value.url, `url in ${path}`);
147
+ if (value.mirror !== void 0) {
148
+ if (!Array.isArray(value.mirror)) throw new Error(`${path}: mirror is a list of selectors`);
149
+ const scoped = value.mirror.find((sel) => typeof sel === "string" && /^(?:group|fleet|tree|machine|org):/.test(sel));
150
+ if (scoped) throw new Error(`${path}: mirror selectors match by handle, harness, kind and label; ${JSON.stringify(scoped)} is a scope selector`);
151
+ parsePolicy({ rules: value.mirror.map((match) => ({ match })) });
152
+ }
153
+ if (value.redact !== void 0 && typeof value.redact !== "boolean") throw new Error(`${path}: redact is true or false`);
154
+ if (value.private_ok !== void 0 && typeof value.private_ok !== "boolean") throw new Error(`${path}: private_ok is true or false`);
155
+ return {
156
+ bridge: true,
157
+ machine: value.machine,
158
+ url,
159
+ ...value.mirror ? { mirror: value.mirror } : {},
160
+ ...value.redact !== void 0 ? { redact: value.redact } : {},
161
+ ...value.private_ok !== void 0 ? { private_ok: value.private_ok } : {}
162
+ };
163
+ }
164
+ async function daemonBridge(env = process.env, log = console.error) {
165
+ const path = cloudFilePath(env);
166
+ if (!path || !existsSync(path)) return void 0;
167
+ let file;
168
+ try {
169
+ file = readCloudFile(path, env);
170
+ } catch (error) {
171
+ log(`relay broker: ${error instanceof Error ? error.message : String(error)}; running local-only`);
172
+ return void 0;
173
+ }
174
+ let credentials;
175
+ try {
176
+ credentials = relayCredential(env, file.url);
177
+ } catch (error) {
178
+ credentials = { token: null, problem: error instanceof Error ? error.message : String(error) };
179
+ }
180
+ if (credentials.problem) log(`relay broker: ${credentials.problem}; running local-only`);
181
+ return {
182
+ url: file.url,
183
+ token: credentials.token,
184
+ ...credentials.problem ? { tokenError: `${credentials.problem}; running local-only` } : {},
185
+ credentials: async () => relayCredential(env, file.url).token,
186
+ machine: file.machine,
187
+ mirror: file.mirror,
188
+ redact: file.redact,
189
+ privateOk: file.private_ok
190
+ };
191
+ }
192
+ async function cloudMain(positionals, values, brokerStatus) {
193
+ const [action] = positionals;
194
+ if (positionals.length !== 1) throw new CloudUsage(`${CLI_NAME} cloud takes enable [--machine NAME], disable or status`);
195
+ const path = cloudFilePath();
196
+ if (!path) throw new CloudUsage("neither XDG_CONFIG_HOME nor HOME is set, so there is no place for the relay's cloud.json");
197
+ if (action === "enable") {
198
+ const machine = machineName(values.machine);
199
+ const { url } = activeRelay();
200
+ const { problem } = relayCredential(process.env, url);
201
+ const file = { bridge: true, machine, url };
202
+ writePrivateJson(path, file);
203
+ if (values.json) json({ file: path, ...file, token: problem ? "missing" : "found" });
204
+ else {
205
+ out(`the relay broker links to ${url} as machine ${machine} the next time it starts (${path})`);
206
+ if (problem) note(problem);
207
+ }
208
+ return 0;
209
+ }
210
+ if (values.machine !== void 0) throw new CloudUsage(`${CLI_NAME} cloud ${action ?? ""} does not take --machine`);
211
+ if (action === "disable") {
212
+ const had = existsSync(path);
213
+ rmSync(path, { force: true });
214
+ if (values.json) json({ file: path, removed: had });
215
+ else out(had ? `removed ${path}; the running broker keeps its link until it exits` : `no ${path}; the broker is local-only`);
216
+ return 0;
217
+ }
218
+ if (action === "status") {
219
+ let file = null;
220
+ let fileError = null;
221
+ if (existsSync(path)) {
222
+ try {
223
+ file = readCloudFile(path);
224
+ } catch (error) {
225
+ fileError = error instanceof Error ? error.message : String(error);
226
+ }
227
+ }
228
+ const broker = await brokerStatus();
229
+ if (values.json) {
230
+ json({ file: path, config: file, ...fileError ? { config_error: fileError } : {}, broker });
231
+ return 0;
232
+ }
233
+ out(file ? `${path}: machine ${file.machine} at ${file.url}` : fileError ? fileError : `no ${path}: the broker is local-only`);
234
+ if (!broker.enabled) out(file ? "the running broker started before this file existed and is local-only until it restarts" : "the broker is local-only");
235
+ else
236
+ out(
237
+ `broker: ${broker.connected ? "connected" : "not connected"} to ${broker.url} as ${broker.machine} since ${broker.since}${broker.last_error ? `; ${broker.last_error}` : ""}`
238
+ );
239
+ return 0;
240
+ }
241
+ throw new CloudUsage(`${CLI_NAME} cloud takes enable [--machine NAME], disable or status`);
242
+ }
243
+
244
+ export {
245
+ CloudUsage,
246
+ CloudRelayClient,
247
+ hostMachine,
248
+ machineName,
249
+ openCloud,
250
+ machinesVerb,
251
+ cloudFilePath,
252
+ readCloudFile,
253
+ daemonBridge,
254
+ cloudMain
255
+ };
@@ -0,0 +1,336 @@
1
+ import {
2
+ SELF,
3
+ harnessLineage,
4
+ hiddenHarness,
5
+ labelsFor,
6
+ lineage,
7
+ parseSeconds,
8
+ readPsTable
9
+ } from "./chunk-DA3EB2YF.js";
10
+
11
+ // src/common.ts
12
+ import { spawn } from "child_process";
13
+ import { closeSync, openSync, statSync, truncateSync } from "fs";
14
+ import { join } from "path";
15
+ import { CLI_NAME } from "@scp-dev/core";
16
+ import { connectBroker, privateDir, relayDir } from "@scp-dev/broker";
17
+ var RelayUsage = class extends Error {
18
+ };
19
+ var OPTIONS = {
20
+ cloud: { type: "boolean" },
21
+ machine: { type: "string" },
22
+ token: { type: "string" },
23
+ label: { type: "string" },
24
+ "no-browser": { type: "boolean" },
25
+ json: { type: "boolean" },
26
+ channels: { type: "boolean" },
27
+ write: { type: "boolean" },
28
+ print: { type: "boolean" },
29
+ "block-stop-on-unread": { type: "boolean" },
30
+ "push-wait": { type: "string" },
31
+ all: { type: "boolean" },
32
+ as: { type: "string" },
33
+ harness: { type: "string" },
34
+ wait: { type: "string" },
35
+ ack: { type: "boolean" },
36
+ keep: { type: "boolean" },
37
+ "reply-to": { type: "string" },
38
+ "require-reply": { type: "boolean" },
39
+ thread: { type: "string" },
40
+ private: { type: "boolean" },
41
+ attach: { type: "string", multiple: true },
42
+ tool: { type: "string" },
43
+ summary: { type: "string" },
44
+ since: { type: "string" },
45
+ limit: { type: "string" },
46
+ idle: { type: "string" },
47
+ policy: { type: "string" },
48
+ human: { type: "boolean" },
49
+ help: { type: "boolean", short: "h" },
50
+ scope: { type: "string" },
51
+ tree: { type: "boolean" },
52
+ roots: { type: "boolean" },
53
+ contacts: { type: "boolean" },
54
+ activity: { type: "boolean" },
55
+ in: { type: "string" },
56
+ out: { type: "string" },
57
+ allow: { type: "string", multiple: true },
58
+ rules: { type: "string" },
59
+ group: { type: "string" },
60
+ under: { type: "string" },
61
+ ttl: { type: "string" },
62
+ labels: { type: "string" },
63
+ "handback-bytes": { type: "string" },
64
+ "no-push": { type: "boolean" },
65
+ bg: { type: "boolean" },
66
+ fg: { type: "boolean" },
67
+ name: { type: "string" },
68
+ title: { type: "string" },
69
+ judge: { type: "boolean" },
70
+ redacted: { type: "boolean" },
71
+ reason: { type: "string" },
72
+ turns: { type: "string" },
73
+ full: { type: "boolean" },
74
+ who: { type: "boolean" },
75
+ peek: { type: "string" }
76
+ };
77
+ var SPAWN_OPTIONS = [
78
+ "as",
79
+ "harness",
80
+ "human",
81
+ "name",
82
+ "title",
83
+ "scope",
84
+ "rules",
85
+ "in",
86
+ "out",
87
+ "allow",
88
+ "group",
89
+ "under",
90
+ "ttl",
91
+ "labels",
92
+ "peek",
93
+ "handback-bytes",
94
+ "no-push",
95
+ "bg",
96
+ "fg"
97
+ ];
98
+ var OBSERVE = /* @__PURE__ */ new Set([
99
+ "list",
100
+ "status",
101
+ "watch",
102
+ "log",
103
+ "policy",
104
+ "machines",
105
+ "rules",
106
+ "group",
107
+ "admin",
108
+ "spawn-env",
109
+ "spawn",
110
+ "why",
111
+ "peek",
112
+ "set-peek"
113
+ ]);
114
+ var CLOUD_OBSERVE = /* @__PURE__ */ new Set(["list", "status", "watch", "log", "machines"]);
115
+ var GRANT_TOKEN = /^krt_[0-9a-f]{64}$/;
116
+ var DAEMON_ENV = [
117
+ "PATH",
118
+ "LANG",
119
+ "LC_ALL",
120
+ "LC_CTYPE",
121
+ "TZ",
122
+ "TMPDIR",
123
+ "USER",
124
+ "LOGNAME",
125
+ "SCP_IDLE",
126
+ "HOME",
127
+ "XDG_CONFIG_HOME",
128
+ "SCP_TOKEN",
129
+ "SCP_RELAY",
130
+ "SCP_MACHINE"
131
+ ];
132
+ var LOG_MAX_BYTES = 1024 * 1024;
133
+ function startDaemon(dir) {
134
+ privateDir(dir);
135
+ const logFile = join(dir, "broker.log");
136
+ if ((statSync(logFile, { throwIfNoEntry: false })?.size ?? 0) > LOG_MAX_BYTES) truncateSync(logFile, 0);
137
+ const log = openSync(logFile, "a", 384);
138
+ try {
139
+ const env = { SCP_DIR: dir };
140
+ for (const key of DAEMON_ENV) if (process.env[key] !== void 0) env[key] = process.env[key];
141
+ const child = spawn(SELF[0], [...SELF.slice(1), "daemon"], {
142
+ detached: true,
143
+ stdio: ["ignore", log, log],
144
+ env,
145
+ cwd: dir
146
+ });
147
+ child.unref();
148
+ } finally {
149
+ closeSync(log);
150
+ }
151
+ }
152
+ function terminalAttached(streams, bodyFromStdin) {
153
+ if (streams.stdin.isTTY === true) return true;
154
+ return !bodyFromStdin && (streams.stdout.isTTY === true || streams.stderr.isTTY === true);
155
+ }
156
+ function localHandle(raw) {
157
+ const handle = raw.startsWith("me/") ? raw.slice(3) : raw;
158
+ if (!handle) throw new RelayUsage("--as me/ names your own human inbox: use --human");
159
+ if (handle.includes("/")) throw new RelayUsage(`--as names a local handle, not ${JSON.stringify(raw)}`);
160
+ return handle;
161
+ }
162
+ function walkNow() {
163
+ const table = readPsTable();
164
+ return { keys: lineage(process.ppid, table), self: harnessLineage(process.ppid, table) };
165
+ }
166
+ async function open(verb, values, cwd, bodyFromStdin, walk) {
167
+ const named = values.as ?? process.env.SCP_NAME;
168
+ const tty = terminalAttached(process, bodyFromStdin);
169
+ if (named && values.human) throw new RelayUsage("--human and --as (or $SCP_NAME) name two identities; pick one");
170
+ const grant = process.env.SCP_GRANT || void 0;
171
+ const cloud = values.cloud || process.env.SCP_TRANSPORT === "cloud" || verb === "machines";
172
+ if (grant !== void 0) {
173
+ if (!GRANT_TOKEN.test(grant)) {
174
+ throw new RelayUsage(
175
+ `$SCP_GRANT is not one spawn-env grant: pass spawn-env's lines as separate words: env $(${CLI_NAME} spawn-env ...) cmd; in zsh env \${=G} cmd`
176
+ );
177
+ }
178
+ if (values.human) throw new RelayUsage("--human and $SCP_GRANT name two identities: unset the grant to act as me/");
179
+ if (cloud) throw new RelayUsage("a spawn-env grant is for this machine's relay broker, not the cloud transport");
180
+ }
181
+ if (cloud) {
182
+ if (!named && !values.human && !tty && !OBSERVE.has(verb)) {
183
+ throw new RelayUsage(
184
+ `${CLI_NAME} ${verb} needs an identity: --as HANDLE (or $SCP_NAME) for an agent, or --human for your own inbox me/ (no terminal is attached, so me/ is not assumed)`
185
+ );
186
+ }
187
+ const { machineName, openCloud } = await import("./remote-3BWFISXJ.js");
188
+ const handle2 = named ? localHandle(named) : void 0;
189
+ const claim = handle2 ? {
190
+ handle: handle2,
191
+ harness: values.harness ?? process.env.SCP_HARNESS ?? "cli",
192
+ cwd,
193
+ ...process.env.SCP_PARENT ? { parent: process.env.SCP_PARENT } : {},
194
+ labels: labelsFor(process.env, machineName(values.machine))
195
+ } : void 0;
196
+ const observe = !handle2 && !values.human && CLOUD_OBSERVE.has(verb);
197
+ return {
198
+ ...await openCloud({
199
+ ...values.machine !== void 0 ? { machine: values.machine } : {},
200
+ ...handle2 ? { handle: handle2 } : {},
201
+ ...claim ? { claim } : {},
202
+ tty,
203
+ observe
204
+ }),
205
+ broker: null,
206
+ bound: handle2 ? "as" : observe ? null : "human"
207
+ };
208
+ }
209
+ if (values.machine !== void 0) throw new RelayUsage("--machine names the machine of the cloud transport: add --cloud");
210
+ const handle = named ? localHandle(named) : void 0;
211
+ const { keys, self } = walk ?? walkNow();
212
+ const harnessPid = self.length ? Number(/^pid:(\d+)/.exec(self.at(-1))?.[1]) : void 0;
213
+ const lineageClaim = keys.length ? { lineage: keys } : {};
214
+ const dir = relayDir();
215
+ const client = await connectBroker(dir, () => startDaemon(dir));
216
+ try {
217
+ let bound;
218
+ let hello;
219
+ if (handle) {
220
+ bound = "as";
221
+ hello = await client.hello({
222
+ claim: {
223
+ handle,
224
+ exact: true,
225
+ harness: values.harness ?? process.env.SCP_HARNESS ?? "cli",
226
+ cwd,
227
+ ...process.env.SCP_PARENT ? { parent: process.env.SCP_PARENT } : {},
228
+ labels: labelsFor(process.env),
229
+ ...lineageClaim,
230
+ ...grant ? { grant } : {}
231
+ },
232
+ ...self.length ? { self } : {},
233
+ brief: true
234
+ });
235
+ } else if (grant) {
236
+ hello = await client.hello({ observe: true, ...lineageClaim, ...self.length ? { self } : {}, grant, cwd, brief: true });
237
+ if (!hello.grant_bound) throw new RelayUsage(`the running ${CLI_NAME} broker does not check spawn-env grants: let it exit (it stops when idle) and retry`);
238
+ bound = "lineage";
239
+ } else if (values.human || tty) {
240
+ hello = await client.hello({ human: true, tty, cwd, brief: true, ...lineageClaim });
241
+ bound = hello.endpoint?.kind === "human" ? "human" : "lineage";
242
+ } else {
243
+ hello = await client.hello({ observe: true, ...lineageClaim, ...self.length ? { self } : {}, cwd, brief: true });
244
+ bound = hello.endpoint ? "lineage" : null;
245
+ if (!hello.endpoint && !OBSERVE.has(verb)) {
246
+ if (hiddenHarness(process.ppid, readPsTable()))
247
+ throw new RelayUsage(`${CLI_NAME} ${verb}: sandbox pid namespace: this shell cannot see its harness; use the scp MCP tools`);
248
+ throw new RelayUsage(
249
+ `${CLI_NAME} ${verb}: no relay agent runs this shell's harness process (${harnessPid ? `pid ${harnessPid}` : "none found"}); pass --as HANDLE or --human (no terminal is attached, so me/ is not assumed)`
250
+ );
251
+ }
252
+ }
253
+ return { client, me: hello.endpoint, broker: hello.broker, where: brokerLine(hello.broker), bound, harnessPid };
254
+ } catch (error) {
255
+ client.close();
256
+ throw error;
257
+ }
258
+ }
259
+ function localClient(client, broker, verb) {
260
+ if (!broker) throw new RelayUsage(`${CLI_NAME} ${verb} is the local broker's; it has no --cloud form`);
261
+ return client;
262
+ }
263
+ function brokerLine(broker) {
264
+ const policy = broker.policy.state === "open" ? `no policy at ${broker.policy.file}, open` : broker.policy.state === "loaded" ? `policy ${broker.policy.file}` : `policy ${broker.policy.file} unusable, sends refused`;
265
+ return `broker ${broker.socket} (pid ${broker.pid}); ${policy}`;
266
+ }
267
+ function selectorList(value) {
268
+ if (value === void 0 || typeof value === "boolean") return void 0;
269
+ return (Array.isArray(value) ? value : [value]).flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
270
+ }
271
+ var PRESETS = /* @__PURE__ */ new Set(["open", "tree", "parent", "none"]);
272
+ function preset(value) {
273
+ if (value === void 0) return void 0;
274
+ if (typeof value !== "string" || !PRESETS.has(value)) throw new RelayUsage("--scope takes open, tree, parent or none");
275
+ return value;
276
+ }
277
+ function peekGrant(value) {
278
+ if (value === void 0) return {};
279
+ if (value !== "local") throw new RelayUsage(`--peek takes local (peekable on this machine from its start), not ${JSON.stringify(value)}`);
280
+ return { peek: "local" };
281
+ }
282
+ function grantParams(values) {
283
+ const rules = selectorList(values.rules);
284
+ if (rules && values.scope !== void 0) throw new RelayUsage("--rules and --scope both set a preset; pick one");
285
+ const allow = [...rules ?? [], ...selectorList(values.allow) ?? []];
286
+ const scope = rules ? "none" : preset(values.scope);
287
+ const groups = selectorList(values.group);
288
+ const incoming = selectorList(values.in);
289
+ const outgoing = selectorList(values.out);
290
+ return {
291
+ ...values.name ? { name: values.name } : {},
292
+ ...values.title !== void 0 ? { title: values.title } : {},
293
+ ...values.under ? { under: values.under } : {},
294
+ ...scope ? { scope } : {},
295
+ ...incoming ? { in: incoming } : {},
296
+ ...outgoing ? { out: outgoing } : {},
297
+ ...allow.length ? { allow } : {},
298
+ ...groups?.length ? { groups } : {},
299
+ ...values.ttl !== void 0 ? { ttl_s: ttlSeconds(values.ttl) } : {},
300
+ ...values["no-push"] === true ? { no_push: true } : {},
301
+ ...peekGrant(values.peek)
302
+ };
303
+ }
304
+ function ttlSeconds(text) {
305
+ try {
306
+ return parseSeconds(text, "--ttl");
307
+ } catch (error) {
308
+ throw new RelayUsage(error instanceof Error ? error.message : String(error));
309
+ }
310
+ }
311
+ var pad = (n) => String(n).padStart(2, "0");
312
+ function time(iso) {
313
+ const at = new Date(iso);
314
+ return Number.isNaN(at.getTime()) ? iso : `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
315
+ }
316
+ function stamp(iso) {
317
+ const at = new Date(iso);
318
+ return Number.isNaN(at.getTime()) ? iso : `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${time(iso)}`;
319
+ }
320
+
321
+ export {
322
+ RelayUsage,
323
+ OPTIONS,
324
+ SPAWN_OPTIONS,
325
+ startDaemon,
326
+ walkNow,
327
+ open,
328
+ localClient,
329
+ brokerLine,
330
+ selectorList,
331
+ preset,
332
+ peekGrant,
333
+ grantParams,
334
+ time,
335
+ stamp
336
+ };