@ours.network/cli 0.5.0 → 1.0.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/README.md +13 -13
- package/dist/boot-35BOHNSZ.js +9 -0
- package/dist/{chunk-6W3RHW3C.js → chunk-6K2JBKHI.js} +5 -6
- package/dist/{chunk-G2GHHOYX.js → chunk-DRQEV4SN.js} +2 -2
- package/dist/chunk-FXKFSNKS.js +166 -0
- package/dist/chunk-MHVYJY3H.js +2530 -0
- package/dist/{chunk-4IFFMMQO.js → chunk-QZIL3IT7.js} +3 -3
- package/dist/{chunk-YSHMCQ2B.js → chunk-SDNTPCKW.js} +3 -3
- package/dist/chunk-TNNBVUKK.js +241 -0
- package/dist/{chunk-3RLAHOPS.js → chunk-UZQWSOUB.js} +15 -1
- package/dist/{chunk-VGWQD7CJ.js → chunk-ZFE47KH3.js} +1 -2
- package/dist/commands.d.ts +3 -3
- package/dist/commands.js +1 -1
- package/dist/config-command.js +2 -2
- package/dist/connection.js +1 -1
- package/dist/daemon-UUG6P6GK.js +250 -0
- package/dist/help.js +2 -2
- package/dist/lifecycle.js +2 -2
- package/dist/main.js +11 -13
- package/dist/server-TDJBEWTU.js +2077 -0
- package/dist/service-instance.d.ts +3 -0
- package/dist/service-instance.js +7 -1
- package/dist/service.d.ts +30 -0
- package/dist/service.js +10 -4
- package/package.json +4 -2
- package/dist/chunk-227DBTDK.js +0 -132
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
attachClient,
|
|
3
3
|
defaultConfigPath,
|
|
4
4
|
resolveSelection
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-6K2JBKHI.js";
|
|
6
6
|
|
|
7
7
|
// src/lifecycle.ts
|
|
8
8
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
@@ -54,7 +54,7 @@ async function inspectDaemon(config, deps = depsDefault) {
|
|
|
54
54
|
try {
|
|
55
55
|
client = await attachClient(config, { fetch: deps.fetch, leaseToken: "ours-cli-status" });
|
|
56
56
|
} catch (error) {
|
|
57
|
-
if (error instanceof Error && error.name === "
|
|
57
|
+
if (error instanceof Error && error.name === "DaemonUnavailableError") {
|
|
58
58
|
return { state: "stopped", managed: false, info: null, ...base };
|
|
59
59
|
}
|
|
60
60
|
throw error;
|
|
@@ -87,7 +87,7 @@ function childEnvironment(selection) {
|
|
|
87
87
|
async function serveDaemon(selection, managed = false) {
|
|
88
88
|
if (selection.endpoint !== void 0) throw new Error("--endpoint selects a running daemon and cannot be used with daemon serve");
|
|
89
89
|
Object.assign(process.env, childEnvironment(selection));
|
|
90
|
-
const { startDaemon } = await import("
|
|
90
|
+
const { startDaemon } = await import("./daemon-UUG6P6GK.js");
|
|
91
91
|
const handle = await startDaemon();
|
|
92
92
|
if (managed) {
|
|
93
93
|
const response = await fetch(`http://127.0.0.1:${handle.port}/state-dir`);
|
|
@@ -42,14 +42,14 @@ var COMMAND_GROUPS = {
|
|
|
42
42
|
"install-service": {
|
|
43
43
|
kind: "daemon",
|
|
44
44
|
handler: "install-service",
|
|
45
|
-
summary: "Install and enable the Linux
|
|
46
|
-
effects: "Changes ~/.config/systemd/user/ours.service and runs systemctl --user. Requires --yes unless --dry-run is used; --force may replace only an existing unowned unit.",
|
|
45
|
+
summary: "Install and enable the per-user boot service (Linux systemd, macOS launchd).",
|
|
46
|
+
effects: "Changes ~/.config/systemd/user/ours.service and runs systemctl --user on Linux, or ~/Library/LaunchAgents and launchctl on macOS. Requires --yes unless --dry-run is used; --force may replace only an existing unowned unit.",
|
|
47
47
|
example: "ours daemon install-service --dry-run --json"
|
|
48
48
|
},
|
|
49
49
|
"uninstall-service": {
|
|
50
50
|
kind: "daemon",
|
|
51
51
|
handler: "uninstall-service",
|
|
52
|
-
summary: "Disable and remove the
|
|
52
|
+
summary: "Disable and remove the per-user boot service owned by this CLI.",
|
|
53
53
|
effects: "Changes the user service manager. Requires --yes unless --dry-run is used and refuses to remove an unowned unit.",
|
|
54
54
|
example: "ours daemon uninstall-service --dry-run --json"
|
|
55
55
|
},
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
instanceNameFromStateDir,
|
|
3
|
+
labelForStateDir,
|
|
4
|
+
serviceDescription,
|
|
5
|
+
unitNameForStateDir
|
|
6
|
+
} from "./chunk-UZQWSOUB.js";
|
|
7
|
+
|
|
8
|
+
// src/service.ts
|
|
9
|
+
import { execFile as nodeExecFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
var execFile = promisify(nodeExecFile);
|
|
15
|
+
var MARKER = "# Managed by @ours.network/cli";
|
|
16
|
+
var PLIST_MARKER = "<!-- Managed by @ours.network/cli -->";
|
|
17
|
+
var SERVICE_NAME = "ours.service";
|
|
18
|
+
var defaultDeps = {
|
|
19
|
+
platform: process.platform,
|
|
20
|
+
run: async (command, args) => {
|
|
21
|
+
await execFile(command, args, { windowsHide: true });
|
|
22
|
+
},
|
|
23
|
+
uid: process.getuid?.()
|
|
24
|
+
};
|
|
25
|
+
function systemdEscapeArgument(value) {
|
|
26
|
+
if (/[\n\r]/.test(value)) throw new Error("service arguments must not contain newlines");
|
|
27
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
28
|
+
}
|
|
29
|
+
function systemdUnescapeArgument(value) {
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
if (!trimmed.startsWith('"') || !trimmed.endsWith('"') || trimmed.length < 2) return void 0;
|
|
32
|
+
return trimmed.slice(1, -1).replaceAll("%%", "%").replaceAll('\\"', '"').replaceAll("\\\\", "\\");
|
|
33
|
+
}
|
|
34
|
+
function bakedStateDir(unit) {
|
|
35
|
+
for (const line of unit.split("\n")) {
|
|
36
|
+
if (!line.startsWith("Environment=OURS_STATE_DIR=")) continue;
|
|
37
|
+
return systemdUnescapeArgument(line.slice("Environment=OURS_STATE_DIR=".length));
|
|
38
|
+
}
|
|
39
|
+
return void 0;
|
|
40
|
+
}
|
|
41
|
+
function describeConflict(existing, serviceFile, stateDir, verb = "overwrite") {
|
|
42
|
+
if (!existing.managed) {
|
|
43
|
+
return {
|
|
44
|
+
kind: "unmanaged",
|
|
45
|
+
serviceFile,
|
|
46
|
+
message: verb === "remove" ? `${serviceFile} is not managed by @ours.network/cli; refusing to remove it` : `${serviceFile} is not managed by @ours.network/cli; refusing to overwrite it without --force`
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const owner = existing.owner;
|
|
50
|
+
if (owner !== void 0 && owner !== stateDir) {
|
|
51
|
+
return {
|
|
52
|
+
kind: "other-state-dir",
|
|
53
|
+
serviceFile,
|
|
54
|
+
owner,
|
|
55
|
+
message: `${serviceFile} is the boot service for the daemon at ${owner}, not ${stateDir}; refusing to ${verb} it without --force`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return void 0;
|
|
59
|
+
}
|
|
60
|
+
function resolveStateDir(context) {
|
|
61
|
+
return resolve(context.stateDir ?? join(context.homeDir ?? homedir(), ".ours"));
|
|
62
|
+
}
|
|
63
|
+
function readSystemdUnit(previous) {
|
|
64
|
+
return { managed: previous.startsWith(MARKER), owner: bakedStateDir(previous) };
|
|
65
|
+
}
|
|
66
|
+
function createLinuxUserSystemdAdapter(deps = defaultDeps) {
|
|
67
|
+
if (deps.platform !== "linux") throw new Error(`service management is not supported on ${deps.platform}; use an external launcher for \`ours daemon serve\``);
|
|
68
|
+
return {
|
|
69
|
+
name: "systemd-user",
|
|
70
|
+
async install(context) {
|
|
71
|
+
const home = context.homeDir ?? homedir();
|
|
72
|
+
const stateDir = resolveStateDir(context);
|
|
73
|
+
const unitName = unitNameForStateDir(stateDir);
|
|
74
|
+
const serviceFile = join(home, ".config", "systemd", "user", unitName);
|
|
75
|
+
const unitConfig = context.configPath ? resolve(context.configPath) : void 0;
|
|
76
|
+
const args = [process.execPath, resolve(context.cliPath), "daemon", "serve"];
|
|
77
|
+
if (unitConfig) args.push("--config", unitConfig);
|
|
78
|
+
const environment = `Environment=OURS_STATE_DIR=${systemdEscapeArgument(stateDir)}
|
|
79
|
+
`;
|
|
80
|
+
const unit = `${MARKER}
|
|
81
|
+
[Unit]
|
|
82
|
+
Description=${serviceDescription(instanceNameFromStateDir(stateDir).name)}
|
|
83
|
+
After=network-online.target
|
|
84
|
+
|
|
85
|
+
[Service]
|
|
86
|
+
Type=simple
|
|
87
|
+
ExecStart=${args.map(systemdEscapeArgument).join(" ")}
|
|
88
|
+
${environment}Restart=on-failure
|
|
89
|
+
RestartSec=2
|
|
90
|
+
|
|
91
|
+
[Install]
|
|
92
|
+
WantedBy=default.target
|
|
93
|
+
`;
|
|
94
|
+
if (existsSync(serviceFile)) {
|
|
95
|
+
const previous = readFileSync(serviceFile, "utf8");
|
|
96
|
+
const conflict = describeConflict(readSystemdUnit(previous), serviceFile, stateDir);
|
|
97
|
+
if (conflict && !context.force) {
|
|
98
|
+
if (!context.dryRun) throw new Error(conflict.message);
|
|
99
|
+
return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands: [], changed: false, dryRun: true, conflict };
|
|
100
|
+
}
|
|
101
|
+
if (previous === unit) return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands: [], changed: false, dryRun: context.dryRun === true };
|
|
102
|
+
}
|
|
103
|
+
const commands = [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", unitName]];
|
|
104
|
+
if (!context.dryRun) {
|
|
105
|
+
mkdirSync(dirname(serviceFile), { recursive: true });
|
|
106
|
+
writeFileSync(serviceFile, unit, { mode: 420 });
|
|
107
|
+
for (const [command, ...args2] of commands) await deps.run(command, args2);
|
|
108
|
+
}
|
|
109
|
+
return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands, changed: true, dryRun: context.dryRun === true };
|
|
110
|
+
},
|
|
111
|
+
async uninstall(context) {
|
|
112
|
+
const home = context.homeDir ?? homedir();
|
|
113
|
+
const stateDir = resolveStateDir(context);
|
|
114
|
+
const unitName = unitNameForStateDir(stateDir);
|
|
115
|
+
const serviceFile = join(home, ".config", "systemd", "user", unitName);
|
|
116
|
+
if (!existsSync(serviceFile)) return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands: [], changed: false, dryRun: context.dryRun === true };
|
|
117
|
+
const previous = readFileSync(serviceFile, "utf8");
|
|
118
|
+
const existing = readSystemdUnit(previous);
|
|
119
|
+
const conflict = describeConflict(existing, serviceFile, stateDir, "remove");
|
|
120
|
+
if (conflict && !(context.force && existing.managed)) {
|
|
121
|
+
if (!context.dryRun) throw new Error(conflict.message);
|
|
122
|
+
return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands: [], changed: false, dryRun: true, conflict };
|
|
123
|
+
}
|
|
124
|
+
const commands = [["systemctl", "--user", "disable", "--now", unitName], ["systemctl", "--user", "daemon-reload"]];
|
|
125
|
+
if (!context.dryRun) {
|
|
126
|
+
await deps.run(commands[0][0], commands[0].slice(1));
|
|
127
|
+
unlinkSync(serviceFile);
|
|
128
|
+
await deps.run(commands[1][0], commands[1].slice(1));
|
|
129
|
+
}
|
|
130
|
+
return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands, changed: true, dryRun: context.dryRun === true };
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function xmlText(value) {
|
|
135
|
+
return String(value).replace(/[&<>"']/g, (character) => ({
|
|
136
|
+
"&": "&",
|
|
137
|
+
"<": "<",
|
|
138
|
+
">": ">",
|
|
139
|
+
'"': """,
|
|
140
|
+
"'": "'"
|
|
141
|
+
})[character]);
|
|
142
|
+
}
|
|
143
|
+
function xmlUntext(value) {
|
|
144
|
+
return value.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", '"').replaceAll("'", "'").replaceAll("&", "&");
|
|
145
|
+
}
|
|
146
|
+
function bakedStateDirFromPlist(plist) {
|
|
147
|
+
const match = /<key>OURS_STATE_DIR<\/key><string>([^<]*)<\/string>/.exec(plist);
|
|
148
|
+
return match ? xmlUntext(match[1]) : void 0;
|
|
149
|
+
}
|
|
150
|
+
function readLaunchdPlist(previous) {
|
|
151
|
+
const managed = previous.split("\n")[1] === PLIST_MARKER;
|
|
152
|
+
return { managed, owner: bakedStateDirFromPlist(previous) };
|
|
153
|
+
}
|
|
154
|
+
function createMacosUserLaunchdAdapter(deps = defaultDeps) {
|
|
155
|
+
if (deps.platform !== "darwin") throw new Error(`service management is not supported on ${deps.platform}; use an external launcher for \`ours daemon serve\``);
|
|
156
|
+
const domain = `gui/${deps.uid ?? process.getuid?.() ?? 0}`;
|
|
157
|
+
const plan = (context) => {
|
|
158
|
+
const home = context.homeDir ?? homedir();
|
|
159
|
+
const stateDir = resolveStateDir(context);
|
|
160
|
+
const unitName = labelForStateDir(stateDir);
|
|
161
|
+
return { stateDir, unitName, serviceFile: join(home, "Library", "LaunchAgents", `${unitName}.plist`) };
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
name: "launchd-user",
|
|
165
|
+
async install(context) {
|
|
166
|
+
const { stateDir, unitName, serviceFile } = plan(context);
|
|
167
|
+
const unitConfig = context.configPath ? resolve(context.configPath) : void 0;
|
|
168
|
+
const args = [process.execPath, resolve(context.cliPath), "daemon", "serve"];
|
|
169
|
+
if (unitConfig) args.push("--config", unitConfig);
|
|
170
|
+
const unit = `<?xml version="1.0" encoding="UTF-8"?>
|
|
171
|
+
${PLIST_MARKER}
|
|
172
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
173
|
+
<plist version="1.0">
|
|
174
|
+
<dict>
|
|
175
|
+
<key>Label</key><string>${xmlText(unitName)}</string>
|
|
176
|
+
<key>ProgramArguments</key>
|
|
177
|
+
<array>
|
|
178
|
+
${args.map((argument) => ` <string>${xmlText(argument)}</string>`).join("\n")}
|
|
179
|
+
</array>
|
|
180
|
+
<key>EnvironmentVariables</key>
|
|
181
|
+
<dict>
|
|
182
|
+
<key>OURS_STATE_DIR</key><string>${xmlText(stateDir)}</string>
|
|
183
|
+
</dict>
|
|
184
|
+
<key>RunAtLoad</key><true/>
|
|
185
|
+
<key>KeepAlive</key><true/>
|
|
186
|
+
</dict>
|
|
187
|
+
</plist>
|
|
188
|
+
`;
|
|
189
|
+
if (existsSync(serviceFile)) {
|
|
190
|
+
const previous = readFileSync(serviceFile, "utf8");
|
|
191
|
+
const conflict = describeConflict(readLaunchdPlist(previous), serviceFile, stateDir);
|
|
192
|
+
if (conflict && !context.force) {
|
|
193
|
+
if (!context.dryRun) throw new Error(conflict.message);
|
|
194
|
+
return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands: [], changed: false, dryRun: true, conflict };
|
|
195
|
+
}
|
|
196
|
+
if (previous === unit) return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands: [], changed: false, dryRun: context.dryRun === true };
|
|
197
|
+
}
|
|
198
|
+
const commands = [["launchctl", "bootout", domain, serviceFile], ["launchctl", "bootstrap", domain, serviceFile]];
|
|
199
|
+
if (!context.dryRun) {
|
|
200
|
+
mkdirSync(dirname(serviceFile), { recursive: true });
|
|
201
|
+
writeFileSync(serviceFile, unit, { mode: 420 });
|
|
202
|
+
await deps.run(commands[0][0], commands[0].slice(1)).catch(() => {
|
|
203
|
+
});
|
|
204
|
+
await deps.run(commands[1][0], commands[1].slice(1));
|
|
205
|
+
}
|
|
206
|
+
return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands, changed: true, dryRun: context.dryRun === true };
|
|
207
|
+
},
|
|
208
|
+
async uninstall(context) {
|
|
209
|
+
const { stateDir, unitName, serviceFile } = plan(context);
|
|
210
|
+
if (!existsSync(serviceFile)) return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands: [], changed: false, dryRun: context.dryRun === true };
|
|
211
|
+
const previous = readFileSync(serviceFile, "utf8");
|
|
212
|
+
const existing = readLaunchdPlist(previous);
|
|
213
|
+
const conflict = describeConflict(existing, serviceFile, stateDir, "remove");
|
|
214
|
+
if (conflict && !(context.force && existing.managed)) {
|
|
215
|
+
if (!context.dryRun) throw new Error(conflict.message);
|
|
216
|
+
return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands: [], changed: false, dryRun: true, conflict };
|
|
217
|
+
}
|
|
218
|
+
const commands = [["launchctl", "bootout", domain, serviceFile]];
|
|
219
|
+
if (!context.dryRun) {
|
|
220
|
+
await deps.run(commands[0][0], commands[0].slice(1)).catch(() => {
|
|
221
|
+
});
|
|
222
|
+
unlinkSync(serviceFile);
|
|
223
|
+
}
|
|
224
|
+
return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands, changed: true, dryRun: context.dryRun === true };
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function createServiceAdapter(deps = defaultDeps) {
|
|
229
|
+
if (deps.platform === "linux") return createLinuxUserSystemdAdapter(deps);
|
|
230
|
+
if (deps.platform === "darwin") return createMacosUserLaunchdAdapter(deps);
|
|
231
|
+
throw new Error(`service management is not supported on ${deps.platform}; use an external launcher for \`ours daemon serve\``);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export {
|
|
235
|
+
SERVICE_NAME,
|
|
236
|
+
bakedStateDir,
|
|
237
|
+
createLinuxUserSystemdAdapter,
|
|
238
|
+
bakedStateDirFromPlist,
|
|
239
|
+
createMacosUserLaunchdAdapter,
|
|
240
|
+
createServiceAdapter
|
|
241
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/service-instance.ts
|
|
2
2
|
import { basename, resolve } from "node:path";
|
|
3
3
|
var DEFAULT_SYSTEMD_UNIT = "ours.service";
|
|
4
|
+
var DEFAULT_LAUNCHD_LABEL = "solutions.adaptframework.ours";
|
|
4
5
|
var INSTANCE_RE = /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,30}[A-Za-z0-9])?$/;
|
|
5
6
|
function normalizeInstanceName(raw) {
|
|
6
7
|
const s = typeof raw === "string" ? raw.trim() : "";
|
|
@@ -22,6 +23,11 @@ function systemdUnitName(instance = "") {
|
|
|
22
23
|
if (!ok || !name) return DEFAULT_SYSTEMD_UNIT;
|
|
23
24
|
return `ours-${name}.service`;
|
|
24
25
|
}
|
|
26
|
+
function launchdLabel(instance = "") {
|
|
27
|
+
const { ok, name } = normalizeInstanceName(instance);
|
|
28
|
+
if (!ok || !name) return DEFAULT_LAUNCHD_LABEL;
|
|
29
|
+
return `${DEFAULT_LAUNCHD_LABEL}.${name}`;
|
|
30
|
+
}
|
|
25
31
|
function serviceDescription(instance = "") {
|
|
26
32
|
const { ok, name } = normalizeInstanceName(instance);
|
|
27
33
|
return ok && name ? `ours daemon (instance "${name}")` : "ours shared daemon";
|
|
@@ -46,12 +52,20 @@ function unitNameForStateDir(stateDir) {
|
|
|
46
52
|
if (!ok) throw new Error(reason);
|
|
47
53
|
return systemdUnitName(name);
|
|
48
54
|
}
|
|
55
|
+
function labelForStateDir(stateDir) {
|
|
56
|
+
const { ok, name, reason } = instanceNameFromStateDir(stateDir);
|
|
57
|
+
if (!ok) throw new Error(reason);
|
|
58
|
+
return launchdLabel(name);
|
|
59
|
+
}
|
|
49
60
|
|
|
50
61
|
export {
|
|
51
62
|
DEFAULT_SYSTEMD_UNIT,
|
|
63
|
+
DEFAULT_LAUNCHD_LABEL,
|
|
52
64
|
normalizeInstanceName,
|
|
53
65
|
systemdUnitName,
|
|
66
|
+
launchdLabel,
|
|
54
67
|
serviceDescription,
|
|
55
68
|
instanceNameFromStateDir,
|
|
56
|
-
unitNameForStateDir
|
|
69
|
+
unitNameForStateDir,
|
|
70
|
+
labelForStateDir
|
|
57
71
|
};
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
IDENTITY_PREBIND_EXCEPTIONS,
|
|
14
14
|
commandSpec,
|
|
15
15
|
isCommandGroup
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-SDNTPCKW.js";
|
|
17
17
|
|
|
18
18
|
// src/help.ts
|
|
19
19
|
var pad = (value, width = 32) => value.length >= width ? `${value} ` : value.padEnd(width);
|
|
@@ -126,7 +126,6 @@ function nativeConfigOptions(action) {
|
|
|
126
126
|
["--identity NAME", "Compatibility-only/ignored by config commands."],
|
|
127
127
|
["--broker-url URL", show ? "Compatibility-only/ignored by config show." : "Set the broker WebSocket URL."],
|
|
128
128
|
["--gc-interval-ms N", show ? "Compatibility-only/ignored by config show." : "Set a positive garbage-collection interval in milliseconds."],
|
|
129
|
-
["--auto-start true|false", show ? "Compatibility-only/ignored by config show." : "Set automatic daemon startup (1/0 are also accepted)."],
|
|
130
129
|
["--api-visibility MODE", show ? "Compatibility-only/ignored by config show." : "Set owner, shared, or open API visibility."],
|
|
131
130
|
["--dry-run", show ? "Compatibility-only/ignored by config show." : "Return the merged redacted result without writing the file."],
|
|
132
131
|
["--json", "Write one JSON value to stdout."],
|
package/dist/commands.d.ts
CHANGED
|
@@ -64,14 +64,14 @@ export declare const COMMAND_GROUPS: {
|
|
|
64
64
|
readonly 'install-service': {
|
|
65
65
|
readonly kind: "daemon";
|
|
66
66
|
readonly handler: "install-service";
|
|
67
|
-
readonly summary: "Install and enable the Linux
|
|
68
|
-
readonly effects: "Changes ~/.config/systemd/user/ours.service and runs systemctl --user. Requires --yes unless --dry-run is used; --force may replace only an existing unowned unit.";
|
|
67
|
+
readonly summary: "Install and enable the per-user boot service (Linux systemd, macOS launchd).";
|
|
68
|
+
readonly effects: "Changes ~/.config/systemd/user/ours.service and runs systemctl --user on Linux, or ~/Library/LaunchAgents and launchctl on macOS. Requires --yes unless --dry-run is used; --force may replace only an existing unowned unit.";
|
|
69
69
|
readonly example: "ours daemon install-service --dry-run --json";
|
|
70
70
|
};
|
|
71
71
|
readonly 'uninstall-service': {
|
|
72
72
|
readonly kind: "daemon";
|
|
73
73
|
readonly handler: "uninstall-service";
|
|
74
|
-
readonly summary: "Disable and remove the
|
|
74
|
+
readonly summary: "Disable and remove the per-user boot service owned by this CLI.";
|
|
75
75
|
readonly effects: "Changes the user service manager. Requires --yes unless --dry-run is used and refuses to remove an unowned unit.";
|
|
76
76
|
readonly example: "ours daemon uninstall-service --dry-run --json";
|
|
77
77
|
};
|
package/dist/commands.js
CHANGED
package/dist/config-command.js
CHANGED
package/dist/connection.js
CHANGED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertConfigNotDrifted,
|
|
3
|
+
loadConfig
|
|
4
|
+
} from "./chunk-FXKFSNKS.js";
|
|
5
|
+
|
|
6
|
+
// ../../src/internal/daemon.ts
|
|
7
|
+
import * as fs2 from "node:fs";
|
|
8
|
+
|
|
9
|
+
// ../../src/internal/state-root-lock.ts
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as net from "node:net";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
var SUPPORTED_PLATFORMS = /* @__PURE__ */ new Set(["linux", "darwin"]);
|
|
15
|
+
var MAX_SOCKET_PATH_BYTES = 103;
|
|
16
|
+
var MAX_ACQUIRE_ATTEMPTS = 8;
|
|
17
|
+
var StateRootLockedError = class extends Error {
|
|
18
|
+
constructor(stateDir, ownerPid, owner) {
|
|
19
|
+
super(
|
|
20
|
+
`ours daemon state directory ${JSON.stringify(stateDir)} is already owned by ` + (ownerPid === null ? "another live process" : `pid ${ownerPid}`) + ". Stop that daemon through the ours CLI before starting another one for the same state directory."
|
|
21
|
+
);
|
|
22
|
+
this.stateDir = stateDir;
|
|
23
|
+
this.ownerPid = ownerPid;
|
|
24
|
+
this.owner = owner;
|
|
25
|
+
this.name = "StateRootLockedError";
|
|
26
|
+
}
|
|
27
|
+
code = "STATE_ROOT_LOCKED";
|
|
28
|
+
};
|
|
29
|
+
function fsCode(error) {
|
|
30
|
+
return error && typeof error === "object" && "code" in error ? String(error.code) : void 0;
|
|
31
|
+
}
|
|
32
|
+
function canonicalStateDir(input) {
|
|
33
|
+
let cursor = resolve(input);
|
|
34
|
+
const missing = [];
|
|
35
|
+
while (true) {
|
|
36
|
+
try {
|
|
37
|
+
const canonicalPrefix = fs.realpathSync.native(cursor);
|
|
38
|
+
return resolve(canonicalPrefix, ...missing.reverse());
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (fsCode(error) !== "ENOENT") throw error;
|
|
41
|
+
const parent = dirname(cursor);
|
|
42
|
+
if (parent === cursor) throw error;
|
|
43
|
+
missing.push(cursor.slice(parent.length + (parent.endsWith("/") ? 0 : 1)));
|
|
44
|
+
cursor = parent;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function ensurePrivateDirectory(path, uid) {
|
|
49
|
+
try {
|
|
50
|
+
fs.mkdirSync(path, { mode: 448 });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (fsCode(error) !== "EEXIST") throw error;
|
|
53
|
+
}
|
|
54
|
+
const stat = fs.lstatSync(path);
|
|
55
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
56
|
+
throw new Error(`daemon lock path ${JSON.stringify(path)} must be a real directory, not a symlink or file`);
|
|
57
|
+
}
|
|
58
|
+
if (stat.uid !== uid) {
|
|
59
|
+
throw new Error(`daemon lock path ${JSON.stringify(path)} is owned by uid ${stat.uid}, expected uid ${uid}`);
|
|
60
|
+
}
|
|
61
|
+
fs.chmodSync(path, 448);
|
|
62
|
+
}
|
|
63
|
+
function stateRootLockPath(stateDir) {
|
|
64
|
+
if (!SUPPORTED_PLATFORMS.has(process.platform)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`the ours daemon state-root lock supports Linux and macOS only; platform ${JSON.stringify(process.platform)} has no verified Unix-domain-socket lock contract, so startup is refused`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const getuid = process.getuid;
|
|
70
|
+
if (typeof getuid !== "function") {
|
|
71
|
+
throw new Error("the ours daemon state-root lock requires a Unix uid; startup is refused");
|
|
72
|
+
}
|
|
73
|
+
const uid = getuid.call(process);
|
|
74
|
+
const base = `/tmp/ours-${uid}`;
|
|
75
|
+
const directory = join(base, "locks");
|
|
76
|
+
ensurePrivateDirectory(base, uid);
|
|
77
|
+
ensurePrivateDirectory(directory, uid);
|
|
78
|
+
const key = createHash("sha256").update(stateDir).digest("hex");
|
|
79
|
+
const socketPath = join(directory, `${key}.sock`);
|
|
80
|
+
if (Buffer.byteLength(socketPath) > MAX_SOCKET_PATH_BYTES) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`daemon lock socket path is ${Buffer.byteLength(socketPath)} bytes, exceeding the portable ${MAX_SOCKET_PATH_BYTES}-byte Linux/macOS contract: ${JSON.stringify(socketPath)}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return { directory, socketPath };
|
|
86
|
+
}
|
|
87
|
+
function probeSocket(socketPath) {
|
|
88
|
+
return new Promise((resolveProbe) => {
|
|
89
|
+
const socket = net.createConnection(socketPath);
|
|
90
|
+
let data = "";
|
|
91
|
+
let settled = false;
|
|
92
|
+
const finish = (result) => {
|
|
93
|
+
if (settled) return;
|
|
94
|
+
settled = true;
|
|
95
|
+
socket.destroy();
|
|
96
|
+
resolveProbe(result);
|
|
97
|
+
};
|
|
98
|
+
socket.setEncoding("utf8");
|
|
99
|
+
socket.setTimeout(1e3);
|
|
100
|
+
socket.on("data", (chunk) => {
|
|
101
|
+
data += chunk;
|
|
102
|
+
});
|
|
103
|
+
socket.once("end", () => {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(data);
|
|
106
|
+
const owner = Number.isInteger(parsed.pid) && typeof parsed.stateDir === "string" && typeof parsed.startedAt === "string" ? parsed : null;
|
|
107
|
+
finish({ kind: "live", owner });
|
|
108
|
+
} catch {
|
|
109
|
+
finish({ kind: "live", owner: null });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
socket.once("timeout", () => finish({ kind: "ambiguous", error: new Error("timed out probing the live lock socket") }));
|
|
113
|
+
socket.once("error", (error) => {
|
|
114
|
+
const code = fsCode(error);
|
|
115
|
+
if (code === "ECONNREFUSED" || code === "ENOENT") finish({ kind: "stale" });
|
|
116
|
+
else finish({ kind: "ambiguous", error });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function listen(server, socketPath) {
|
|
121
|
+
return new Promise((resolveListen, reject) => {
|
|
122
|
+
const onError = (error) => reject(error);
|
|
123
|
+
server.once("error", onError);
|
|
124
|
+
server.listen(socketPath, () => {
|
|
125
|
+
server.off("error", onError);
|
|
126
|
+
resolveListen();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function acquireStateRootLock(inputStateDir) {
|
|
131
|
+
const stateDir = canonicalStateDir(inputStateDir);
|
|
132
|
+
const { socketPath } = stateRootLockPath(stateDir);
|
|
133
|
+
const owner = { pid: process.pid, stateDir, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
134
|
+
for (let attempt = 1; attempt <= MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
135
|
+
const server = net.createServer((socket) => socket.end(`${JSON.stringify(owner)}
|
|
136
|
+
`));
|
|
137
|
+
try {
|
|
138
|
+
await listen(server, socketPath);
|
|
139
|
+
server.unref();
|
|
140
|
+
let closed = false;
|
|
141
|
+
return {
|
|
142
|
+
stateDir,
|
|
143
|
+
socketPath,
|
|
144
|
+
owner,
|
|
145
|
+
close: async () => {
|
|
146
|
+
if (closed) return;
|
|
147
|
+
closed = true;
|
|
148
|
+
await new Promise((resolveClose) => server.close(() => resolveClose()));
|
|
149
|
+
try {
|
|
150
|
+
fs.unlinkSync(socketPath);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (fsCode(error) !== "ENOENT") throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
} catch (error) {
|
|
157
|
+
try {
|
|
158
|
+
server.close();
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
if (fsCode(error) !== "EADDRINUSE") throw error;
|
|
162
|
+
const probe = await probeSocket(socketPath);
|
|
163
|
+
if (probe.kind === "live") {
|
|
164
|
+
throw new StateRootLockedError(stateDir, probe.owner?.pid ?? null, probe.owner);
|
|
165
|
+
}
|
|
166
|
+
if (probe.kind === "ambiguous") {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`could not prove whether daemon lock ${JSON.stringify(socketPath)} is live or stale; refusing to remove it: ${probe.error.message}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
fs.unlinkSync(socketPath);
|
|
173
|
+
} catch (unlinkError) {
|
|
174
|
+
if (fsCode(unlinkError) !== "ENOENT") throw unlinkError;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`could not acquire daemon state-root lock for ${JSON.stringify(stateDir)} after ${MAX_ACQUIRE_ATTEMPTS} races`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ../../src/internal/daemon.ts
|
|
182
|
+
var ENTRY_CONFIG = loadConfig();
|
|
183
|
+
var heldLock = null;
|
|
184
|
+
async function ensureLock() {
|
|
185
|
+
assertConfigNotDrifted();
|
|
186
|
+
if (heldLock) return { lock: heldLock, acquired: false };
|
|
187
|
+
const lock = await acquireStateRootLock(ENTRY_CONFIG.stateDir);
|
|
188
|
+
fs2.mkdirSync(lock.stateDir, { recursive: true, mode: 448 });
|
|
189
|
+
const actual = fs2.realpathSync.native(lock.stateDir);
|
|
190
|
+
if (actual !== lock.stateDir) {
|
|
191
|
+
await lock.close();
|
|
192
|
+
throw new Error(
|
|
193
|
+
`state directory canonical path changed during lock acquisition: locked ${JSON.stringify(lock.stateDir)}, created ${JSON.stringify(actual)}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
heldLock = lock;
|
|
197
|
+
return { lock, acquired: true };
|
|
198
|
+
}
|
|
199
|
+
async function releaseHeld(lock) {
|
|
200
|
+
if (heldLock !== lock) return;
|
|
201
|
+
heldLock = null;
|
|
202
|
+
await lock.close();
|
|
203
|
+
}
|
|
204
|
+
async function bootKernel() {
|
|
205
|
+
const { lock, acquired } = await ensureLock();
|
|
206
|
+
try {
|
|
207
|
+
const { bootWrapper } = await import("./boot-35BOHNSZ.js");
|
|
208
|
+
await bootWrapper();
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (acquired) await releaseHeld(lock);
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function startDaemon(opts = {}) {
|
|
215
|
+
const { lock, acquired } = await ensureLock();
|
|
216
|
+
try {
|
|
217
|
+
const runtime = await import("./server-TDJBEWTU.js");
|
|
218
|
+
const { onRuntimeLoaded, ...daemonOptions } = opts;
|
|
219
|
+
onRuntimeLoaded?.();
|
|
220
|
+
const handle = await runtime.startDaemon({
|
|
221
|
+
...daemonOptions,
|
|
222
|
+
// The server owns its existing signal behavior. This callback releases
|
|
223
|
+
// the lock after teardown and before its signal path exits the process.
|
|
224
|
+
onShutdown: async () => releaseHeld(lock)
|
|
225
|
+
});
|
|
226
|
+
let closed = false;
|
|
227
|
+
return {
|
|
228
|
+
port: handle.port,
|
|
229
|
+
close: async () => {
|
|
230
|
+
if (closed) return;
|
|
231
|
+
closed = true;
|
|
232
|
+
try {
|
|
233
|
+
await handle.close();
|
|
234
|
+
} finally {
|
|
235
|
+
await releaseHeld(lock);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (acquired) await releaseHeld(lock);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export {
|
|
245
|
+
StateRootLockedError,
|
|
246
|
+
bootKernel,
|
|
247
|
+
canonicalStateDir,
|
|
248
|
+
startDaemon,
|
|
249
|
+
stateRootLockPath
|
|
250
|
+
};
|
package/dist/help.js
CHANGED
|
@@ -3,10 +3,10 @@ import {
|
|
|
3
3
|
helpRequestPath,
|
|
4
4
|
renderHelp,
|
|
5
5
|
renderTopHelp
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-ZFE47KH3.js";
|
|
7
7
|
import "./chunk-QRNZFPNJ.js";
|
|
8
8
|
import "./chunk-6NFATG6P.js";
|
|
9
|
-
import "./chunk-
|
|
9
|
+
import "./chunk-SDNTPCKW.js";
|
|
10
10
|
export {
|
|
11
11
|
helpHint,
|
|
12
12
|
helpRequestPath,
|
package/dist/lifecycle.js
CHANGED