@ours.network/cli 0.5.0 → 0.6.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.
@@ -13,7 +13,7 @@ import {
13
13
  IDENTITY_PREBIND_EXCEPTIONS,
14
14
  commandSpec,
15
15
  isCommandGroup
16
- } from "./chunk-YSHMCQ2B.js";
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);
@@ -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 user systemd service.",
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 Linux user systemd service owned by this CLI.",
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
+ "&": "&amp;",
137
+ "<": "&lt;",
138
+ ">": "&gt;",
139
+ '"': "&quot;",
140
+ "'": "&apos;"
141
+ })[character]);
142
+ }
143
+ function xmlUntext(value) {
144
+ return value.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&apos;", "'").replaceAll("&amp;", "&");
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
  };
@@ -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 user systemd service.";
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 Linux user systemd service owned by this CLI.";
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
@@ -4,7 +4,7 @@ import {
4
4
  IDENTITY_PREBIND_EXCEPTIONS,
5
5
  commandSpec,
6
6
  isCommandGroup
7
- } from "./chunk-YSHMCQ2B.js";
7
+ } from "./chunk-SDNTPCKW.js";
8
8
  export {
9
9
  COMMAND_GROUPS,
10
10
  DESTRUCTIVE_OPERATIONS,
package/dist/help.js CHANGED
@@ -3,10 +3,10 @@ import {
3
3
  helpRequestPath,
4
4
  renderHelp,
5
5
  renderTopHelp
6
- } from "./chunk-VGWQD7CJ.js";
6
+ } from "./chunk-RJWUCR3R.js";
7
7
  import "./chunk-QRNZFPNJ.js";
8
8
  import "./chunk-6NFATG6P.js";
