@ours.network/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 ADDED
@@ -0,0 +1,98 @@
1
+ # Functional Source License, Version 1.1, Apache 2.0 Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-Apache-2.0
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Adapt Framework Solutions Ltd
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to use,
27
+ copy, modify, create derivative works, publicly perform, publicly display and
28
+ redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or service
34
+ that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software that
39
+ exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee using
52
+ the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to the
59
+ infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software, you
68
+ must include a copy of or a link to these Terms and Conditions and not remove any
69
+ copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, INCLUDING
74
+ WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
75
+ PURPOSE, NON-INFRINGEMENT, OR THAT THE SOFTWARE IS FREE OF DEFECTS. IN NO EVENT
76
+ WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE SOFTWARE,
77
+ INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, EVEN IF WE HAVE
78
+ BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
79
+
80
+ ### Grant of Future License
81
+
82
+ We hereby irrevocably grant you an additional license to use the Software under
83
+ the Apache License, Version 2.0 that is effective on the second anniversary of
84
+ the date we make the Software available. On or after that date, you may use the
85
+ Software under the Apache License, Version 2.0, in which case the following will
86
+ apply:
87
+
88
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
89
+ this file except in compliance with the License.
90
+
91
+ You may obtain a copy of the License at
92
+
93
+ http://www.apache.org/licenses/LICENSE-2.0
94
+
95
+ Unless required by applicable law or agreed to in writing, software distributed
96
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
97
+ CONDITIONS OF ANY KIND, either express or implied. See the License for the
98
+ specific language governing permissions and limitations under the License.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # @ours.network/cli
2
+
3
+ `ours` is the transport-neutral operator CLI for the shared ours daemon. It is a
4
+ separate package above `@ours.network/sdk`: lifecycle commands call
5
+ `startDaemon`, and network operations use `OursClient`. It has no dependency on
6
+ `@ours.network/mcp`.
7
+
8
+ ## Install and first checks
9
+
10
+ ```bash
11
+ npm install --global @ours.network/cli
12
+ ours version
13
+ ours config show --json
14
+ ours daemon status --json
15
+ ```
16
+
17
+ Node 20 or newer is required. `--json` emits one JSON value on stdout; errors
18
+ are JSON on stderr. Status exits `0` while running and `3` while stopped. Usage
19
+ errors exit `2`; other failures exit `1`.
20
+
21
+ ## Daemon lifecycle
22
+
23
+ ```bash
24
+ ours daemon serve # foreground
25
+ ours daemon start # detached, CLI-managed
26
+ ours daemon status --json
27
+ ours daemon stop
28
+ ours daemon restart
29
+ ```
30
+
31
+ `stop` and `restart` signal a process only when all three facts agree: the
32
+ CLI-owned PID record, the PID reported by the selected daemon, and its expected
33
+ state directory/port. A daemon started by ours-mcp, a container, systemd, or any
34
+ other launcher remains fully attachable, but these commands refuse to signal it.
35
+
36
+ Selection is coherent and credential-safe. Use a single config file or select
37
+ both the endpoint and its state directory:
38
+
39
+ ```bash
40
+ ours daemon status --config /etc/ours/config.json
41
+ ours daemon status --endpoint http://127.0.0.1:3070 --state-dir /srv/ours-a
42
+ ```
43
+
44
+ Before sending an API token, the CLI calls the unauthenticated `/state-dir`
45
+ endpoint and verifies that the answering daemon owns the selected directory.
46
+ Tokens are read from the SDK selection rules; there is intentionally no token
47
+ command-line flag.
48
+
49
+ ## Configuration
50
+
51
+ `config show` is redacted: it reports token provenance and whether secret
52
+ sections are configured, never token or STT key values. `config setup` is
53
+ noninteractive and changes only the named safe fields while preserving unknown
54
+ and secret fields already in the file.
55
+
56
+ ```bash
57
+ ours config setup --port 3070 --state-dir /srv/ours-a \
58
+ --broker-url wss://broker1.ours.network --api-visibility owner
59
+ ours config setup --auto-start false --gc-interval-ms 3600000
60
+ ours config setup --port 3070 --dry-run --json
61
+ ```
62
+
63
+ Supported setup fields are `brokerUrl`, `port`, `stateDir`, `gcIntervalMs`,
64
+ `autoStart`, and `apiVisibility`. Supply credentials through an operator-owned
65
+ secret mechanism or the existing SDK configuration, not CLI arguments.
66
+
67
+ ## Operator commands
68
+
69
+ All commands accept the selection flags `--config`, `--endpoint`, `--port`, and
70
+ `--state-dir`; identity-scoped commands also accept `--identity NAME`, which
71
+ binds that identity without force for the current process.
72
+
73
+ ```text
74
+ identity create | create-root | create-temporary | close-temporary
75
+ list | show | use | remove | release | pin
76
+ profile set-bio | set-persona
77
+ invite create | list | revoke | accept
78
+ contact list | local | policy | remove | rename | respond
79
+ message send | list | get | defer
80
+ file send | list | get | fetch | defer
81
+ conversation show | receipts | mark-read | policy
82
+ daemon info | identities | unread | watch
83
+ ```
84
+
85
+ Input fields become kebab-case flags. For example:
86
+
87
+ ```bash
88
+ ours identity create --name BuildBot --bio "Build coordinator"
89
+ ours message send --identity BuildBot --contact Peer --text "done" --json
90
+ ours file get --identity BuildBot --wire-ids ID1,ID2 --json
91
+ ours conversation policy --identity Human --keep-history true
92
+ ```
93
+
94
+ Malformed JSON, unknown input fields, invalid booleans/integers, and incomplete
95
+ file inputs fail closed before the request. Destructive identity/contact/invite
96
+ operations require `--yes`.
97
+
98
+ `ours api list` shows the expert fallback allowlist. Invoke one with exact SDK
99
+ argument names, for example:
100
+
101
+ ```bash
102
+ ours api send-message --identity BuildBot \
103
+ --input '{"contact":"Peer","text":"done"}' --json
104
+ ```
105
+
106
+ The fallback deliberately excludes daemon boot internals, migration helpers,
107
+ file fallback probes, and legacy monitoring/control-plane operations.
108
+
109
+ ## Service management
110
+
111
+ Linux user systemd is supported through a testable adapter:
112
+
113
+ ```bash
114
+ ours daemon install-service --dry-run --json
115
+ ours daemon install-service --yes
116
+ ours daemon uninstall-service --yes
117
+ ```
118
+
119
+ The adapter owns only `~/.config/systemd/user/ours.service` files carrying its
120
+ management marker. It refuses to overwrite or remove an unrelated unit. Other
121
+ platforms fail explicitly; use the platform's external service manager to run
122
+ `ours daemon serve`. Service installation is not emulated and never reports
123
+ success on an unsupported platform.
124
+
125
+ ## License
126
+
127
+ `@ours.network/cli` is licensed under the Functional Source License 1.1 with
128
+ the Apache License 2.0 future license (`FSL-1.1-Apache-2.0`). The complete
129
+ terms are included in the package as [LICENSE](LICENSE).
package/dist/args.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export declare class CliUsageError extends Error {
2
+ readonly exitCode = 2;
3
+ constructor(message: string);
4
+ }
5
+ export interface ParsedFlags {
6
+ positionals: string[];
7
+ values: Record<string, string>;
8
+ booleans: Set<string>;
9
+ }
10
+ export declare function parseFlags(argv: string[], valueFlags: ReadonlySet<string>, booleanFlags: ReadonlySet<string>): ParsedFlags;
11
+ export declare function parseInteger(value: string, flag: string, min?: number, max?: number): number;
12
+ export declare function parseBoolean(value: string, flag: string): boolean;
package/dist/args.js ADDED
@@ -0,0 +1,12 @@
1
+ import {
2
+ CliUsageError,
3
+ parseBoolean,
4
+ parseFlags,
5
+ parseInteger
6
+ } from "./chunk-AXXFFER2.js";
7
+ export {
8
+ CliUsageError,
9
+ parseBoolean,
10
+ parseFlags,
11
+ parseInteger
12
+ };
@@ -0,0 +1,181 @@
1
+ import {
2
+ attachClient,
3
+ defaultConfigPath,
4
+ resolveSelection
5
+ } from "./chunk-6W3RHW3C.js";
6
+
7
+ // src/lifecycle.ts
8
+ import { spawn as nodeSpawn } from "node:child_process";
9
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ var MANAGED_PID_FILE = "ours-cli-daemon.json";
12
+ var DAEMON_LOG_FILE = "ours-cli-daemon.log";
13
+ var depsDefault = {
14
+ fetch: globalThis.fetch,
15
+ spawn: nodeSpawn,
16
+ kill: (pid, signal) => process.kill(pid, signal),
17
+ now: Date.now,
18
+ delay: (ms) => new Promise((done) => setTimeout(done, ms)),
19
+ cliPath: process.argv[1]
20
+ };
21
+ var pidPath = (stateDir) => join(stateDir, MANAGED_PID_FILE);
22
+ function readManagedRecord(stateDir) {
23
+ try {
24
+ const value = JSON.parse(readFileSync(pidPath(stateDir), "utf8"));
25
+ if (value.version !== 1 || value.owner !== "@ours.network/cli" || !Number.isInteger(value.pid) || Number(value.pid) <= 1) return null;
26
+ if (!Number.isInteger(value.port) || typeof value.stateDir !== "string" || typeof value.startedAt !== "string") return null;
27
+ return value;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+ function writeManagedRecord(stateDir, port) {
33
+ mkdirSync(stateDir, { recursive: true });
34
+ const path = pidPath(stateDir);
35
+ const tmp = `${path}.${process.pid}.tmp`;
36
+ const record = {
37
+ version: 1,
38
+ owner: "@ours.network/cli",
39
+ pid: process.pid,
40
+ port,
41
+ stateDir: resolve(stateDir),
42
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
43
+ };
44
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}
45
+ `, { mode: 384 });
46
+ renameSync(tmp, path);
47
+ return path;
48
+ }
49
+ async function inspectDaemon(config, deps = depsDefault) {
50
+ const stateDir = resolve(config.expectStateDir);
51
+ const record = readManagedRecord(stateDir);
52
+ const base = { stateDir, pidFile: pidPath(stateDir), stalePidFile: record !== null };
53
+ let client;
54
+ try {
55
+ client = await attachClient(config, { fetch: deps.fetch, leaseToken: "ours-cli-status" });
56
+ } catch (error) {
57
+ if (error instanceof Error && error.name === "DaemonSelectionError" && /could not be reached/.test(error.message)) {
58
+ return { state: "stopped", managed: false, info: null, ...base };
59
+ }
60
+ throw error;
61
+ }
62
+ let info;
63
+ try {
64
+ info = await client.version();
65
+ } catch (error) {
66
+ if (error instanceof TypeError || /fetch failed|ECONNREFUSED|socket|connect/i.test(String(error))) {
67
+ return { state: "stopped", managed: false, info: null, ...base };
68
+ }
69
+ throw error;
70
+ }
71
+ if (info.name !== "ours" || !Number.isInteger(info.pid) || info.pid <= 1 || resolve(info.stateDir) !== stateDir) {
72
+ throw new Error("the selected endpoint did not return a valid ours daemon identity");
73
+ }
74
+ const selectedUrl = new URL(config.baseUrl.value);
75
+ const selectedPort = Number(selectedUrl.port || (selectedUrl.protocol === "https:" ? 443 : 80));
76
+ const managed = record !== null && record.pid === info.pid && resolve(record.stateDir) === stateDir && record.port === selectedPort;
77
+ return { state: "running", managed, info, ...base, stalePidFile: record !== null && !managed };
78
+ }
79
+ function childEnvironment(selection) {
80
+ const env = { ...process.env };
81
+ const configFile = selection.configPath ?? defaultConfigPath();
82
+ if (selection.configPath !== void 0 || existsSync(configFile)) env.OURS_CONFIG = resolve(configFile);
83
+ if (selection.port !== void 0) env.OURS_PORT = String(selection.port);
84
+ if (selection.stateDir !== void 0) env.OURS_STATE_DIR = resolve(selection.stateDir);
85
+ return env;
86
+ }
87
+ async function serveDaemon(selection, managed = false) {
88
+ if (selection.endpoint !== void 0) throw new Error("--endpoint selects a running daemon and cannot be used with daemon serve");
89
+ Object.assign(process.env, childEnvironment(selection));
90
+ const { startDaemon } = await import("@ours.network/sdk/daemon");
91
+ const handle = await startDaemon();
92
+ if (managed) {
93
+ const response = await fetch(`http://127.0.0.1:${handle.port}/state-dir`);
94
+ const identity = await response.json();
95
+ if (!response.ok || typeof identity.stateDir !== "string") {
96
+ await handle.close();
97
+ throw new Error("started daemon did not report its state directory; refusing to create a managed PID record");
98
+ }
99
+ writeManagedRecord(identity.stateDir, handle.port);
100
+ }
101
+ return await new Promise(() => void 0);
102
+ }
103
+ async function startDaemonManaged(selection, deps = depsDefault) {
104
+ if (selection.endpoint !== void 0) throw new Error("--endpoint selects an external daemon and cannot be used with daemon start");
105
+ const config = await resolveSelection(selection);
106
+ const before = await inspectDaemon(config, deps);
107
+ if (before.state === "running") return before;
108
+ mkdirSync(config.stateDir.value, { recursive: true });
109
+ const logPath = join(config.stateDir.value, DAEMON_LOG_FILE);
110
+ const logFd = openSync(logPath, "a", 384);
111
+ let child;
112
+ try {
113
+ child = deps.spawn(process.execPath, [deps.cliPath, "daemon", "serve", "--managed"], {
114
+ detached: true,
115
+ env: childEnvironment(selection),
116
+ stdio: ["ignore", logFd, logFd],
117
+ windowsHide: true
118
+ });
119
+ } finally {
120
+ closeSync(logFd);
121
+ }
122
+ if (!child.pid || child.pid <= 1) throw new Error(`daemon start failed; see ${logPath}`);
123
+ child.unref();
124
+ const deadline = deps.now() + 2e4;
125
+ while (deps.now() < deadline) {
126
+ await deps.delay(100);
127
+ const status = await inspectDaemon(config, deps);
128
+ if (status.state === "running" && status.managed && status.info?.pid === child.pid) return status;
129
+ try {
130
+ deps.kill(child.pid, 0);
131
+ } catch {
132
+ throw new Error(`daemon exited before becoming ready; see ${logPath}`);
133
+ }
134
+ }
135
+ try {
136
+ deps.kill(child.pid, "SIGTERM");
137
+ } catch {
138
+ }
139
+ throw new Error(`daemon did not become ready within 20 seconds and was stopped; see ${logPath}`);
140
+ }
141
+ function removeOwnedPidFile(status) {
142
+ const record = readManagedRecord(status.stateDir);
143
+ if (record?.owner === "@ours.network/cli") {
144
+ try {
145
+ unlinkSync(status.pidFile);
146
+ } catch {
147
+ }
148
+ }
149
+ }
150
+ async function stopDaemonManaged(config, deps = depsDefault) {
151
+ const status = await inspectDaemon(config, deps);
152
+ if (status.state === "stopped") {
153
+ removeOwnedPidFile(status);
154
+ return status;
155
+ }
156
+ if (!status.managed || !status.info) {
157
+ throw new Error("the daemon is running but was not started by @ours.network/cli; refusing to signal an external shared daemon");
158
+ }
159
+ deps.kill(status.info.pid, "SIGTERM");
160
+ const deadline = deps.now() + 1e4;
161
+ while (deps.now() < deadline) {
162
+ await deps.delay(100);
163
+ const current = await inspectDaemon(config, deps);
164
+ if (current.state === "stopped") {
165
+ removeOwnedPidFile(current);
166
+ return { ...current, stalePidFile: false };
167
+ }
168
+ }
169
+ throw new Error(`daemon pid ${status.info.pid} did not stop within 10 seconds`);
170
+ }
171
+
172
+ export {
173
+ MANAGED_PID_FILE,
174
+ DAEMON_LOG_FILE,
175
+ readManagedRecord,
176
+ writeManagedRecord,
177
+ inspectDaemon,
178
+ serveDaemon,
179
+ startDaemonManaged,
180
+ stopDaemonManaged
181
+ };
@@ -0,0 +1,78 @@
1
+ // src/service.ts
2
+ import { execFile as nodeExecFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ var execFile = promisify(nodeExecFile);
8
+ var MARKER = "# Managed by @ours.network/cli";
9
+ var SERVICE_NAME = "ours.service";
10
+ var defaultDeps = {
11
+ platform: process.platform,
12
+ run: async (command, args) => {
13
+ await execFile(command, args, { windowsHide: true });
14
+ }
15
+ };
16
+ function systemdEscapeArgument(value) {
17
+ if (/[\n\r]/.test(value)) throw new Error("service arguments must not contain newlines");
18
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
19
+ }
20
+ function createLinuxUserSystemdAdapter(deps = defaultDeps) {
21
+ if (deps.platform !== "linux") throw new Error(`service management is not supported on ${deps.platform}; use an external launcher for \`ours daemon serve\``);
22
+ return {
23
+ name: "systemd-user",
24
+ async install(context) {
25
+ const home = context.homeDir ?? homedir();
26
+ const serviceFile = join(home, ".config", "systemd", "user", SERVICE_NAME);
27
+ const args = [process.execPath, resolve(context.cliPath), "daemon", "serve"];
28
+ if (context.configPath) args.push("--config", resolve(context.configPath));
29
+ const unit = `${MARKER}
30
+ [Unit]
31
+ Description=ours shared daemon
32
+ After=network-online.target
33
+
34
+ [Service]
35
+ Type=simple
36
+ ExecStart=${args.map(systemdEscapeArgument).join(" ")}
37
+ Restart=on-failure
38
+ RestartSec=2
39
+
40
+ [Install]
41
+ WantedBy=default.target
42
+ `;
43
+ if (existsSync(serviceFile)) {
44
+ const previous = readFileSync(serviceFile, "utf8");
45
+ if (!previous.startsWith(MARKER) && !context.force) {
46
+ throw new Error(`${serviceFile} is not managed by @ours.network/cli; refusing to overwrite it without --force`);
47
+ }
48
+ if (previous === unit) return { adapter: this.name, action: "install", serviceFile, commands: [], changed: false, dryRun: context.dryRun === true };
49
+ }
50
+ const commands = [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", SERVICE_NAME]];
51
+ if (!context.dryRun) {
52
+ mkdirSync(dirname(serviceFile), { recursive: true });
53
+ writeFileSync(serviceFile, unit, { mode: 420 });
54
+ for (const [command, ...args2] of commands) await deps.run(command, args2);
55
+ }
56
+ return { adapter: this.name, action: "install", serviceFile, commands, changed: true, dryRun: context.dryRun === true };
57
+ },
58
+ async uninstall(context) {
59
+ const home = context.homeDir ?? homedir();
60
+ const serviceFile = join(home, ".config", "systemd", "user", SERVICE_NAME);
61
+ if (!existsSync(serviceFile)) return { adapter: this.name, action: "uninstall", serviceFile, commands: [], changed: false, dryRun: context.dryRun === true };
62
+ const previous = readFileSync(serviceFile, "utf8");
63
+ if (!previous.startsWith(MARKER)) throw new Error(`${serviceFile} is not managed by @ours.network/cli; refusing to remove it`);
64
+ const commands = [["systemctl", "--user", "disable", "--now", SERVICE_NAME], ["systemctl", "--user", "daemon-reload"]];
65
+ if (!context.dryRun) {
66
+ await deps.run(commands[0][0], commands[0].slice(1));
67
+ unlinkSync(serviceFile);
68
+ await deps.run(commands[1][0], commands[1].slice(1));
69
+ }
70
+ return { adapter: this.name, action: "uninstall", serviceFile, commands, changed: true, dryRun: context.dryRun === true };
71
+ }
72
+ };
73
+ }
74
+
75
+ export {
76
+ SERVICE_NAME,
77
+ createLinuxUserSystemdAdapter
78
+ };
@@ -0,0 +1,43 @@
1
+ // src/connection.ts
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { randomBytes } from "node:crypto";
6
+ import {
7
+ OursClient,
8
+ assertDaemonStateDir,
9
+ describeDaemonConfig,
10
+ resolveDaemonConfig
11
+ } from "@ours.network/sdk/client";
12
+ function defaultConfigPath() {
13
+ return process.env.OURS_CONFIG ?? join(homedir(), ".ours", "config.json");
14
+ }
15
+ function resolveSelection(options = {}) {
16
+ const fallbackConfig = defaultConfigPath();
17
+ const configPath = options.configPath ?? (process.env.OURS_CONFIG === void 0 && existsSync(fallbackConfig) ? fallbackConfig : void 0);
18
+ return resolveDaemonConfig({
19
+ endpoint: options.endpoint,
20
+ port: options.port,
21
+ stateDir: options.stateDir,
22
+ configPath
23
+ });
24
+ }
25
+ function redactedSelection(config) {
26
+ return describeDaemonConfig(config);
27
+ }
28
+ async function attachClient(config, opts = {}) {
29
+ await assertDaemonStateDir(config, { fetch: opts.fetch });
30
+ return new OursClient({
31
+ url: config.baseUrl.value,
32
+ leaseToken: opts.leaseToken ?? randomBytes(32).toString("hex"),
33
+ apiToken: config.token?.value,
34
+ fetch: opts.fetch
35
+ });
36
+ }
37
+
38
+ export {
39
+ defaultConfigPath,
40
+ resolveSelection,
41
+ redactedSelection,
42
+ attachClient
43
+ };
@@ -0,0 +1,61 @@
1
+ // src/args.ts
2
+ var CliUsageError = class extends Error {
3
+ exitCode = 2;
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "CliUsageError";
7
+ }
8
+ };
9
+ function parseFlags(argv, valueFlags, booleanFlags) {
10
+ const positionals = [];
11
+ const values = {};
12
+ const booleans = /* @__PURE__ */ new Set();
13
+ let positionalOnly = false;
14
+ for (let i = 0; i < argv.length; i += 1) {
15
+ const token = argv[i];
16
+ if (positionalOnly) {
17
+ positionals.push(token);
18
+ continue;
19
+ }
20
+ if (token === "--") {
21
+ positionalOnly = true;
22
+ continue;
23
+ }
24
+ if (!token.startsWith("--")) {
25
+ positionals.push(token);
26
+ continue;
27
+ }
28
+ const equal = token.indexOf("=");
29
+ const name = equal < 0 ? token : token.slice(0, equal);
30
+ if (booleanFlags.has(name)) {
31
+ if (equal >= 0) throw new CliUsageError(`${name} does not take a value`);
32
+ if (booleans.has(name)) throw new CliUsageError(`${name} may be given only once`);
33
+ booleans.add(name);
34
+ continue;
35
+ }
36
+ if (!valueFlags.has(name)) throw new CliUsageError(`unknown option: ${name}`);
37
+ const value = equal >= 0 ? token.slice(equal + 1) : argv[++i];
38
+ if (value === void 0 || value === "") throw new CliUsageError(`${name} requires a value`);
39
+ if (Object.prototype.hasOwnProperty.call(values, name)) throw new CliUsageError(`${name} may be given only once`);
40
+ values[name] = value;
41
+ }
42
+ return { positionals, values, booleans };
43
+ }
44
+ function parseInteger(value, flag, min = 0, max = Number.MAX_SAFE_INTEGER) {
45
+ if (!/^[0-9]+$/.test(value)) throw new CliUsageError(`${flag} must be an integer`);
46
+ const parsed = Number(value);
47
+ if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) throw new CliUsageError(`${flag} must be between ${min} and ${max}`);
48
+ return parsed;
49
+ }
50
+ function parseBoolean(value, flag) {
51
+ if (value === "true" || value === "1") return true;
52
+ if (value === "false" || value === "0") return false;
53
+ throw new CliUsageError(`${flag} must be true or false`);
54
+ }
55
+
56
+ export {
57
+ CliUsageError,
58
+ parseFlags,
59
+ parseInteger,
60
+ parseBoolean
61
+ };
@@ -0,0 +1,66 @@
1
+ import {
2
+ defaultConfigPath,
3
+ redactedSelection,
4
+ resolveSelection
5
+ } from "./chunk-6W3RHW3C.js";
6
+
7
+ // src/config-command.ts
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { dirname, resolve } from "node:path";
10
+ var SAFE_KEYS = /* @__PURE__ */ new Set(["brokerUrl", "port", "stateDir", "gcIntervalMs", "autoStart", "apiVisibility"]);
11
+ async function showConfig(selection) {
12
+ const resolved = await resolveSelection(selection);
13
+ let stored = {};
14
+ const path = selection.configPath ?? defaultConfigPath();
15
+ if (existsSync(path)) {
16
+ try {
17
+ stored = JSON.parse(readFileSync(path, "utf8"));
18
+ } catch {
19
+ stored = { invalid: true };
20
+ }
21
+ }
22
+ return {
23
+ path,
24
+ selection: await redactedSelection(resolved),
25
+ daemon: Object.fromEntries(Object.entries(stored).filter(([key]) => SAFE_KEYS.has(key))),
26
+ apiTokenConfigured: typeof stored.apiToken === "string" && stored.apiToken.length > 0,
27
+ sttConfigured: stored.stt !== void 0
28
+ };
29
+ }
30
+ function setupConfig(path, patch, dryRun = false) {
31
+ let stored = {};
32
+ if (existsSync(path)) {
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(readFileSync(path, "utf8"));
36
+ } catch (error) {
37
+ throw new Error(`refusing to overwrite malformed config ${path}: ${String(error)}`);
38
+ }
39
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`refusing to overwrite non-object config ${path}`);
40
+ stored = parsed;
41
+ }
42
+ for (const key of Object.keys(patch)) if (!SAFE_KEYS.has(key)) throw new Error(`unsafe config field: ${key}`);
43
+ const next = { ...stored, ...patch };
44
+ if (!dryRun) {
45
+ mkdirSync(dirname(path), { recursive: true });
46
+ const tmp = `${path}.${process.pid}.tmp`;
47
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}
48
+ `, { mode: 384 });
49
+ renameSync(tmp, path);
50
+ try {
51
+ chmodSync(path, 384);
52
+ } catch {
53
+ }
54
+ }
55
+ return {
56
+ path: resolve(path),
57
+ dryRun,
58
+ daemon: Object.fromEntries(Object.entries(next).filter(([key]) => SAFE_KEYS.has(key))),
59
+ preservedSecretFields: ["apiToken", "stt"].filter((key) => Object.prototype.hasOwnProperty.call(next, key))
60
+ };
61
+ }
62
+
63
+ export {
64
+ showConfig,
65
+ setupConfig
66
+ };