@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.
@@ -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 serviceFile = join(home, ".config", "systemd", "user", SERVICE_NAME);
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 (context.configPath) args.push("--config", resolve(context.configPath));
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=ours shared daemon
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
- if (previous === unit) return { adapter: this.name, action: "install", serviceFile, commands: [], changed: false, dryRun: context.dryRun === true };
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", SERVICE_NAME]];
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 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 };
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 commands = [["systemctl", "--user", "disable", "--now", SERVICE_NAME], ["systemctl", "--user", "daemon-reload"]];
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." : "Compatibility-only/ignored for service management."],
107
- ["--state-dir PATH", selected ? "Select the daemon state directory." : "Compatibility-only/ignored for service management."],
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
@@ -3,7 +3,7 @@ import {
3
3
  helpRequestPath,
4
4
  renderHelp,
5
5
  renderTopHelp
6
- } from "./chunk-HNBWRI4O.js";
6
+ } from "./chunk-VGWQD7CJ.js";
7
7
  import "./chunk-QRNZFPNJ.js";
8
8
  import "./chunk-6NFATG6P.js";
9
9
  import "./chunk-YSHMCQ2B.js";
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-67BC2E5U.js";
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-HNBWRI4O.js";
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 { resolve } from "node:path";
54
- var CLI_VERSION = false ? "0.0.0-dev" : "0.3.0";
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: selection.configPath, dryRun, force: flags.booleans.has("--force") };
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
@@ -1,8 +1,11 @@
1
1
  import {
2
2
  SERVICE_NAME,
3
+ bakedStateDir,
3
4
  createLinuxUserSystemdAdapter
4
- } from "./chunk-67BC2E5U.js";
5
+ } from "./chunk-SSUJ5QGC.js";
6
+ import "./chunk-3RLAHOPS.js";
5
7
  export {
6
8
  SERVICE_NAME,
9
+ bakedStateDir,
7
10
  createLinuxUserSystemdAdapter
8
11
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Transport-neutral operator CLI for the ours shared daemon",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",