@nowcrew/daemon 0.5.36 → 0.5.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,6 +45,11 @@ crew-daemon uninstall --profile work
45
45
  crew-daemon profile remove work
46
46
  ```
47
47
 
48
+ `doctor` reports the native service, Agent root and execution journal plus managed-update diagnostics for
49
+ the host registry, exact service identity, and installation lease. It is read-only: stale or corrupt lock
50
+ state is reported but never acquired, repaired, or removed. Use these checks when the Web update action is
51
+ hidden.
52
+
48
53
  Lifecycle installation is intentionally refused from an `npx`/temporary cache entry because the service
49
54
  must keep a stable executable path across cache cleanup and upgrades.
50
55
 
@@ -66,6 +71,12 @@ crew-daemon upgrade
66
71
  crew-daemon upgrade --profile work
67
72
  ```
68
73
 
74
+ With `--profile`, upgrade validates isolation, stops the native service, acquires the installation lease,
75
+ replaces only the entrypoint-derived npm prefix, releases the lease, and starts the service. If package
76
+ replacement fails, it still attempts to start the previous installation. Without `--profile`, upgrade
77
+ refuses to replace a prefix currently leased by a running daemon; use the profile form for a managed
78
+ service.
79
+
69
80
  ## Managed Self-Update
70
81
 
71
82
  An owner or admin can update an eligible outdated daemon from the computer detail view. The HTTP request
@@ -76,10 +87,32 @@ only after the native service reconnects and reports that exact version.
76
87
  Existing installations need one manual bootstrap to a release containing managed self-update:
77
88
 
78
89
  ```bash
90
+ # Default daemon home (~/.crew/daemon): upgrade, adopt the exact live descriptor, then reconnect.
79
91
  crew-daemon upgrade --profile work
92
+ crew-daemon install --profile work
93
+ crew-daemon restart --profile work
94
+ crew-daemon doctor --profile work
80
95
  crew-daemon status --profile work
81
96
  ```
82
97
 
98
+ For a non-default daemon home, the bootstrap release changes the native service ID to include the
99
+ daemon-home fingerprint. Run the first command with the currently installed daemon: it upgrades and
100
+ restarts the exact legacy service ID. Then use the upgraded CLI to migrate that profile instead of adopting
101
+ the old ID:
102
+
103
+ ```bash
104
+ CREW_DAEMON_HOME="$HOME/.crew/daemon-dev" crew-daemon upgrade --profile work
105
+ CREW_DAEMON_HOME="$HOME/.crew/daemon-dev" crew-daemon uninstall --profile work
106
+ CREW_DAEMON_HOME="$HOME/.crew/daemon-dev" crew-daemon install --profile work
107
+ CREW_DAEMON_HOME="$HOME/.crew/daemon-dev" crew-daemon start --profile work
108
+ CREW_DAEMON_HOME="$HOME/.crew/daemon-dev" crew-daemon doctor --profile work
109
+ CREW_DAEMON_HOME="$HOME/.crew/daemon-dev" crew-daemon status --profile work
110
+ ```
111
+
112
+ If migration is interrupted after `uninstall`, resume at `install` and `start`. If it is interrupted before
113
+ `uninstall` completes, verify the exact legacy service with `status`, then repeat `uninstall`; identity checks
114
+ prevent either path from removing a drifted descriptor.
115
+
83
116
  The daemon advertises `daemon_update_v1` only when all of these conditions hold:
84
117
 
85
118
  | Requirement | Supported shape |
@@ -87,18 +120,42 @@ The daemon advertises `daemon_update_v1` only when all of these conditions hold:
87
120
  | Platform | macOS LaunchAgent or Linux systemd user service |
88
121
  | Entrypoint | global `@nowcrew/daemon/dist/main.js`, not npx or source/tsx |
89
122
  | Startup | `crew-daemon serve --profile <name>` through the installed service |