9
- import "./chunk-YSHMCQ2B.js";
9
+ import "./chunk-SDNTPCKW.js";
10
10
  export {
11
11
  helpHint,
12
12
  helpRequestPath,
package/dist/main.js CHANGED
@@ -4,11 +4,11 @@ import {
4
4
  writeResult
5
5
  } from "./chunk-YC7QBGVC.js";
6
6
  import {
7
- createLinuxUserSystemdAdapter
8
- } from "./chunk-227DBTDK.js";
7
+ createServiceAdapter
8
+ } from "./chunk-TNNBVUKK.js";
9
9
  import {
10
10
  instanceNameFromStateDir
11
- } from "./chunk-3RLAHOPS.js";
11
+ } from "./chunk-UZQWSOUB.js";
12
12
  import {
13
13
  setupConfig,
14
14
  showConfig
@@ -17,7 +17,7 @@ import {
17
17
  helpHint,
18
18
  helpRequestPath,
19
19
  renderHelp
20
- } from "./chunk-VGWQD7CJ.js";
20
+ } from "./chunk-RJWUCR3R.js";
21
21
  import {
22
22
  invokeOperation,
23
23
  isOperationName,
@@ -38,7 +38,7 @@ import {
38
38
  IDENTITY_PREBIND_EXCEPTIONS,
39
39
  commandSpec,
40
40
  isCommandGroup
41
- } from "./chunk-YSHMCQ2B.js";
41
+ } from "./chunk-SDNTPCKW.js";
42
42
  import {
43
43
  inspectDaemon,
44
44
  serveDaemon,
@@ -55,7 +55,7 @@ import {
55
55
  import { existsSync, readFileSync } from "node:fs";
56
56
  import { homedir } from "node:os";
57
57
  import { join, resolve } from "node:path";
58
- var CLI_VERSION = false ? "0.0.0-dev" : "0.5.0";
58
+ var CLI_VERSION = false ? "0.0.0-dev" : "0.6.0";
59
59
  var COMMON_VALUES = /* @__PURE__ */ new Set(["--endpoint", "--port", "--state-dir", "--config", "--identity"]);
60
60
  var COMMON_BOOLEANS = /* @__PURE__ */ new Set(["--json", "--yes", "--help"]);
61
61
  function selectionFrom(values) {
@@ -227,7 +227,7 @@ async function runDaemon(args, io) {
227
227
  const derived = instanceNameFromStateDir(target.stateDir);
228
228
  if (!derived.ok) throw new CliUsageError(derived.reason ?? `cannot derive a service name from ${target.stateDir}`);
229
229
  assertPortAgrees(selection.port, target);
230
- const adapter = createLinuxUserSystemdAdapter();
230
+ const adapter = createServiceAdapter();
231
231
  const context = { cliPath: process.argv[1], configPath: target.unitConfigPath, stateDir: target.stateDir, dryRun, force: flags.booleans.has("--force") };
232
232
  const result = handler === "install-service" ? await adapter.install(context) : await adapter.uninstall(context);
233
233
  writeResult(io, result, json, handler);
@@ -1,4 +1,5 @@
1
1
  export declare const DEFAULT_SYSTEMD_UNIT = "ours.service";
2
+ export declare const DEFAULT_LAUNCHD_LABEL = "solutions.adaptframework.ours";
2
3
  export interface InstanceResult {
3
4
  ok: boolean;
4
5
  /** '' means "the default, unnamed daemon" — the historical single-unit behaviour. */
@@ -7,6 +8,8 @@ export interface InstanceResult {
7
8
  }
8
9
  export declare function normalizeInstanceName(raw: unknown): InstanceResult;
9
10
  export declare function systemdUnitName(instance?: string): string;
11
+ export declare function launchdLabel(instance?: string): string;
10
12
  export declare function serviceDescription(instance?: string): string;
11
13
  export declare function instanceNameFromStateDir(stateDir: string): InstanceResult;
12
14
  export declare function unitNameForStateDir(stateDir: string): string;
15
+ export declare function labelForStateDir(stateDir: string): string;
@@ -1,14 +1,20 @@
1
1
  import {
2
+ DEFAULT_LAUNCHD_LABEL,
2
3
  DEFAULT_SYSTEMD_UNIT,
3
4
  instanceNameFromStateDir,
5
+ labelForStateDir,
6
+ launchdLabel,
4
7
  normalizeInstanceName,
5
8
  serviceDescription,
6
9
  systemdUnitName,
7
10
  unitNameForStateDir
8
- } from "./chunk-3RLAHOPS.js";
11
+ } from "./chunk-UZQWSOUB.js";
9
12
  export {
13
+ DEFAULT_LAUNCHD_LABEL,
10
14
  DEFAULT_SYSTEMD_UNIT,
11
15
  instanceNameFromStateDir,
16
+ labelForStateDir,
17
+ launchdLabel,
12
18
  normalizeInstanceName,
13
19
  serviceDescription,
14
20
  systemdUnitName,
package/dist/service.d.ts CHANGED
@@ -47,6 +47,12 @@ export interface ServiceContext {
47
47
  export interface ServiceDeps {
48
48
  platform: NodeJS.Platform;
49
49
  run(command: string, args: string[]): Promise<void>;
50
+ /**
51
+ * launchd only — the uid whose GUI domain the agent is bootstrapped into.
52
+ * Injected rather than read from `process.getuid()` so the domain a plan
53
+ * targets is assertable off macOS, where the rest of this file is developed.
54
+ */
55
+ uid?: number;
50
56
  }
51
57
  export interface ServiceAdapter {
52
58
  readonly name: string;
@@ -58,4 +64,28 @@ export interface ServiceAdapter {
58
64
  * unit written before this CLI baked one in.
59
65
  */
60
66
  export declare function bakedStateDir(unit: string): string | undefined;
67
+ /**
68
+ * What an already-present service definition says about itself, in the terms
69
+ * the guard needs: did this CLI write it, and which daemon is it for. Reading
70
+ * it is per-format; judging it is not, which is what keeps the systemd and
71
+ * launchd guards from drifting into two different sets of refusals.
72
+ */
73
+ export interface ExistingDefinition {
74
+ managed: boolean;
75
+ /** The baked state directory, or undefined for a definition that bakes none. */
76
+ owner?: string;
77
+ }
61
78
  export declare function createLinuxUserSystemdAdapter(deps?: ServiceDeps): ServiceAdapter;
79
+ /**
80
+ * The state directory a CLI-managed launchd agent was installed for. The plist
81
+ * counterpart of `bakedStateDir`, and the reason a label — which is not
82
+ * injective over state directories — is still enough to find its daemon.
83
+ */
84
+ export declare function bakedStateDirFromPlist(plist: string): string | undefined;
85
+ export declare function createMacosUserLaunchdAdapter(deps?: ServiceDeps): ServiceAdapter;
86
+ /**
87
+ * The adapter for the platform this CLI is running on. The two factories keep
88
+ * refusing every platform but their own — the selector is the only thing that
89
+ * is allowed to know which platform maps to which service manager.
90
+ */
91
+ export declare function createServiceAdapter(deps?: ServiceDeps): ServiceAdapter;
package/dist/service.js CHANGED
@@ -1,11 +1,17 @@
1
1
  import {
2
2
  SERVICE_NAME,
3
3
  bakedStateDir,
4
- createLinuxUserSystemdAdapter
5
- } from "./chunk-227DBTDK.js";
6
- import "./chunk-3RLAHOPS.js";
4
+ bakedStateDirFromPlist,
5
+ createLinuxUserSystemdAdapter,
6
+ createMacosUserLaunchdAdapter,
7
+ createServiceAdapter
8
+ } from "./chunk-TNNBVUKK.js";
9
+ import "./chunk-UZQWSOUB.js";
7
10
  export {
8
11
  SERVICE_NAME,
9
12
  bakedStateDir,
10
- createLinuxUserSystemdAdapter
13
+ bakedStateDirFromPlist,
14
+ createLinuxUserSystemdAdapter,
15
+ createMacosUserLaunchdAdapter,
16
+ createServiceAdapter
11
17
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -1,132 +0,0 @@
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
- };