@ours.network/cli 0.3.0 → 0.4.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/dist/chunk-3RLAHOPS.js +57 -0
- package/dist/{chunk-67BC2E5U.js → chunk-SSUJ5QGC.js} +48 -11
- package/dist/{chunk-HNBWRI4O.js → chunk-VGWQD7CJ.js} +2 -2
- package/dist/help.js +1 -1
- package/dist/main.js +49 -6
- package/dist/service-instance.d.ts +12 -0
- package/dist/service-instance.js +16 -0
- package/dist/service.d.ts +21 -0
- package/dist/service.js +4 -1
- package/package.json +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/service-instance.ts
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
var DEFAULT_SYSTEMD_UNIT = "ours.service";
|
|
4
|
+
var INSTANCE_RE = /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,30}[A-Za-z0-9])?$/;
|
|
5
|
+
function normalizeInstanceName(raw) {
|
|
6
|
+
const s = typeof raw === "string" ? raw.trim() : "";
|
|
7
|
+
if (!s) return { ok: true, name: "" };
|
|
8
|
+
if (s.length > 32) {
|
|
9
|
+
return { ok: false, name: "", reason: "service name must be 32 characters or fewer" };
|
|
10
|
+
}
|
|
11
|
+
if (!INSTANCE_RE.test(s)) {
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
name: "",
|
|
15
|
+
reason: "service name must be 1\u201332 characters of letters, digits, hyphen or underscore, starting and ending with a letter or digit"
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return { ok: true, name: s };
|
|
19
|
+
}
|
|
20
|
+
function systemdUnitName(instance = "") {
|
|
21
|
+
const { ok, name } = normalizeInstanceName(instance);
|
|
22
|
+
if (!ok || !name) return DEFAULT_SYSTEMD_UNIT;
|
|
23
|
+
return `ours-${name}.service`;
|
|
24
|
+
}
|
|
25
|
+
function serviceDescription(instance = "") {
|
|
26
|
+
const { ok, name } = normalizeInstanceName(instance);
|
|
27
|
+
return ok && name ? `ours daemon (instance "${name}")` : "ours shared daemon";
|
|
28
|
+
}
|
|
29
|
+
function instanceNameFromStateDir(stateDir) {
|
|
30
|
+
const segment = basename(resolve(stateDir));
|
|
31
|
+
const undotted = segment.startsWith(".") ? segment.slice(1) : segment;
|
|
32
|
+
if (undotted === "ours") return { ok: true, name: "" };
|
|
33
|
+
const stripped = undotted.startsWith("ours-") ? undotted.slice("ours-".length) : undotted;
|
|
34
|
+
const result = normalizeInstanceName(stripped);
|
|
35
|
+
if (!result.ok) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
name: "",
|
|
39
|
+
reason: `state directory ${JSON.stringify(resolve(stateDir))} does not yield a usable service name (${JSON.stringify(stripped)}): ${result.reason}`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
function unitNameForStateDir(stateDir) {
|
|
45
|
+
const { ok, name, reason } = instanceNameFromStateDir(stateDir);
|
|
46
|
+
if (!ok) throw new Error(reason);
|
|
47
|
+
return systemdUnitName(name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
DEFAULT_SYSTEMD_UNIT,
|
|
52
|
+
normalizeInstanceName,
|
|
53
|
+
systemdUnitName,
|
|
54
|
+
serviceDescription,
|
|
55
|
+
instanceNameFromStateDir,
|
|
56
|
+
unitNameForStateDir
|
|
57
|
+
};
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
instanceNameFromStateDir,
|
|
3
|
+
serviceDescription,
|
|
4
|
+
unitNameForStateDir
|
|
5
|
+
} from "./chunk-3RLAHOPS.js";
|
|
6
|
+
|
|
1
7
|
// src/service.ts
|
|
2
8
|
import { execFile as nodeExecFile } from "node:child_process";
|
|
3
9
|
import { promisify } from "node:util";
|
|
@@ -17,24 +23,44 @@ function systemdEscapeArgument(value) {
|
|
|
17
23
|
if (/[\n\r]/.test(value)) throw new Error("service arguments must not contain newlines");
|
|
18
24
|
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
19
25
|
}
|
|
26
|
+
function systemdUnescapeArgument(value) {
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
if (!trimmed.startsWith('"') || !trimmed.endsWith('"') || trimmed.length < 2) return void 0;
|
|
29
|
+
return trimmed.slice(1, -1).replaceAll("%%", "%").replaceAll('\\"', '"').replaceAll("\\\\", "\\");
|
|
30
|
+
}
|
|
31
|
+
function bakedStateDir(unit) {
|
|
32
|
+
for (const line of unit.split("\n")) {
|
|
33
|
+
if (!line.startsWith("Environment=OURS_STATE_DIR=")) continue;
|
|
34
|
+
return systemdUnescapeArgument(line.slice("Environment=OURS_STATE_DIR=".length));
|
|
35
|
+
}
|
|
36
|
+
return void 0;
|
|
37
|
+
}
|
|
38
|
+
function resolveStateDir(context) {
|
|
39
|
+
return resolve(context.stateDir ?? join(context.homeDir ?? homedir(), ".ours"));
|
|
40
|
+
}
|
|
20
41
|
function createLinuxUserSystemdAdapter(deps = defaultDeps) {
|
|
21
42
|
if (deps.platform !== "linux") throw new Error(`service management is not supported on ${deps.platform}; use an external launcher for \`ours daemon serve\``);
|
|
22
43
|
return {
|
|
23
44
|
name: "systemd-user",
|
|
24
45
|
async install(context) {
|
|
25
46
|
const home = context.homeDir ?? homedir();
|
|
26
|
-
const
|
|
47
|
+
const stateDir = resolveStateDir(context);
|
|
48
|
+
const unitName = unitNameForStateDir(stateDir);
|
|
49
|
+
const serviceFile = join(home, ".config", "systemd", "user", unitName);
|
|
50
|
+
const unitConfig = context.configPath ? resolve(context.configPath) : void 0;
|
|
27
51
|
const args = [process.execPath, resolve(context.cliPath), "daemon", "serve"];
|
|
28
|
-
if (
|
|
52
|
+
if (unitConfig) args.push("--config", unitConfig);
|
|
53
|
+
const environment = `Environment=OURS_STATE_DIR=${systemdEscapeArgument(stateDir)}
|
|
54
|
+
`;
|
|
29
55
|
const unit = `${MARKER}
|
|
30
56
|
[Unit]
|
|
31
|
-
Description
|
|
57
|
+
Description=${serviceDescription(instanceNameFromStateDir(stateDir).name)}
|
|
32
58
|
After=network-online.target
|
|
33
59
|
|
|
34
60
|
[Service]
|
|
35
61
|
Type=simple
|
|
36
62
|
ExecStart=${args.map(systemdEscapeArgument).join(" ")}
|
|
37
|
-
Restart=on-failure
|
|
63
|
+
${environment}Restart=on-failure
|
|
38
64
|
RestartSec=2
|
|
39
65
|
|
|
40
66
|
[Install]
|
|
@@ -45,34 +71,45 @@ WantedBy=default.target
|
|
|
45
71
|
if (!previous.startsWith(MARKER) && !context.force) {
|
|
46
72
|
throw new Error(`${serviceFile} is not managed by @ours.network/cli; refusing to overwrite it without --force`);
|
|
47
73
|
}
|
|
48
|
-
|
|
74
|
+
const owner = previous.startsWith(MARKER) ? bakedStateDir(previous) : void 0;
|
|
75
|
+
if (owner !== void 0 && owner !== stateDir && !context.force) {
|
|
76
|
+
throw new Error(`${serviceFile} is the boot service for the daemon at ${owner}, not ${stateDir}; refusing to overwrite it without --force`);
|
|
77
|
+
}
|
|
78
|
+
if (previous === unit) return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands: [], changed: false, dryRun: context.dryRun === true };
|
|
49
79
|
}
|
|
50
|
-
const commands = [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now",
|
|
80
|
+
const commands = [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", unitName]];
|
|
51
81
|
if (!context.dryRun) {
|
|
52
82
|
mkdirSync(dirname(serviceFile), { recursive: true });
|
|
53
83
|
writeFileSync(serviceFile, unit, { mode: 420 });
|
|
54
84
|
for (const [command, ...args2] of commands) await deps.run(command, args2);
|
|
55
85
|
}
|
|
56
|
-
return { adapter: this.name, action: "install", serviceFile, commands, changed: true, dryRun: context.dryRun === true };
|
|
86
|
+
return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands, changed: true, dryRun: context.dryRun === true };
|
|
57
87
|
},
|
|
58
88
|
async uninstall(context) {
|
|
59
89
|
const home = context.homeDir ?? homedir();
|
|
60
|
-
const
|
|
61
|
-
|
|
90
|
+
const stateDir = resolveStateDir(context);
|
|
91
|
+
const unitName = unitNameForStateDir(stateDir);
|
|
92
|
+
const serviceFile = join(home, ".config", "systemd", "user", unitName);
|
|
93
|
+
if (!existsSync(serviceFile)) return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands: [], changed: false, dryRun: context.dryRun === true };
|
|
62
94
|
const previous = readFileSync(serviceFile, "utf8");
|
|
63
95
|
if (!previous.startsWith(MARKER)) throw new Error(`${serviceFile} is not managed by @ours.network/cli; refusing to remove it`);
|
|
64
|
-
const
|
|
96
|
+
const owner = bakedStateDir(previous);
|
|
97
|
+
if (owner !== void 0 && owner !== stateDir && !context.force) {
|
|
98
|
+
throw new Error(`${serviceFile} is the boot service for the daemon at ${owner}, not ${stateDir}; refusing to remove it without --force`);
|
|
99
|
+
}
|
|
100
|
+
const commands = [["systemctl", "--user", "disable", "--now", unitName], ["systemctl", "--user", "daemon-reload"]];
|
|
65
101
|
if (!context.dryRun) {
|
|
66
102
|
await deps.run(commands[0][0], commands[0].slice(1));
|
|
67
103
|
unlinkSync(serviceFile);
|
|
68
104
|
await deps.run(commands[1][0], commands[1].slice(1));
|
|
69
105
|
}
|
|
70
|
-
return { adapter: this.name, action: "uninstall", serviceFile, commands, changed: true, dryRun: context.dryRun === true };
|
|
106
|
+
return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands, changed: true, dryRun: context.dryRun === true };
|
|
71
107
|
}
|
|
72
108
|
};
|
|
73
109
|
}
|
|
74
110
|
|
|
75
111
|
export {
|
|
76
112
|
SERVICE_NAME,
|
|
113
|
+
bakedStateDir,
|
|
77
114
|
createLinuxUserSystemdAdapter
|
|
78
115
|
};
|
|
@@ -103,8 +103,8 @@ function nativeDaemonOptions(action) {
|
|
|
103
103
|
const install = action === "install-service";
|
|
104
104
|
const rows = [
|
|
105
105
|
["--endpoint URL", selected ? action === "serve" || action === "start" || action === "restart" ? "Rejected: this action must start a local selected daemon." : "Select a running endpoint; pair it with --state-dir." : "Compatibility-only/ignored for service management."],
|
|
106
|
-
["--port N", selected ? "Select daemon port 1\u201365535." : "
|
|
107
|
-
["--state-dir PATH", selected ? "Select the daemon state directory." : "
|
|
106
|
+
["--port N", selected ? "Select daemon port 1\u201365535." : "Checked against the port recorded for this state directory; a disagreement is refused and nothing is written."],
|
|
107
|
+
["--state-dir PATH", selected ? "Select the daemon state directory." : "Select the daemon whose unit this is; the unit name derives from it (~/.ours \u2192 ours.service, ~/.ours-tg \u2192 ours-tg.service)."],
|
|
108
108
|
["--config PATH", selected ? "Use this daemon config for lifecycle selection." : action === "install-service" ? "Embed this config file\u2019s absolute path in the installed unit." : "Compatibility-only/ignored by uninstall-service."],
|
|
109
109
|
["--identity NAME", "Compatibility-only/ignored by daemon lifecycle and service commands."],
|
|
110
110
|
["--json", action === "serve" ? "Compatibility-only/ignored because serve runs until interrupted." : "Write one JSON result to stdout."],
|
package/dist/help.js
CHANGED
package/dist/main.js
CHANGED
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
} from "./chunk-YC7QBGVC.js";
|
|
6
6
|
import {
|
|
7
7
|
createLinuxUserSystemdAdapter
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-SSUJ5QGC.js";
|
|
9
|
+
import {
|
|
10
|
+
instanceNameFromStateDir
|
|
11
|
+
} from "./chunk-3RLAHOPS.js";
|
|
9
12
|
import {
|
|
10
13
|
setupConfig,
|
|
11
14
|
showConfig
|
|
@@ -14,7 +17,7 @@ import {
|
|
|
14
17
|
helpHint,
|
|
15
18
|
helpRequestPath,
|
|
16
19
|
renderHelp
|
|
17
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-VGWQD7CJ.js";
|
|
18
21
|
import {
|
|
19
22
|
invokeOperation,
|
|
20
23
|
isOperationName,
|
|
@@ -49,9 +52,10 @@ import {
|
|
|
49
52
|
} from "./chunk-6W3RHW3C.js";
|
|
50
53
|
|
|
51
54
|
// src/main.ts
|
|
52
|
-
import { readFileSync } from "node:fs";
|
|
53
|
-
import {
|
|
54
|
-
|
|
55
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
56
|
+
import { homedir } from "node:os";
|
|
57
|
+
import { join, resolve } from "node:path";
|
|
58
|
+
var CLI_VERSION = false ? "0.0.0-dev" : "0.4.0";
|
|
55
59
|
var COMMON_VALUES = /* @__PURE__ */ new Set(["--endpoint", "--port", "--state-dir", "--config", "--identity"]);
|
|
56
60
|
var COMMON_BOOLEANS = /* @__PURE__ */ new Set(["--json", "--yes", "--help"]);
|
|
57
61
|
function selectionFrom(values) {
|
|
@@ -62,6 +66,41 @@ function selectionFrom(values) {
|
|
|
62
66
|
configPath: values["--config"]
|
|
63
67
|
};
|
|
64
68
|
}
|
|
69
|
+
function readDaemonConfig(path) {
|
|
70
|
+
if (!existsSync(path)) return void 0;
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new CliUsageError(`${path} is not readable JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
76
|
+
}
|
|
77
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
78
|
+
throw new CliUsageError(`${path} must contain a JSON object`);
|
|
79
|
+
}
|
|
80
|
+
return parsed;
|
|
81
|
+
}
|
|
82
|
+
function serviceTarget(values) {
|
|
83
|
+
const namedConfig = values["--config"] ?? process.env.OURS_CONFIG;
|
|
84
|
+
const seedConfigPath = namedConfig ?? defaultConfigPath();
|
|
85
|
+
const seedConfig = readDaemonConfig(resolve(seedConfigPath));
|
|
86
|
+
const fromFile = typeof seedConfig?.stateDir === "string" ? seedConfig.stateDir : void 0;
|
|
87
|
+
const stateDir = resolve(
|
|
88
|
+
values["--state-dir"] ?? process.env.OURS_STATE_DIR ?? fromFile ?? join(homedir(), ".ours")
|
|
89
|
+
);
|
|
90
|
+
const configPath = namedConfig ?? join(stateDir, "config.json");
|
|
91
|
+
const ownConfig = join(stateDir, "config.json");
|
|
92
|
+
const unitConfigPath = values["--config"] ?? (existsSync(ownConfig) ? ownConfig : void 0);
|
|
93
|
+
return { stateDir, configPath, unitConfigPath };
|
|
94
|
+
}
|
|
95
|
+
function assertPortAgrees(port, target) {
|
|
96
|
+
if (port === void 0 || target.configPath === void 0) return;
|
|
97
|
+
const config = readDaemonConfig(resolve(target.configPath));
|
|
98
|
+
const recorded = config?.port;
|
|
99
|
+
if (typeof recorded !== "number" || recorded === port) return;
|
|
100
|
+
throw new CliUsageError(
|
|
101
|
+
`--port ${port} disagrees with port ${recorded}, which ${resolve(target.configPath)} records for the daemon at ${target.stateDir}. Re-run without --port to use the recorded port, or change the recorded port first. Nothing was written.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
65
104
|
function requireYes(operation, yes) {
|
|
66
105
|
if (DESTRUCTIVE_OPERATIONS.has(operation) && !yes) throw new CliUsageError(`${operation} is destructive; re-run with --yes`);
|
|
67
106
|
}
|
|
@@ -184,8 +223,12 @@ async function runDaemon(args, io) {
|
|
|
184
223
|
if (handler === "install-service" || handler === "uninstall-service") {
|
|
185
224
|
const dryRun = flags.booleans.has("--dry-run");
|
|
186
225
|
if (!dryRun && !flags.booleans.has("--yes")) throw new CliUsageError(`${handler} changes the user service manager; re-run with --yes or preview with --dry-run`);
|
|
226
|
+
const target = serviceTarget(flags.values);
|
|
227
|
+
const derived = instanceNameFromStateDir(target.stateDir);
|
|
228
|
+
if (!derived.ok) throw new CliUsageError(derived.reason ?? `cannot derive a service name from ${target.stateDir}`);
|
|
229
|
+
assertPortAgrees(selection.port, target);
|
|
187
230
|
const adapter = createLinuxUserSystemdAdapter();
|
|
188
|
-
const context = { cliPath: process.argv[1], configPath:
|
|
231
|
+
const context = { cliPath: process.argv[1], configPath: target.unitConfigPath, stateDir: target.stateDir, dryRun, force: flags.booleans.has("--force") };
|
|
189
232
|
const result = handler === "install-service" ? await adapter.install(context) : await adapter.uninstall(context);
|
|
190
233
|
writeResult(io, result, json, handler);
|
|
191
234
|
return 0;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const DEFAULT_SYSTEMD_UNIT = "ours.service";
|
|
2
|
+
export interface InstanceResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
/** '' means "the default, unnamed daemon" — the historical single-unit behaviour. */
|
|
5
|
+
name: string;
|
|
6
|
+
reason?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function normalizeInstanceName(raw: unknown): InstanceResult;
|
|
9
|
+
export declare function systemdUnitName(instance?: string): string;
|
|
10
|
+
export declare function serviceDescription(instance?: string): string;
|
|
11
|
+
export declare function instanceNameFromStateDir(stateDir: string): InstanceResult;
|
|
12
|
+
export declare function unitNameForStateDir(stateDir: string): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SYSTEMD_UNIT,
|
|
3
|
+
instanceNameFromStateDir,
|
|
4
|
+
normalizeInstanceName,
|
|
5
|
+
serviceDescription,
|
|
6
|
+
systemdUnitName,
|
|
7
|
+
unitNameForStateDir
|
|
8
|
+
} from "./chunk-3RLAHOPS.js";
|
|
9
|
+
export {
|
|
10
|
+
DEFAULT_SYSTEMD_UNIT,
|
|
11
|
+
instanceNameFromStateDir,
|
|
12
|
+
normalizeInstanceName,
|
|
13
|
+
serviceDescription,
|
|
14
|
+
systemdUnitName,
|
|
15
|
+
unitNameForStateDir
|
|
16
|
+
};
|
package/dist/service.d.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unit for the default, unnamed daemon. Kept as a named export because it
|
|
3
|
+
* is the historical public constant; every other unit name comes from
|
|
4
|
+
* `unitNameForStateDir`, which returns exactly this string for `~/.ours`.
|
|
5
|
+
*/
|
|
1
6
|
export declare const SERVICE_NAME = "ours.service";
|
|
2
7
|
export interface ServicePlan {
|
|
3
8
|
adapter: string;
|
|
4
9
|
action: 'install' | 'uninstall';
|
|
5
10
|
serviceFile: string;
|
|
11
|
+
unitName: string;
|
|
12
|
+
stateDir: string;
|
|
13
|
+
/** The config file the unit's ExecStart selects, or undefined when it selects none. */
|
|
14
|
+
configPath?: string;
|
|
6
15
|
commands: string[][];
|
|
7
16
|
changed: boolean;
|
|
8
17
|
dryRun: boolean;
|
|
@@ -10,6 +19,13 @@ export interface ServicePlan {
|
|
|
10
19
|
export interface ServiceContext {
|
|
11
20
|
cliPath: string;
|
|
12
21
|
configPath?: string;
|
|
22
|
+
/**
|
|
23
|
+
* The state directory this unit belongs to. It decides the unit NAME and is
|
|
24
|
+
* baked into the unit FILE, so a unit can always be traced back to its
|
|
25
|
+
* daemon. Defaults to `<home>/.ours`, which derives the historical
|
|
26
|
+
* `ours.service`.
|
|
27
|
+
*/
|
|
28
|
+
stateDir?: string;
|
|
13
29
|
homeDir?: string;
|
|
14
30
|
dryRun?: boolean;
|
|
15
31
|
force?: boolean;
|
|
@@ -23,4 +39,9 @@ export interface ServiceAdapter {
|
|
|
23
39
|
install(context: ServiceContext): Promise<ServicePlan>;
|
|
24
40
|
uninstall(context: ServiceContext): Promise<ServicePlan>;
|
|
25
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The state directory a CLI-managed unit was installed for, or undefined for a
|
|
44
|
+
* unit written before this CLI baked one in.
|
|
45
|
+
*/
|
|
46
|
+
export declare function bakedStateDir(unit: string): string | undefined;
|
|
26
47
|
export declare function createLinuxUserSystemdAdapter(deps?: ServiceDeps): ServiceAdapter;
|
package/dist/service.js
CHANGED