@nowcrew/daemon 0.5.36 → 0.5.38
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 +69 -9
- package/dist/computer-cli.js +133 -12
- package/dist/computer-service.js +88 -23
- package/dist/daemon-installation-lease.js +86 -0
- package/dist/daemon-installation.js +38 -0
- package/dist/daemon-update-controller.js +30 -2
- package/dist/daemon-update-eligibility.js +70 -39
- package/dist/daemon-updater.js +16 -0
- package/dist/i18n.js +1 -1
- package/dist/main.js +28 -6
- package/dist/managed-service-diagnostics.js +92 -0
- package/dist/managed-service-lifecycle.js +189 -0
- package/dist/managed-service-registry.js +285 -0
- package/dist/managed-service-startup.js +86 -0
- package/dist/serve.js +1 -0
- package/package.json +3 -3
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
|
-
|
|
|
91
|
-
|
|
|
92
|
-
|
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
100
|
-
service restart from a detached helper.
|
|
101
|
-
|
|
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:
|
package/dist/computer-cli.js
CHANGED
|
@@ -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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|
package/dist/computer-service.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { access, chmod, mkdir, readFile, rename, rm
|
|
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
|
-
|
|
97
|
-
await
|
|
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
|
|
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", ["
|
|
253
|
-
|
|
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", ["
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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 =
|
|
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 [
|
|
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
|
+
}
|