@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 +98 -0
- package/README.md +129 -0
- package/dist/args.d.ts +12 -0
- package/dist/args.js +12 -0
- package/dist/chunk-4IFFMMQO.js +181 -0
- package/dist/chunk-67BC2E5U.js +78 -0
- package/dist/chunk-6W3RHW3C.js +43 -0
- package/dist/chunk-AXXFFER2.js +61 -0
- package/dist/chunk-G2GHHOYX.js +66 -0
- package/dist/chunk-IUZOQT4D.js +152 -0
- package/dist/chunk-YC7QBGVC.js +31 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +29 -0
- package/dist/config-command.d.ts +3 -0
- package/dist/config-command.js +9 -0
- package/dist/connection.d.ts +14 -0
- package/dist/connection.js +12 -0
- package/dist/lifecycle.d.ts +36 -0
- package/dist/lifecycle.js +21 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +250 -0
- package/dist/operations.d.ts +267 -0
- package/dist/operations.js +19 -0
- package/dist/output.d.ts +7 -0
- package/dist/output.js +10 -0
- package/dist/service.d.ts +26 -0
- package/dist/service.js +8 -0
- package/package.json +28 -0
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,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
|
+
};
|