@ours.network/cli 0.3.0 → 0.4.1
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-227DBTDK.js +132 -0
- package/dist/chunk-3RLAHOPS.js +57 -0
- 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 +35 -0
- package/dist/service.js +4 -1
- package/package.json +1 -1
- package/dist/chunk-67BC2E5U.js +0 -78
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
instanceNameFromStateDir,
|
|
3
|
+
serviceDescription,
|
|
4
|
+
unitNameForStateDir
|
|
5
|
+
} from "./chunk-3RLAHOPS.js";
|
|
6
|
+
|
|
7
|
+
// src/service.ts
|
|
8
|
+
import { execFile as nodeExecFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
var execFile = promisify(nodeExecFile);
|
|
14
|
+
var MARKER = "# Managed by @ours.network/cli";
|
|
15
|
+
var SERVICE_NAME = "ours.service";
|
|
16
|
+
var defaultDeps = {
|
|
17
|
+
platform: process.platform,
|
|
18
|
+
run: async (command, args) => {
|
|
19
|
+
await execFile(command, args, { windowsHide: true });
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function systemdEscapeArgument(value) {
|
|
23
|
+
if (/[\n\r]/.test(value)) throw new Error("service arguments must not contain newlines");
|
|
24
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
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 describeConflict(previous, serviceFile, stateDir, verb = "overwrite") {
|
|
39
|
+
if (!previous.startsWith(MARKER)) {
|
|
40
|
+
return {
|
|
41
|
+
kind: "unmanaged",
|
|
42
|
+
serviceFile,
|
|
43
|
+
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`
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const owner = bakedStateDir(previous);
|
|
47
|
+
if (owner !== void 0 && owner !== stateDir) {
|
|
48
|
+
return {
|
|
49
|
+
kind: "other-state-dir",
|
|
50
|
+
serviceFile,
|
|
51
|
+
owner,
|
|
52
|
+
message: `${serviceFile} is the boot service for the daemon at ${owner}, not ${stateDir}; refusing to ${verb} it without --force`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return void 0;
|
|
56
|
+
}
|
|
57
|
+
function resolveStateDir(context) {
|
|
58
|
+
return resolve(context.stateDir ?? join(context.homeDir ?? homedir(), ".ours"));
|
|
59
|
+
}
|
|
60
|
+
function createLinuxUserSystemdAdapter(deps = defaultDeps) {
|
|
61
|
+
if (deps.platform !== "linux") throw new Error(`service management is not supported on ${deps.platform}; use an external launcher for \`ours daemon serve\``);
|
|
62
|
+
return {
|
|
63
|
+
name: "systemd-user",
|
|
64
|
+
async install(context) {
|
|
65
|
+
const home = context.homeDir ?? homedir();
|
|
66
|
+
const stateDir = resolveStateDir(context);
|
|
67
|
+
const unitName = unitNameForStateDir(stateDir);
|
|
68
|
+
const serviceFile = join(home, ".config", "systemd", "user", unitName);
|
|
69
|
+
const unitConfig = context.configPath ? resolve(context.configPath) : void 0;
|
|
70
|
+
const args = [process.execPath, resolve(context.cliPath), "daemon", "serve"];
|
|
71
|
+
if (unitConfig) args.push("--config", unitConfig);
|
|
72
|
+
const environment = `Environment=OURS_STATE_DIR=${systemdEscapeArgument(stateDir)}
|
|
73
|
+
`;
|
|
74
|
+
const unit = `${MARKER}
|
|
75
|
+
[Unit]
|
|
76
|
+
Description=${serviceDescription(instanceNameFromStateDir(stateDir).name)}
|
|
77
|
+
After=network-online.target
|
|
78
|
+
|
|
79
|
+
[Service]
|
|
80
|
+
Type=simple
|
|
81
|
+
ExecStart=${args.map(systemdEscapeArgument).join(" ")}
|
|
82
|
+
${environment}Restart=on-failure
|
|
83
|
+
RestartSec=2
|
|
84
|
+
|
|
85
|
+
[Install]
|
|
86
|
+
WantedBy=default.target
|
|
87
|
+
`;
|
|
88
|
+
if (existsSync(serviceFile)) {
|
|
89
|
+
const previous = readFileSync(serviceFile, "utf8");
|
|
90
|
+
const conflict = describeConflict(previous, serviceFile, stateDir);
|
|
91
|
+
if (conflict && !context.force) {
|
|
92
|
+
if (!context.dryRun) throw new Error(conflict.message);
|
|
93
|
+
return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands: [], changed: false, dryRun: true, conflict };
|
|
94
|
+
}
|
|
95
|
+
if (previous === unit) return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands: [], changed: false, dryRun: context.dryRun === true };
|
|
96
|
+
}
|
|
97
|
+
const commands = [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", unitName]];
|
|
98
|
+
if (!context.dryRun) {
|
|
99
|
+
mkdirSync(dirname(serviceFile), { recursive: true });
|
|
100
|
+
writeFileSync(serviceFile, unit, { mode: 420 });
|
|
101
|
+
for (const [command, ...args2] of commands) await deps.run(command, args2);
|
|
102
|
+
}
|
|
103
|
+
return { adapter: this.name, action: "install", serviceFile, unitName, stateDir, configPath: unitConfig, commands, changed: true, dryRun: context.dryRun === true };
|
|
104
|
+
},
|
|
105
|
+
async uninstall(context) {
|
|
106
|
+
const home = context.homeDir ?? homedir();
|
|
107
|
+
const stateDir = resolveStateDir(context);
|
|
108
|
+
const unitName = unitNameForStateDir(stateDir);
|
|
109
|
+
const serviceFile = join(home, ".config", "systemd", "user", unitName);
|
|
110
|
+
if (!existsSync(serviceFile)) return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands: [], changed: false, dryRun: context.dryRun === true };
|
|
111
|
+
const previous = readFileSync(serviceFile, "utf8");
|
|
112
|
+
const conflict = describeConflict(previous, serviceFile, stateDir, "remove");
|
|
113
|
+
if (conflict && !(context.force && previous.startsWith(MARKER))) {
|
|
114
|
+
if (!context.dryRun) throw new Error(conflict.message);
|
|
115
|
+
return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands: [], changed: false, dryRun: true, conflict };
|
|
116
|
+
}
|
|
117
|
+
const commands = [["systemctl", "--user", "disable", "--now", unitName], ["systemctl", "--user", "daemon-reload"]];
|
|
118
|
+
if (!context.dryRun) {
|
|
119
|
+
await deps.run(commands[0][0], commands[0].slice(1));
|
|
120
|
+
unlinkSync(serviceFile);
|
|
121
|
+
await deps.run(commands[1][0], commands[1].slice(1));
|
|
122
|
+
}
|
|
123
|
+
return { adapter: this.name, action: "uninstall", serviceFile, unitName, stateDir, commands, changed: true, dryRun: context.dryRun === true };
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export {
|
|
129
|
+
SERVICE_NAME,
|
|
130
|
+
bakedStateDir,
|
|
131
|
+
createLinuxUserSystemdAdapter
|
|
132
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -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-227DBTDK.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.1";
|
|
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,15 +1,45 @@
|
|
|
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;
|
|
15
|
+
/**
|
|
16
|
+
* Set ONLY on a --dry-run that found something in the way. A real run throws
|
|
17
|
+
* on the same condition; a preview reports it, because a preview that raises
|
|
18
|
+
* on the one case it exists to preview is not a preview.
|
|
19
|
+
*/
|
|
20
|
+
conflict?: ServiceConflict;
|
|
6
21
|
commands: string[][];
|
|
7
22
|
changed: boolean;
|
|
8
23
|
dryRun: boolean;
|
|
9
24
|
}
|
|
25
|
+
/** Why a run would refuse to touch the unit file that is already there. */
|
|
26
|
+
export interface ServiceConflict {
|
|
27
|
+
kind: 'unmanaged' | 'other-state-dir';
|
|
28
|
+
serviceFile: string;
|
|
29
|
+
/** For 'other-state-dir': the state directory the existing unit belongs to. */
|
|
30
|
+
owner?: string;
|
|
31
|
+
message: string;
|
|
32
|
+
}
|
|
10
33
|
export interface ServiceContext {
|
|
11
34
|
cliPath: string;
|
|
12
35
|
configPath?: string;
|
|
36
|
+
/**
|
|
37
|
+
* The state directory this unit belongs to. It decides the unit NAME and is
|
|
38
|
+
* baked into the unit FILE, so a unit can always be traced back to its
|
|
39
|
+
* daemon. Defaults to `<home>/.ours`, which derives the historical
|
|
40
|
+
* `ours.service`.
|
|
41
|
+
*/
|
|
42
|
+
stateDir?: string;
|
|
13
43
|
homeDir?: string;
|
|
14
44
|
dryRun?: boolean;
|
|
15
45
|
force?: boolean;
|
|
@@ -23,4 +53,9 @@ export interface ServiceAdapter {
|
|
|
23
53
|
install(context: ServiceContext): Promise<ServicePlan>;
|
|
24
54
|
uninstall(context: ServiceContext): Promise<ServicePlan>;
|
|
25
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The state directory a CLI-managed unit was installed for, or undefined for a
|
|
58
|
+
* unit written before this CLI baked one in.
|
|
59
|
+
*/
|
|
60
|
+
export declare function bakedStateDir(unit: string): string | undefined;
|
|
26
61
|
export declare function createLinuxUserSystemdAdapter(deps?: ServiceDeps): ServiceAdapter;
|
package/dist/service.js
CHANGED
package/package.json
CHANGED
package/dist/chunk-67BC2E5U.js
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
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
|
-
};
|