90
- | Profiles | exactly one installed daemon service using the global package |
91
- | Service | the selected profile is currently running |
92
- | Install root | the current user can write the global npm modules root |
123
+ | Registry | every NowCrew descriptor on the OS user account is registered and conflict-free |
124
+ | Identity | service ID, descriptor, daemon home, Agent root, entrypoint, package root, and npm prefix match |
125
+ | Service | the selected profile is running and holds the installation lease |
126
+ | Install root | a standard writable Unix global npm prefix can be derived from the running entrypoint |
127
+
128
+ Windows, npx/source runs, stopped or uninstalled services, legacy unregistered descriptors, shared roots,
129
+ and read-only global package roots remain manually upgradeable. These shapes do not show an update button.
130
+ For the default daemon home, `install` adopts only an exact legacy descriptor; the following restart makes
131
+ the daemon reconnect and advertise the newly registered capability. A pre-namespace service in a non-default
132
+ daemon home must be migrated with `uninstall` followed by `install`, because the new service ID includes the
133
+ daemon-home fingerprint. A host that runs multiple environments can keep managed updates by isolating all
134
+ three roots:
135
+
136
+ | Environment | Agents root | Daemon home | npm prefix |
137
+ | --- | --- | --- | --- |
138
+ | in | `~/.crew/agents` | `~/.crew/daemon` | `~/.crew/daemon-runtimes/in` |
139
+ | dev | `~/.crew/agents-dev` | `~/.crew/daemon-dev` | `~/.crew/daemon-runtimes/dev` |
93
140
 
94
- Windows, npx/source runs, stopped or uninstalled services, multiple installed profiles, and read-only
95
- global package roots remain manually upgradeable. These shapes do not show an update button.
141
+ The host execution coordinator intentionally remains shared under `~/.crew/daemon/compute-execution`, so an
142
+ update in either environment still refuses to run while any profile on the machine is executing Agent work.
143
+ Managed service ownership and installation leases are shared under `~/.crew/daemon/managed-services`.
144
+ Registry writes are private, atomic, schema-validated, and serialized across processes. Each non-default
145
+ daemon home also contributes a stable hash to the native service ID, so equal profile names can coexist.
96
146
 
97
147
  Before replacing files, the daemon refuses new work, requires zero local active or queued work, and
98
148
  non-blockingly acquires every host execution slot. It then installs only the server-selected exact
99
- `x.y.z` version with npm, verifies the installed package version, and schedules the existing native
100
- service restart from a detached helper. No package name, registry, command, or free-form argument comes
101
- from the browser control message.
149
+ `x.y.z` version with npm using the prefix derived from the running global entrypoint, verifies the installed
150
+ package version, and schedules the existing native service restart from a detached helper. macOS uses
151
+ `launchctl kickstart -k` to avoid the `bootout`/`bootstrap` race; Linux uses `systemctl --user --no-block
152
+ restart`. The helper reports manager failure over IPC. A failed handoff releases both execution barriers and
153
+ reports `restart_failed`; a SIGTERM/shutdown initiated by a successful handoff releases them during orderly
154
+ shutdown. No package name, registry, command, or free-form argument comes from the browser control message.
155
+
156
+ The release guard packs with pnpm so workspace dependencies become registry-compatible version ranges,
157
+ rejects any runtime dependency that still uses `workspace:`, and installs the tarball into a clean npm
158
+ prefix before a daemon version can be published.
102
159
 
103
160
  Failures are recoverable and keep a bounded code in the machine record:
104
161
 
@@ -117,11 +174,14 @@ Remote downgrade is intentionally unsupported. To roll back, install a known exa
117
174
  the service manually:
118
175
 
119
176
  ```bash
120
- npm install --global --ignore-scripts --no-audit --no-fund @nowcrew/daemon@<previous-x.y.z>
177
+ npm install --global --prefix <environment-prefix> --ignore-scripts --no-audit --no-fund @nowcrew/daemon@<previous-x.y.z>
121
178
  crew-daemon restart --profile work
122
179
  crew-daemon status --profile work
123
180
  ```
124
181
 
182
+ Use the `npmPrefix` reported by `crew-daemon doctor --profile work`; do not rely on the interactive shell's
183
+ current global npm configuration on a multi-environment host.
184
+
125
185
  ## Execution Boundary
126
186
 
127
187
  The server selects the machine and sends a validated `execution:start` containing:
@@ -3,10 +3,15 @@ import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { assertProfileAgentsRootUnique, daemonHome, inspectProfileAgentsRoot, listProfiles, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, profileIsPrivate, profilePath, publicProfile, removeProfile, resolveAgentsRoot, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
6
- import { buildServiceSpec, doctorService, hardenWindowsProfile, installService, serviceAction, serviceStatus, systemCommandRunner, uninstallService, upgradeDaemon, windowsProfileIsPrivate, } from "./computer-service.js";
6
+ import { buildServiceSpec, doctorService, hardenWindowsProfile, installService, readServiceDescriptor, serviceAction, serviceStatus, systemCommandRunner, uninstallService, upgradeDaemon, windowsProfileIsPrivate, } from "./computer-service.js";
7
7
  import { defaultProcessController } from "./execution-journal.js";
8
8
  import { inspectJournalLock } from "./execution-journal-lock.js";
9
9
  import { detectDaemonLang, formatDaemonText } from "./i18n.js";
10
+ import { daemonGlobalInstallation } from "./daemon-installation.js";
11
+ import { acquireDaemonInstallationLease, } from "./daemon-installation-lease.js";
12
+ import { assertManagedServiceIdentity, buildManagedServiceRecord, installManagedService, uninstallUnregisteredManagedService, uninstallManagedService, } from "./managed-service-lifecycle.js";
13
+ import { inspectManagedServiceDiagnostics } from "./managed-service-diagnostics.js";
14
+ import { readManagedServiceRegistry } from "./managed-service-registry.js";
10
15
  const COMPUTER_COMMANDS = new Set(["profile", "doctor", "install", "uninstall", "start", "stop", "restart", "status", "upgrade"]);
11
16
  export function builtDaemonEntry(moduleUrl = import.meta.url) {
12
17
  return moduleUrl.endsWith(".js")
@@ -31,6 +36,7 @@ function defaults() {
31
36
  }
32
37
  return result.stdout.trim();
33
38
  },
39
+ acquireInstallationLease: acquireDaemonInstallationLease,
34
40
  readStdin: async () => {
35
41
  if (process.stdin.isTTY)
36
42
  throw new Error("--token-stdin requires a token on standard input");
@@ -153,6 +159,7 @@ export async function runComputerCommand(argv, overrides = {}) {
153
159
  }
154
160
  const profileName = parsed.values.profile;
155
161
  if (command === "upgrade") {
162
+ const installation = daemonGlobalInstallation(deps.entryPath, deps.platform);
156
163
  const restartProfile = profileName
157
164
  ? await loadProfile(profileName, home, profileStorage)
158
165
  : null;
@@ -163,29 +170,61 @@ export async function runComputerCommand(argv, overrides = {}) {
163
170
  profileHome: home,
164
171
  })
165
172
  : null;
166
- if (spec) {
167
- if (!isGlobalDaemonEntry(deps.entryPath, await deps.resolveGlobalNodeModules())) {
173
+ if (spec && restartProfile) {
174
+ if (installation === null && !isGlobalDaemonEntry(deps.entryPath, await deps.resolveGlobalNodeModules())) {
168
175
  throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest"));
169
176
  }
170
177
  const before = await serviceStatus(spec, deps.runner);
171
178
  if (!before.installed)
172
179
  throw new Error(td("Service '{{id}}' is not installed", { id: spec.id }));
173
- }
174
- await upgradeDaemon(deps.platform, deps.runner);
175
- if (restartProfile) {
176
180
  try {
177
181
  await assertProfileAgentsRootUnique(restartProfile, home, deps.userHome, profileStorage);
178
182
  }
179
183
  catch (error) {
180
184
  if (!(error instanceof ProfileAgentsRootConflictError))
181
185
  throw error;
182
- deps.stdout(`${td("Upgraded daemon but skipped restart for '{{name}}': {{reason}}", { name: restartProfile.name, reason: translatedError(error, td) })}\n`);
183
- return 0;
186
+ throw new Error(td("Refused daemon upgrade for '{{name}}': {{reason}}", { name: restartProfile.name, reason: translatedError(error, td) }));
187
+ }
188
+ await serviceAction(spec, "stop", deps.runner);
189
+ }
190
+ let upgradeError;
191
+ let installationLease = null;
192
+ try {
193
+ installationLease = installation === null
194
+ ? null
195
+ : await deps.acquireInstallationLease(installation.npmPrefix, { userHome: deps.userHome });
196
+ await upgradeDaemon(deps.platform, deps.runner, installation?.npmPrefix);
197
+ }
198
+ catch (error) {
199
+ upgradeError = error;
200
+ }
201
+ finally {
202
+ try {
203
+ await installationLease?.close();
204
+ }
205
+ catch (error) {
206
+ upgradeError = upgradeError === undefined
207
+ ? error
208
+ : new Error(`${upgradeError.message}; installation lease release failed: ${error.message}`, { cause: upgradeError });
209
+ }
210
+ }
211
+ if (restartProfile) {
212
+ try {
213
+ await serviceAction(spec, "start", deps.runner);
214
+ }
215
+ catch (restartError) {
216
+ if (upgradeError !== undefined) {
217
+ throw new Error(`${upgradeError.message}; service recovery failed: ${restartError.message}`, { cause: upgradeError });
218
+ }
219
+ throw restartError;
184
220
  }
185
- await serviceAction(spec, "restart", deps.runner);
221
+ if (upgradeError !== undefined)
222
+ throw upgradeError;
186
223
  deps.stdout(`${td("Upgraded daemon and restart request accepted for '{{name}}'. Verify with status.", { name: restartProfile.name })}\n`);
187
224
  }
188
225
  else {
226
+ if (upgradeError !== undefined)
227
+ throw upgradeError;
189
228
  deps.stdout(`${td("Upgraded daemon. Installed services were not restarted; pass --profile to restart one.")}\n`);
190
229
  }
191
230
  return 0;
@@ -201,20 +240,93 @@ export async function runComputerCommand(argv, overrides = {}) {
201
240
  const spec = buildServiceSpec({
202
241
  ...deps, profile: name, profileHome: home,
203
242
  });
243
+ const installation = daemonGlobalInstallation(deps.entryPath, deps.platform);
244
+ const managedRecord = installation !== null && spec.platform !== "win32"
245
+ ? buildManagedServiceRecord({
246
+ spec,
247
+ daemonHome: home,
248
+ agentsRoot: resolveAgentsRoot(profile.agentsRoot, deps.userHome, deps.platform),
249
+ nodePath: deps.nodePath,
250
+ entryPath: deps.entryPath,
251
+ ...installation,
252
+ })
253
+ : null;
204
254
  if (command === "install") {
205
- if (!isGlobalDaemonEntry(deps.entryPath, await deps.resolveGlobalNodeModules())) {
255
+ if (installation === null && !isGlobalDaemonEntry(deps.entryPath, await deps.resolveGlobalNodeModules())) {
206
256
  throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest"));
207
257
  }
208
- await installService(spec, deps.runner);
258
+ if (managedRecord === null)
259
+ await installService(spec, deps.runner);
260
+ else
261
+ await installManagedService({
262
+ spec,
263
+ record: managedRecord,
264
+ runner: deps.runner,
265
+ registry: { userHome: deps.userHome },
266
+ });
209
267
  deps.stdout(`${td("Installed '{{id}}'. Use status to confirm runtime state.", { id: spec.id })}\n`);
210
268
  return 0;
211
269
  }
212
270
  if (command === "uninstall") {
213
- await uninstallService(spec, deps.runner);
271
+ if (managedRecord === null)
272
+ await uninstallService(spec, deps.runner);
273
+ else {
274
+ const current = (await readManagedServiceRegistry({ userHome: deps.userHome })).find((record) => record.serviceId === spec.id
275
+ && record.daemonHome === managedRecord.daemonHome
276
+ && record.profile === name);
277
+ if (current === undefined) {
278
+ const descriptor = await readServiceDescriptor(spec);
279
+ if (descriptor !== null) {
280
+ await uninstallUnregisteredManagedService({
281
+ spec,
282
+ daemonHome: home,
283
+ npmPrefix: installation.npmPrefix,
284
+ runner: deps.runner,
285
+ registry: { userHome: deps.userHome },
286
+ acquireInstallationLease: deps.acquireInstallationLease,
287
+ });
288
+ }
289
+ else {
290
+ const legacySpec = buildServiceSpec({
291
+ ...deps,
292
+ profile: name,
293
+ profileHome: home,
294
+ legacyServiceId: true,
295
+ });
296
+ const legacyDescriptor = await readServiceDescriptor(legacySpec);
297
+ if (legacyDescriptor === null) {
298
+ throw new Error(`Managed service '${spec.id}' is not registered and no exact legacy descriptor exists`);
299
+ }
300
+ await uninstallUnregisteredManagedService({
301
+ spec: legacySpec,
302
+ daemonHome: home,
303
+ npmPrefix: installation.npmPrefix,
304
+ runner: deps.runner,
305
+ registry: { userHome: deps.userHome },
306
+ acquireInstallationLease: deps.acquireInstallationLease,
307
+ });
308
+ }
309
+ }
310
+ else {
311
+ await uninstallManagedService({
312
+ spec,
313
+ record: current,
314
+ runner: deps.runner,
315
+ registry: { userHome: deps.userHome },
316
+ });
317
+ }
318
+ }
214
319
  deps.stdout(`${td("Uninstalled '{{id}}'.", { id: spec.id })}\n`);
215
320
  return 0;
216
321
  }
217
322
  if (command === "start" || command === "stop" || command === "restart") {
323
+ if (command !== "stop" && managedRecord !== null) {
324
+ await assertManagedServiceIdentity({
325
+ spec,
326
+ record: managedRecord,
327
+ registry: { userHome: deps.userHome },
328
+ });
329
+ }
218
330
  await serviceAction(spec, command, deps.runner);
219
331
  deps.stdout(`${td("{{action}} request accepted for '{{id}}'. Verify with status.", {
220
332
  action: command,
@@ -238,6 +350,13 @@ export async function runComputerCommand(argv, overrides = {}) {
238
350
  directory: journalPath,
239
351
  inspectIdentity: defaultProcessController.inspectIdentity,
240
352
  });
353
+ const managedService = managedRecord === null
354
+ ? null
355
+ : await inspectManagedServiceDiagnostics({
356
+ spec,
357
+ expectedRecord: managedRecord,
358
+ registry: { userHome: deps.userHome },
359
+ });
241
360
  const checks = [
242
361
  ...serviceChecks,
243
362
  {
@@ -252,6 +371,7 @@ export async function runComputerCommand(argv, overrides = {}) {
252
371
  ok: journalLock.status === "unlocked" || journalLock.status === "owned",
253
372
  detail: journalLock.detail,
254
373
  },
374
+ ...(managedService?.checks ?? []),
255
375
  ];
256
376
  const registered = (await serviceStatus(spec, deps.runner)).installed;
257
377
  deps.stdout(`${JSON.stringify({
@@ -259,6 +379,7 @@ export async function runComputerCommand(argv, overrides = {}) {
259
379
  journalPath,
260
380
  duplicateProfiles: rootInspection.duplicateProfiles,
261
381
  journalLock,
382
+ managedService,
262
383
  checks,
263
384
  registered,
264
385
  }, null, 2)}\n`);
@@ -1,7 +1,7 @@
1
- import { access, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
1
+ import { access, chmod, mkdir, open, readFile, rename, rm } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { execFile, spawn } from "node:child_process";
4
- import { randomUUID } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
  import { promisify } from "node:util";
6
6
  import { dirname, resolve } from "node:path";
7
7
  const execFileAsync = promisify(execFile);
@@ -21,6 +21,11 @@ export const systemCommandRunner = async (command, args) => {
21
21
  };
22
22
  }
23
23
  };
24
+ function serviceIdentitySuffix(profileHome, userHome) {
25
+ if (profileHome === undefined || resolve(profileHome) === resolve(userHome, ".crew", "daemon"))
26
+ return "";
27
+ return `.${createHash("sha256").update(resolve(profileHome)).digest("hex").slice(0, 12)}`;
28
+ }
24
29
  function assertSingleLine(value, field) {
25
30
  if (value.includes("\n") || value.includes("\r") || value.includes("\0")) {
26
31
  throw new Error(`${field} must not contain control characters`);
@@ -51,10 +56,13 @@ export function buildServiceSpec(input) {
51
56
  assertSingleLine(profile, "profile"),
52
57
  ...(input.profileHome ? ["--daemon-home", assertSingleLine(input.profileHome, "daemon home")] : []),
53
58
  ];
59
+ const identitySuffix = input.legacyServiceId === true
60
+ ? ""
61
+ : serviceIdentitySuffix(input.profileHome, userHome);
54
62
  if (platform === "darwin") {
55
63
  if (input.uid === undefined)
56
64
  throw new Error("Cannot install launchd service without a numeric uid");
57
- const id = `com.nowcrew.daemon.${profile}`;
65
+ const id = `com.nowcrew.daemon.${profile}${identitySuffix}`;
58
66
  const args = daemonCommand.map((arg) => ` <string>${xml(arg)}</string>`).join("\n");
59
67
  return {
60
68
  platform,
@@ -67,7 +75,7 @@ export function buildServiceSpec(input) {
67
75
  };
68
76
  }
69
77
  if (platform === "linux") {
70
- const id = `nowcrew-daemon-${profile}.service`;
78
+ const id = `nowcrew-daemon-${profile}${identitySuffix}.service`;
71
79
  return {
72
80
  platform,
73
81
  profile,
@@ -78,7 +86,7 @@ export function buildServiceSpec(input) {
78
86
  descriptor: `[Unit]\nDescription=NowCrew daemon (${profile})\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${daemonCommand.map(systemdArg).join(" ")}\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n`,
79
87
  };
80
88
  }
81
- const id = `NowCrew Daemon (${profile})`;
89
+ const id = `NowCrew Daemon (${profile}${identitySuffix})`;
82
90
  return { platform, profile, id, descriptorPath: null, descriptor: null, daemonCommand, managerDomain: null };
83
91
  }
84
92
  async function pathExists(path) {
@@ -93,15 +101,31 @@ async function pathExists(path) {
93
101
  }
94
102
  }
95
103
  async function writePrivateAtomic(path, content) {
96
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
97
- await chmod(dirname(path), 0o700);
104
+ const directory = dirname(path);
105
+ await mkdir(directory, { recursive: true, mode: 0o700 });
106
+ await chmod(directory, 0o700);
98
107
  const temporary = `${path}.${randomUUID()}.tmp`;
108
+ let handle = null;
99
109
  try {
100
- await writeFile(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
110
+ handle = await open(temporary, "wx", 0o600);
111
+ await handle.writeFile(content, "utf8");
112
+ await handle.sync();
113
+ await handle.close();
114
+ handle = null;
101
115
  await rename(temporary, path);
102
116
  await chmod(path, 0o600);
117
+ if (process.platform !== "win32") {
118
+ const directoryHandle = await open(directory, "r");
119
+ try {
120
+ await directoryHandle.sync();
121
+ }
122
+ finally {
123
+ await directoryHandle.close();
124
+ }
125
+ }
103
126
  }
104
127
  finally {
128
+ await handle?.close().catch(() => undefined);
105
129
  await rm(temporary, { force: true });
106
130
  }
107
131
  }
@@ -200,6 +224,15 @@ export async function installService(spec, runner = systemCommandRunner) {
200
224
  throw error;
201
225
  }
202
226
  }
227
+ export async function ensureServiceEnabled(spec, runner = systemCommandRunner) {
228
+ if (spec.platform === "linux") {
229
+ if (spec.descriptorPath === null || !await pathExists(spec.descriptorPath)) {
230
+ throw new Error(`Service '${spec.id}' is not installed`);
231
+ }
232
+ await checked(runner, "systemctl", ["--user", "daemon-reload"], "systemd reload");
233
+ await checked(runner, "systemctl", ["--user", "enable", spec.id], "systemd enable");
234
+ }
235
+ }
203
236
  export async function uninstallService(spec, runner = systemCommandRunner) {
204
237
  if (spec.platform === "win32") {
205
238
  const task = await windowsTaskState(spec, runner);
@@ -249,8 +282,9 @@ export async function serviceAction(spec, action, runner = systemCommandRunner)
249
282
  }
250
283
  else {
251
284
  if (status.loaded)
252
- await checked(runner, "launchctl", ["bootout", target], "launchd stop");
253
- await checked(runner, "launchctl", ["bootstrap", spec.managerDomain, spec.descriptorPath], "launchd start");
285
+ await checked(runner, "launchctl", ["kickstart", "-k", target], "launchd restart");
286
+ else
287
+ await checked(runner, "launchctl", ["bootstrap", spec.managerDomain, spec.descriptorPath], "launchd restart");
254
288
  }
255
289
  }
256
290
  else if (spec.platform === "linux") {
@@ -273,20 +307,25 @@ export async function serviceAction(spec, action, runner = systemCommandRunner)
273
307
  }
274
308
  }
275
309
  }
310
+ const systemDetachedSpawn = (command, args, options) => spawn(command, [...args], options);
276
311
  const RESTART_HELPER_SOURCE = String.raw `
277
312
  const { spawnSync } = require("node:child_process");
278
313
  const spec = JSON.parse(process.argv[1]);
279
314
  setTimeout(() => {
315
+ let restarted;
280
316
  if (spec.platform === "darwin") {
281
- spawnSync("launchctl", ["bootout", spec.target], { stdio: "ignore", shell: false });
282
- const started = spawnSync("launchctl", ["bootstrap", spec.domain, spec.descriptorPath], { stdio: "ignore", shell: false });
283
- process.exit(started.status === 0 ? 0 : 1);
317
+ restarted = spawnSync("launchctl", ["kickstart", "-k", spec.target], { stdio: "ignore", shell: false });
318
+ } else {
319
+ restarted = spawnSync("systemctl", ["--user", "--no-block", "restart", spec.id], { stdio: "ignore", shell: false });
284
320
  }
285
- const restarted = spawnSync("systemctl", ["--user", "restart", spec.id], { stdio: "ignore", shell: false });
286
- process.exit(restarted.status === 0 ? 0 : 1);
321
+ if (restarted.status !== 0) process.send?.({ type: "restart-failed", status: restarted.status });
322
+ setTimeout(() => {
323
+ process.send?.({ type: "restart-failed", status: "daemon-still-running" });
324
+ process.exit(1);
325
+ }, 10000);
287
326
  }, 250);
288
327
  `;
289
- export function scheduleServiceRestart(spec, spawnDetached = spawn) {
328
+ export function scheduleServiceRestart(spec, spawnDetached = systemDetachedSpawn) {
290
329
  if (spec.platform === "win32")
291
330
  throw new Error("Windows detached restart is not supported");
292
331
  if (spec.descriptorPath === null)
@@ -295,12 +334,30 @@ export function scheduleServiceRestart(spec, spawnDetached = spawn) {
295
334
  ? {
296
335
  platform: spec.platform,
297
336
  target: `${spec.managerDomain}/${spec.id}`,
298
- domain: spec.managerDomain,
299
- descriptorPath: spec.descriptorPath,
300
337
  }
301
338
  : { platform: spec.platform, id: spec.id };
302
- const child = spawnDetached(process.execPath, ["-e", RESTART_HELPER_SOURCE, JSON.stringify(payload)], { detached: true, stdio: "ignore", shell: false });
339
+ const child = spawnDetached(process.execPath, ["-e", RESTART_HELPER_SOURCE, JSON.stringify(payload)], { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"], shell: false });
303
340
  child.unref();
341
+ let settleFailure;
342
+ const failed = new Promise((resolveFailure) => { settleFailure = resolveFailure; });
343
+ let reported = false;
344
+ const reportFailure = (error) => {
345
+ if (reported)
346
+ return;
347
+ reported = true;
348
+ settleFailure(error);
349
+ };
350
+ child.on("message", (message) => {
351
+ if (message && typeof message === "object" && "type" in message && message.type === "restart-failed") {
352
+ reportFailure(new Error("Detached service restart failed"));
353
+ }
354
+ });
355
+ child.on("error", (error) => reportFailure(error));
356
+ child.on("exit", (code, signal) => {
357
+ reportFailure(new Error(`Detached service restart helper exited before daemon shutdown (${signal ?? code ?? "unknown"})`));
358
+ });
359
+ child.channel?.unref();
360
+ return { failed };
304
361
  }
305
362
  export async function serviceStatus(spec, runner = systemCommandRunner) {
306
363
  if (spec.platform === "win32") {
@@ -337,11 +394,19 @@ export async function doctorService(spec, profilePrivate, runner = systemCommand
337
394
  { name: "service-running", ok: status.running === true, detail: status.running ? "running" : "not running" },
338
395
  ];
339
396
  }
340
- export function buildUpgradeCommand(platform) {
341
- return [platform === "win32" ? "npm.cmd" : "npm", ["install", "--global", "@nowcrew/daemon@latest"]];
397
+ export function buildUpgradeCommand(platform, npmPrefix) {
398
+ return [
399
+ platform === "win32" ? "npm.cmd" : "npm",
400
+ [
401
+ "install",
402
+ "--global",
403
+ ...(npmPrefix === undefined ? [] : ["--prefix", npmPrefix]),
404
+ "@nowcrew/daemon@latest",
405
+ ],
406
+ ];
342
407
  }
343
- export async function upgradeDaemon(platform, runner = systemCommandRunner) {
344
- const [command, args] = buildUpgradeCommand(platform);
408
+ export async function upgradeDaemon(platform, runner = systemCommandRunner, npmPrefix) {
409
+ const [command, args] = buildUpgradeCommand(platform, npmPrefix);
345
410
  await checked(runner, command, args, "Daemon upgrade");
346
411
  }
347
412
  const WINDOWS_SID_SCRIPT = "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value";
@@ -0,0 +1,86 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { mkdir, open } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { resolve } from "node:path";
6
+ import { defaultProcessController } from "./execution-journal.js";
7
+ import { createJournalLease, createJournalLeaseRegistry, inspectJournalLock, } from "./execution-journal-lock.js";
8
+ const registry = createJournalLeaseRegistry();
9
+ const heldPrefixes = new Map();
10
+ function canonicalPrefix(npmPrefix) {
11
+ const absolute = resolve(npmPrefix);
12
+ try {
13
+ return realpathSync.native(absolute);
14
+ }
15
+ catch (error) {
16
+ if (error.code !== "ENOENT")
17
+ throw error;
18
+ return absolute;
19
+ }
20
+ }
21
+ function leaseDirectory(npmPrefix, userHome) {
22
+ const fingerprint = createHash("sha256").update(resolve(npmPrefix)).digest("hex");
23
+ return resolve(userHome, ".crew", "daemon", "managed-services", "installation-leases", fingerprint);
24
+ }
25
+ async function syncDirectory(path) {
26
+ if (process.platform === "win32")
27
+ return;
28
+ const handle = await open(path, "r");
29
+ try {
30
+ await handle.sync();
31
+ }
32
+ finally {
33
+ await handle.close();
34
+ }
35
+ }
36
+ export async function acquireDaemonInstallationLease(npmPrefix, options = {}) {
37
+ const normalizedPrefix = canonicalPrefix(npmPrefix);
38
+ const directory = leaseDirectory(normalizedPrefix, options.userHome ?? homedir());
39
+ await mkdir(directory, { recursive: true, mode: 0o700 });
40
+ const lease = createJournalLease({
41
+ directory,
42
+ currentPid: options.currentPid ?? process.pid,
43
+ captureCurrentIdentity: options.captureCurrentIdentity
44
+ ?? (async () => {
45
+ const identity = await defaultProcessController.inspectIdentity(process.pid);
46
+ if (identity === null)
47
+ throw new Error("Failed to capture daemon installation lease identity");
48
+ return identity;
49
+ }),
50
+ inspectIdentity: options.inspectIdentity ?? defaultProcessController.inspectIdentity,
51
+ syncDirectory,
52
+ registry: options.registry ?? registry,
53
+ });
54
+ await lease.acquire();
55
+ heldPrefixes.set(normalizedPrefix, (heldPrefixes.get(normalizedPrefix) ?? 0) + 1);
56
+ let closed = false;
57
+ return {
58
+ npmPrefix: normalizedPrefix,
59
+ close: async () => {
60
+ if (closed)
61
+ return;
62
+ await lease.close();
63
+ const remaining = (heldPrefixes.get(normalizedPrefix) ?? 1) - 1;
64
+ if (remaining <= 0)
65
+ heldPrefixes.delete(normalizedPrefix);
66
+ else
67
+ heldPrefixes.set(normalizedPrefix, remaining);
68
+ closed = true;
69
+ },
70
+ };
71
+ }
72
+ export function daemonInstallationLeaseHeld(npmPrefix) {
73
+ return (heldPrefixes.get(canonicalPrefix(npmPrefix)) ?? 0) > 0;
74
+ }
75
+ export async function inspectDaemonInstallationLease(npmPrefix, options = {}) {
76
+ const normalizedPrefix = canonicalPrefix(npmPrefix);
77
+ return inspectJournalLock({
78
+ directory: leaseDirectory(normalizedPrefix, options.userHome ?? homedir()),
79
+ inspectIdentity: options.inspectIdentity ?? defaultProcessController.inspectIdentity,
80
+ });
81
+ }
82
+ export async function runWithDaemonInstallationLease(lease, lifecycle) {
83
+ const result = await lifecycle();
84
+ await lease?.close();
85
+ return result;
86
+ }