@nowcrew/daemon 0.5.48 → 0.5.49
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 +34 -6
- package/dist/automatic-daemon-installer.js +326 -0
- package/dist/automatic-install-target.js +76 -0
- package/dist/automatic-service-migration.js +209 -0
- package/dist/computer-cli.js +35 -1
- package/dist/computer-profile.js +2 -1
- package/dist/computer-service.js +66 -0
- package/dist/i18n.js +4 -0
- package/dist/legacy-service-installation.js +12 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,13 +5,14 @@ WebSocket open, and launches coding runtimes on that machine. Product policy rem
|
|
|
5
5
|
|
|
6
6
|
## Run
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
For a temporary foreground connection, run:
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
npx @nowcrew/daemon@latest --server-url https://nowwork.example --api-key sk_machine_...
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
This compatibility form stays resident in the current terminal and reconnects with backoff. New
|
|
15
|
+
**Add Computer** commands use the managed automatic installation described below. For local development, run
|
|
15
16
|
`pnpm --filter @nowcrew/daemon daemon`. `crew-daemon run --agent <handle> --channel <id>` is the legacy
|
|
16
17
|
manual one-shot entry.
|
|
17
18
|
|
|
@@ -31,7 +32,30 @@ Profile files live under `~/.crew/daemon/profiles`. Unix writes use mode `0600`;
|
|
|
31
32
|
encrypted with CurrentUser DPAPI and the profile ACL is restricted to the current user. Windows refuses
|
|
32
33
|
legacy plaintext profiles and fails the save if DPAPI or ACL hardening is unavailable.
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
The connection command generated by **Add Computer** installs the package into an isolated stable prefix,
|
|
36
|
+
saves the private profile, installs the native user service, starts it, and verifies that it is running:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx -y --registry https://registry.npmjs.org @nowcrew/daemon@latest install \
|
|
40
|
+
--server-url https://nowwork-dev.nowcoder.com --api-key sk_machine_...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The installer selects the fixed `in` layout for `nowwork-in.nowcoder.com` and the fixed `dev` layout for
|
|
44
|
+
`nowwork-dev.nowcoder.com`. It compares the parsed hostname exactly; lookalike suffixes are not accepted.
|
|
45
|
+
An unknown hostname is rejected before any write and reports how to retry with an explicit custom profile:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx -y --registry https://registry.npmjs.org @nowcrew/daemon@latest install \
|
|
49
|
+
--server-url https://lab.example.com --api-key sk_machine_... --profile lab
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Custom profiles use `~/.crew/daemon-<profile>`, `~/.crew/agents-<profile>`, and
|
|
53
|
+
`~/.crew/daemon-runtimes/<profile>`. The names `in` and `dev` remain reserved for their known domains.
|
|
54
|
+
Re-running the command updates the exact isolated package, rotates the saved machine credential, repairs the
|
|
55
|
+
exact selected service, and verifies it again. An exact generated pre-namespace service is migrated only for
|
|
56
|
+
the selected profile; a drifted, duplicate, wrong-user, or otherwise unverifiable service fails closed.
|
|
57
|
+
|
|
58
|
+
The lower-level lifecycle commands remain available for diagnostics and manual operation:
|
|
35
59
|
|
|
36
60
|
```bash
|
|
37
61
|
npm install --global @nowcrew/daemon@latest
|
|
@@ -50,8 +74,10 @@ the host registry, exact service identity, and installation lease. It is read-on
|
|
|
50
74
|
state is reported but never acquired, repaired, or removed. Use these checks when the Web update action is
|
|
51
75
|
hidden.
|
|
52
76
|
|
|
53
|
-
|
|
54
|
-
must keep a stable executable path across cache cleanup and upgrades.
|
|
77
|
+
Descriptor-only `install --profile` is intentionally refused from an `npx`/temporary cache entry because
|
|
78
|
+
the service must keep a stable executable path across cache cleanup and upgrades. The automatic
|
|
79
|
+
`install --server-url` mode is allowed from npx because it first installs the invoking package's exact
|
|
80
|
+
version under the profile-specific stable prefix and points the service at that entry.
|
|
55
81
|
|
|
56
82
|
macOS uses a user LaunchAgent, Linux uses a systemd user unit, and Windows uses Task Scheduler at logon.
|
|
57
83
|
On macOS `install` writes the plist, `start` bootstraps it, and `stop` boots it out, so `KeepAlive` cannot
|
|
@@ -122,8 +148,10 @@ The daemon advertises `daemon_update_v1` only when all of these conditions hold:
|
|
|
122
148
|
| Service | the selected profile is running and holds the installation lease |
|
|
123
149
|
| Install root | a standard writable Unix global npm prefix can be derived from the running entrypoint |
|
|
124
150
|
|
|
125
|
-
|
|
151
|
+
Foreground npx/source runs, stopped or uninstalled services, legacy unregistered descriptors, shared roots,
|
|
126
152
|
and read-only global package roots remain manually upgradeable. These shapes do not show an update button.
|
|
153
|
+
Windows Scheduled Tasks are eligible only when the task action, current-user principal, running instance,
|
|
154
|
+
process identity, isolated npm prefix, and installation lease all match exactly.
|
|
127
155
|
For the default daemon home, `install` adopts only an exact legacy descriptor; the following restart makes
|
|
128
156
|
the daemon reconnect and advertise the newly registered capability. A pre-namespace service in a non-default
|
|
129
157
|
daemon home must be migrated with `uninstall` followed by `install`, because the new service ID includes the
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
import { assertProfileAgentsRootUnique, listProfiles, loadProfile, MachineTokenSchema, removeProfile, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
|
|
4
|
+
import { buildServiceSpec, captureExactService, hardenWindowsProfile, replaceExactService, restoreExactService, serviceAction, serviceStatus, systemCommandRunner, uninstallService, } from "./computer-service.js";
|
|
5
|
+
import { daemonGlobalInstallation } from "./daemon-installation.js";
|
|
6
|
+
import { buildManagedServiceRecord, installManagedService, uninstallManagedService, } from "./managed-service-lifecycle.js";
|
|
7
|
+
import { ManagedServiceConflictError, managedServiceConflict, managedServiceIdentityMatches, readManagedServiceRegistry, } from "./managed-service-registry.js";
|
|
8
|
+
import { resolveAutomaticInstallTarget, } from "./automatic-install-target.js";
|
|
9
|
+
import { acquireDaemonInstallationLease, } from "./daemon-installation-lease.js";
|
|
10
|
+
import { daemonVersion } from "./machine-info.js";
|
|
11
|
+
import { discoverExactLegacyService, } from "./automatic-service-migration.js";
|
|
12
|
+
function profileStorage(platform, userHome, runner) {
|
|
13
|
+
return {
|
|
14
|
+
platform,
|
|
15
|
+
userHome,
|
|
16
|
+
...(platform === "win32"
|
|
17
|
+
? {
|
|
18
|
+
protector: windowsDpapiProtector,
|
|
19
|
+
harden: (path) => hardenWindowsProfile(path, runner),
|
|
20
|
+
}
|
|
21
|
+
: {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function sameHostname(left, right) {
|
|
25
|
+
return new URL(left).hostname.toLowerCase() === new URL(right).hostname.toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
function defaultDependencies() {
|
|
28
|
+
const platform = process.platform;
|
|
29
|
+
const userHome = homedir();
|
|
30
|
+
const runner = systemCommandRunner;
|
|
31
|
+
const storage = profileStorage(platform, userHome, runner);
|
|
32
|
+
let managedRecords = [];
|
|
33
|
+
let previousManagedRecord;
|
|
34
|
+
return {
|
|
35
|
+
platform,
|
|
36
|
+
userHome,
|
|
37
|
+
uid: process.getuid?.(),
|
|
38
|
+
nodePath: process.execPath,
|
|
39
|
+
version: daemonVersion(),
|
|
40
|
+
...(process.env.PATH ? { runtimePath: process.env.PATH } : {}),
|
|
41
|
+
runner,
|
|
42
|
+
captureProfile: async (target) => (await listProfiles(target.daemonHome)).includes(target.profile)
|
|
43
|
+
? loadProfile(target.profile, target.daemonHome, storage)
|
|
44
|
+
: null,
|
|
45
|
+
assertProfileTarget: async (target, previous) => {
|
|
46
|
+
if (previous !== null && !sameHostname(previous.serverUrl, target.serverUrl)) {
|
|
47
|
+
throw new Error(`Profile '${target.profile}' belongs to server hostname '${new URL(previous.serverUrl).hostname}'`);
|
|
48
|
+
}
|
|
49
|
+
await assertProfileAgentsRootUnique({
|
|
50
|
+
name: target.profile,
|
|
51
|
+
serverUrl: target.serverUrl,
|
|
52
|
+
agentsRoot: target.agentsRoot,
|
|
53
|
+
}, target.daemonHome, userHome, storage);
|
|
54
|
+
},
|
|
55
|
+
captureService: async (spec, target) => {
|
|
56
|
+
if (platform !== "win32") {
|
|
57
|
+
managedRecords = await readManagedServiceRegistry({ userHome });
|
|
58
|
+
const entryPath = spec.daemonCommand[1];
|
|
59
|
+
const installation = daemonGlobalInstallation(entryPath, platform);
|
|
60
|
+
if (installation === null) {
|
|
61
|
+
throw new Error(`Stable daemon package path is invalid: '${entryPath}'`);
|
|
62
|
+
}
|
|
63
|
+
const candidate = buildManagedServiceRecord({
|
|
64
|
+
spec,
|
|
65
|
+
daemonHome: target.daemonHome,
|
|
66
|
+
agentsRoot: target.agentsRoot,
|
|
67
|
+
nodePath: spec.daemonCommand[0],
|
|
68
|
+
entryPath,
|
|
69
|
+
...installation,
|
|
70
|
+
});
|
|
71
|
+
const { generation: _generation, createdAt: _createdAt, ...expected } = candidate;
|
|
72
|
+
previousManagedRecord = managedRecords.find((record) => managedServiceIdentityMatches(record, expected));
|
|
73
|
+
if (previousManagedRecord === undefined) {
|
|
74
|
+
const conflict = managedServiceConflict(managedRecords, candidate);
|
|
75
|
+
if (conflict !== null) {
|
|
76
|
+
throw new ManagedServiceConflictError(conflict.resource, candidate, conflict.owner);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return captureExactService(spec, runner);
|
|
81
|
+
},
|
|
82
|
+
discoverLegacyService: (spec, target) => discoverExactLegacyService({
|
|
83
|
+
desired: spec,
|
|
84
|
+
profileHome: target.daemonHome,
|
|
85
|
+
userHome,
|
|
86
|
+
uid: process.getuid?.(),
|
|
87
|
+
runner,
|
|
88
|
+
managedServices: managedRecords,
|
|
89
|
+
}),
|
|
90
|
+
stopService: (spec) => serviceAction(spec, "stop", runner),
|
|
91
|
+
acquireInstallationLease: (npmPrefix) => acquireDaemonInstallationLease(npmPrefix, { userHome }),
|
|
92
|
+
installPackage: installExactDaemonPackage,
|
|
93
|
+
saveTargetProfile: async (profile, daemonHome) => {
|
|
94
|
+
await saveProfile(profile, daemonHome, storage);
|
|
95
|
+
},
|
|
96
|
+
replaceService: async (spec, target) => {
|
|
97
|
+
if (platform === "win32") {
|
|
98
|
+
await replaceExactService(spec, runner);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const entryPath = stableDaemonEntry(target, platform);
|
|
102
|
+
const installation = daemonGlobalInstallation(entryPath, platform);
|
|
103
|
+
if (installation === null) {
|
|
104
|
+
throw new Error(`Stable daemon package is missing at '${entryPath}'`);
|
|
105
|
+
}
|
|
106
|
+
const record = buildManagedServiceRecord({
|
|
107
|
+
spec,
|
|
108
|
+
daemonHome: target.daemonHome,
|
|
109
|
+
agentsRoot: target.agentsRoot,
|
|
110
|
+
nodePath: process.execPath,
|
|
111
|
+
entryPath,
|
|
112
|
+
...installation,
|
|
113
|
+
});
|
|
114
|
+
await installManagedService({
|
|
115
|
+
spec,
|
|
116
|
+
record,
|
|
117
|
+
runner,
|
|
118
|
+
registry: { userHome },
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
removeLegacyService: async (legacy) => {
|
|
122
|
+
await uninstallService(legacy.spec, runner);
|
|
123
|
+
},
|
|
124
|
+
startService: (spec) => serviceAction(spec, "start", runner),
|
|
125
|
+
verifyService: async (spec) => {
|
|
126
|
+
const status = await waitForServiceRunning(spec, (candidate) => serviceStatus(candidate, runner));
|
|
127
|
+
if (!status.running)
|
|
128
|
+
return status;
|
|
129
|
+
const exact = await captureExactService(spec, runner);
|
|
130
|
+
if (!exact.exact)
|
|
131
|
+
throw new Error(`Service '${spec.id}' changed before verification`);
|
|
132
|
+
return { running: exact.running };
|
|
133
|
+
},
|
|
134
|
+
restoreProfile: async (target, previous) => {
|
|
135
|
+
if (previous === null)
|
|
136
|
+
await removeProfile(target.profile, target.daemonHome);
|
|
137
|
+
else
|
|
138
|
+
await saveProfile(previous, target.daemonHome, storage);
|
|
139
|
+
},
|
|
140
|
+
restoreService: async (spec, previous) => {
|
|
141
|
+
if (platform === "win32") {
|
|
142
|
+
await restoreExactService(spec, previous, runner);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const current = (await readManagedServiceRegistry({ userHome })).find((record) => record.serviceId === spec.id);
|
|
146
|
+
if (current !== undefined && JSON.stringify(current) !== JSON.stringify(previousManagedRecord)) {
|
|
147
|
+
await uninstallManagedService({
|
|
148
|
+
spec,
|
|
149
|
+
record: current,
|
|
150
|
+
runner,
|
|
151
|
+
registry: { userHome },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (previousManagedRecord !== undefined) {
|
|
155
|
+
await installManagedService({
|
|
156
|
+
spec,
|
|
157
|
+
record: previousManagedRecord,
|
|
158
|
+
runner,
|
|
159
|
+
registry: { userHome },
|
|
160
|
+
});
|
|
161
|
+
if (previous.running)
|
|
162
|
+
await serviceAction(spec, "start", runner);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await restoreExactService(spec, previous, runner);
|
|
166
|
+
},
|
|
167
|
+
restoreLegacyService: async (legacy) => {
|
|
168
|
+
await restoreExactService(legacy.spec, legacy.snapshot, runner);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function stableDaemonEntry(target, platform) {
|
|
173
|
+
const paths = platform === "win32" ? win32 : posix;
|
|
174
|
+
return platform === "win32"
|
|
175
|
+
? paths.join(target.npmPrefix, "node_modules", "@nowcrew", "daemon", "dist", "main.js")
|
|
176
|
+
: paths.join(target.npmPrefix, "lib", "node_modules", "@nowcrew", "daemon", "dist", "main.js");
|
|
177
|
+
}
|
|
178
|
+
export async function installExactDaemonPackage(npmPrefix, version, platform, runner) {
|
|
179
|
+
const command = platform === "win32" ? "npm.cmd" : "npm";
|
|
180
|
+
const result = await runner(command, [
|
|
181
|
+
"install",
|
|
182
|
+
"--global",
|
|
183
|
+
"--prefix",
|
|
184
|
+
npmPrefix,
|
|
185
|
+
"--ignore-scripts",
|
|
186
|
+
"--no-audit",
|
|
187
|
+
"--no-fund",
|
|
188
|
+
`@nowcrew/daemon@${version}`,
|
|
189
|
+
]);
|
|
190
|
+
if (result.exitCode !== 0) {
|
|
191
|
+
throw new Error(`Daemon package installation failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export async function waitForServiceRunning(spec, status, options = {}) {
|
|
195
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
196
|
+
const pollMs = options.pollMs ?? 100;
|
|
197
|
+
const now = options.now ?? Date.now;
|
|
198
|
+
const wait = options.wait ?? ((milliseconds) => new Promise((resolve) => {
|
|
199
|
+
setTimeout(resolve, milliseconds);
|
|
200
|
+
}));
|
|
201
|
+
const deadline = now() + timeoutMs;
|
|
202
|
+
while (true) {
|
|
203
|
+
const current = await status(spec);
|
|
204
|
+
if (current.running || now() >= deadline)
|
|
205
|
+
return current;
|
|
206
|
+
await wait(Math.min(pollMs, Math.max(0, deadline - now())));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function recoveryError(primary, failures) {
|
|
210
|
+
const original = primary instanceof Error ? primary : new Error(String(primary));
|
|
211
|
+
if (failures.length === 0)
|
|
212
|
+
return original;
|
|
213
|
+
return new Error(`${original.message}; recovery failed: ${failures.map((error) => error.message).join("; ")}`, { cause: original });
|
|
214
|
+
}
|
|
215
|
+
export async function installDaemonAutomatically(input, dependencies = defaultDependencies()) {
|
|
216
|
+
const machineToken = MachineTokenSchema.parse(input.machineToken);
|
|
217
|
+
const target = resolveAutomaticInstallTarget({
|
|
218
|
+
serverUrl: input.serverUrl,
|
|
219
|
+
...(input.requestedProfile === undefined ? {} : { requestedProfile: input.requestedProfile }),
|
|
220
|
+
userHome: dependencies.userHome,
|
|
221
|
+
platform: dependencies.platform,
|
|
222
|
+
});
|
|
223
|
+
const spec = buildServiceSpec({
|
|
224
|
+
platform: dependencies.platform,
|
|
225
|
+
profile: target.profile,
|
|
226
|
+
userHome: dependencies.userHome,
|
|
227
|
+
uid: dependencies.uid,
|
|
228
|
+
nodePath: dependencies.nodePath,
|
|
229
|
+
entryPath: stableDaemonEntry(target, dependencies.platform),
|
|
230
|
+
profileHome: target.daemonHome,
|
|
231
|
+
});
|
|
232
|
+
const previousProfile = await dependencies.captureProfile(target);
|
|
233
|
+
await dependencies.assertProfileTarget(target, previousProfile);
|
|
234
|
+
const previousService = await dependencies.captureService(spec, target);
|
|
235
|
+
if (previousService.installed && !previousService.exact) {
|
|
236
|
+
throw new Error(`Installed service '${spec.id}' does not match the requested identity`);
|
|
237
|
+
}
|
|
238
|
+
const legacyService = await dependencies.discoverLegacyService(spec, target);
|
|
239
|
+
if (previousService.installed && legacyService !== null) {
|
|
240
|
+
throw new Error(`Both managed service '${spec.id}' and legacy service '${legacyService.spec.id}' are installed`);
|
|
241
|
+
}
|
|
242
|
+
let stopped = false;
|
|
243
|
+
let legacyRemovalStarted = false;
|
|
244
|
+
let profileSaved = false;
|
|
245
|
+
let serviceReplacementStarted = false;
|
|
246
|
+
try {
|
|
247
|
+
if (previousService.running) {
|
|
248
|
+
stopped = true;
|
|
249
|
+
await dependencies.stopService(spec);
|
|
250
|
+
}
|
|
251
|
+
const lease = await dependencies.acquireInstallationLease(target.npmPrefix);
|
|
252
|
+
let installFailure;
|
|
253
|
+
try {
|
|
254
|
+
await dependencies.installPackage(target.npmPrefix, dependencies.version, dependencies.platform, dependencies.runner);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
installFailure = error;
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
try {
|
|
261
|
+
await lease.close();
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (installFailure === undefined)
|
|
265
|
+
installFailure = error;
|
|
266
|
+
else
|
|
267
|
+
installFailure = new Error(`${installFailure.message}; installation lease release failed: ${error.message}`, { cause: installFailure });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (installFailure !== undefined)
|
|
271
|
+
throw installFailure;
|
|
272
|
+
if (legacyService !== null) {
|
|
273
|
+
legacyRemovalStarted = true;
|
|
274
|
+
await dependencies.removeLegacyService(legacyService);
|
|
275
|
+
}
|
|
276
|
+
profileSaved = true;
|
|
277
|
+
await dependencies.saveTargetProfile({
|
|
278
|
+
version: 1,
|
|
279
|
+
name: target.profile,
|
|
280
|
+
serverUrl: target.serverUrl,
|
|
281
|
+
machineToken,
|
|
282
|
+
agentsRoot: target.agentsRoot,
|
|
283
|
+
...(dependencies.runtimePath === undefined ? {} : { runtimePath: dependencies.runtimePath }),
|
|
284
|
+
}, target.daemonHome);
|
|
285
|
+
serviceReplacementStarted = true;
|
|
286
|
+
await dependencies.replaceService(spec, target);
|
|
287
|
+
await dependencies.startService(spec);
|
|
288
|
+
const status = await dependencies.verifyService(spec);
|
|
289
|
+
if (!status.running)
|
|
290
|
+
throw new Error(`Service '${spec.id}' did not reach running state`);
|
|
291
|
+
return {
|
|
292
|
+
profile: target.profile,
|
|
293
|
+
version: dependencies.version,
|
|
294
|
+
serviceId: spec.id,
|
|
295
|
+
running: true,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
const failures = [];
|
|
300
|
+
if (profileSaved) {
|
|
301
|
+
try {
|
|
302
|
+
await dependencies.restoreProfile(target, previousProfile);
|
|
303
|
+
}
|
|
304
|
+
catch (restoreError) {
|
|
305
|
+
failures.push(restoreError);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (serviceReplacementStarted || stopped) {
|
|
309
|
+
try {
|
|
310
|
+
await dependencies.restoreService(spec, previousService, target);
|
|
311
|
+
}
|
|
312
|
+
catch (restoreError) {
|
|
313
|
+
failures.push(restoreError);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (legacyRemovalStarted && legacyService !== null) {
|
|
317
|
+
try {
|
|
318
|
+
await dependencies.restoreLegacyService(legacyService);
|
|
319
|
+
}
|
|
320
|
+
catch (restoreError) {
|
|
321
|
+
failures.push(restoreError);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
throw recoveryError(error, failures);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { posix, win32 } from "node:path";
|
|
2
|
+
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
3
|
+
const KNOWN_TARGETS = {
|
|
4
|
+
"nowwork-in.nowcoder.com": {
|
|
5
|
+
profile: "in",
|
|
6
|
+
daemonDirectory: "daemon",
|
|
7
|
+
agentsDirectory: "agents",
|
|
8
|
+
},
|
|
9
|
+
"nowwork-dev.nowcoder.com": {
|
|
10
|
+
profile: "dev",
|
|
11
|
+
daemonDirectory: "daemon-dev",
|
|
12
|
+
agentsDirectory: "agents-dev",
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
function parseServerUrl(value) {
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = new URL(value);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error("Invalid server URL; expected an HTTP(S) URL");
|
|
22
|
+
}
|
|
23
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
24
|
+
|| parsed.username.length > 0
|
|
25
|
+
|| parsed.password.length > 0
|
|
26
|
+
|| value.length > 2048) {
|
|
27
|
+
throw new Error("Invalid server URL; expected an HTTP(S) URL without embedded credentials");
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
function normalizedServerUrl(parsed) {
|
|
32
|
+
const serialized = parsed.toString();
|
|
33
|
+
return parsed.pathname === "/" && parsed.search === "" && parsed.hash === ""
|
|
34
|
+
? serialized.slice(0, -1)
|
|
35
|
+
: serialized;
|
|
36
|
+
}
|
|
37
|
+
function requireCustomProfile(hostname, value) {
|
|
38
|
+
if (value === undefined) {
|
|
39
|
+
throw new Error(`Unknown server hostname '${hostname}'; rerun with --profile <name>`);
|
|
40
|
+
}
|
|
41
|
+
if (!PROFILE_NAME.test(value)) {
|
|
42
|
+
throw new Error("Invalid custom profile; expected a lowercase name of 1-48 letters, digits, '_' or '-'");
|
|
43
|
+
}
|
|
44
|
+
if (value === "in" || value === "dev") {
|
|
45
|
+
throw new Error(`Profile '${value}' is reserved for a known NowWork hostname`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
export function resolveAutomaticInstallTarget(input) {
|
|
50
|
+
const parsed = parseServerUrl(input.serverUrl);
|
|
51
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
52
|
+
const paths = input.platform === "win32" ? win32 : posix;
|
|
53
|
+
const known = KNOWN_TARGETS[hostname];
|
|
54
|
+
if (known !== undefined) {
|
|
55
|
+
if (input.requestedProfile !== undefined && input.requestedProfile !== known.profile) {
|
|
56
|
+
throw new Error(`Server hostname '${hostname}' requires profile '${known.profile}'`);
|
|
57
|
+
}
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
kind: "known",
|
|
60
|
+
profile: known.profile,
|
|
61
|
+
serverUrl: normalizedServerUrl(parsed),
|
|
62
|
+
daemonHome: paths.join(input.userHome, ".crew", known.daemonDirectory),
|
|
63
|
+
agentsRoot: paths.join(input.userHome, ".crew", known.agentsDirectory),
|
|
64
|
+
npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", known.profile),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const profile = requireCustomProfile(hostname, input.requestedProfile);
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
kind: "custom",
|
|
70
|
+
profile,
|
|
71
|
+
serverUrl: normalizedServerUrl(parsed),
|
|
72
|
+
daemonHome: paths.join(input.userHome, ".crew", `daemon-${profile}`),
|
|
73
|
+
agentsRoot: paths.join(input.userHome, ".crew", `agents-${profile}`),
|
|
74
|
+
npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", profile),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
import { buildServiceSpec, serviceStatus, windowsCommandArg, } from "./computer-service.js";
|
|
4
|
+
import { daemonGlobalInstallation } from "./daemon-installation.js";
|
|
5
|
+
import { inspectLegacyServiceInstallationDetails } from "./legacy-service-installation.js";
|
|
6
|
+
function encoded(value) {
|
|
7
|
+
return Buffer.from(value, "utf16le").toString("base64");
|
|
8
|
+
}
|
|
9
|
+
function samePath(left, right, platform) {
|
|
10
|
+
const paths = platform === "win32" ? win32 : posix;
|
|
11
|
+
const normalizedLeft = paths.resolve(left);
|
|
12
|
+
const normalizedRight = paths.resolve(right);
|
|
13
|
+
return platform === "win32"
|
|
14
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
15
|
+
: normalizedLeft === normalizedRight;
|
|
16
|
+
}
|
|
17
|
+
function legacyIdentity(input) {
|
|
18
|
+
return buildServiceSpec({
|
|
19
|
+
platform: input.desired.platform,
|
|
20
|
+
profile: input.desired.profile,
|
|
21
|
+
userHome: input.userHome,
|
|
22
|
+
uid: input.uid,
|
|
23
|
+
nodePath: input.desired.daemonCommand[0],
|
|
24
|
+
entryPath: input.desired.daemonCommand[1],
|
|
25
|
+
profileHome: input.profileHome,
|
|
26
|
+
legacyServiceId: true,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function parseGeneratedSystemdArguments(descriptor) {
|
|
30
|
+
const line = /^ExecStart=(.+)$/m.exec(descriptor)?.[1];
|
|
31
|
+
if (line === undefined)
|
|
32
|
+
return null;
|
|
33
|
+
const values = [];
|
|
34
|
+
let offset = 0;
|
|
35
|
+
while (offset < line.length) {
|
|
36
|
+
while (line[offset] === " ")
|
|
37
|
+
offset += 1;
|
|
38
|
+
if (offset >= line.length)
|
|
39
|
+
break;
|
|
40
|
+
if (line[offset] !== '"')
|
|
41
|
+
return null;
|
|
42
|
+
offset += 1;
|
|
43
|
+
let value = "";
|
|
44
|
+
let closed = false;
|
|
45
|
+
while (offset < line.length) {
|
|
46
|
+
const character = line[offset];
|
|
47
|
+
offset += 1;
|
|
48
|
+
if (character === '"') {
|
|
49
|
+
closed = true;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
if (character === "\\") {
|
|
53
|
+
if (offset >= line.length)
|
|
54
|
+
return null;
|
|
55
|
+
value += line[offset];
|
|
56
|
+
offset += 1;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
value += character;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!closed || (offset < line.length && line[offset] !== " "))
|
|
63
|
+
return null;
|
|
64
|
+
values.push(value.replaceAll("%%", "%"));
|
|
65
|
+
}
|
|
66
|
+
return values;
|
|
67
|
+
}
|
|
68
|
+
async function discoverLinux(input, identity) {
|
|
69
|
+
if (identity.descriptorPath === null)
|
|
70
|
+
return null;
|
|
71
|
+
let descriptor;
|
|
72
|
+
try {
|
|
73
|
+
descriptor = await readFile(identity.descriptorPath, "utf8");
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === "ENOENT")
|
|
77
|
+
return null;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
const args = parseGeneratedSystemdArguments(descriptor);
|
|
81
|
+
if (args === null || args.length !== 7
|
|
82
|
+
|| args[2] !== "serve"
|
|
83
|
+
|| args[3] !== "--profile"
|
|
84
|
+
|| args[4] !== input.desired.profile
|
|
85
|
+
|| args[5] !== "--daemon-home"
|
|
86
|
+
|| !samePath(args[6], input.profileHome, "linux")
|
|
87
|
+
|| !posix.isAbsolute(args[0])
|
|
88
|
+
|| daemonGlobalInstallation(args[1], "linux") === null) {
|
|
89
|
+
throw new Error(`Legacy service '${identity.id}' does not match a generated NowCrew service`);
|
|
90
|
+
}
|
|
91
|
+
const spec = buildServiceSpec({
|
|
92
|
+
platform: "linux",
|
|
93
|
+
profile: input.desired.profile,
|
|
94
|
+
userHome: input.userHome,
|
|
95
|
+
uid: input.uid,
|
|
96
|
+
nodePath: args[0],
|
|
97
|
+
entryPath: args[1],
|
|
98
|
+
profileHome: input.profileHome,
|
|
99
|
+
legacyServiceId: true,
|
|
100
|
+
});
|
|
101
|
+
if (spec.descriptor !== descriptor) {
|
|
102
|
+
throw new Error(`Legacy service '${identity.id}' does not match a generated NowCrew service`);
|
|
103
|
+
}
|
|
104
|
+
const status = await serviceStatus(spec, input.runner);
|
|
105
|
+
return { spec, snapshot: { installed: true, running: status.running } };
|
|
106
|
+
}
|
|
107
|
+
async function discoverDarwin(input, identity) {
|
|
108
|
+
if (identity.descriptorPath === null)
|
|
109
|
+
return null;
|
|
110
|
+
const details = await inspectLegacyServiceInstallationDetails(identity.descriptorPath, "darwin", input.userHome, input.uid, input.runner);
|
|
111
|
+
if (details === null) {
|
|
112
|
+
try {
|
|
113
|
+
await readFile(identity.descriptorPath, "utf8");
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error.code === "ENOENT")
|
|
117
|
+
return null;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`Legacy service '${identity.id}' does not match a generated NowCrew service`);
|
|
121
|
+
}
|
|
122
|
+
if (details.profile !== input.desired.profile
|
|
123
|
+
|| !samePath(details.profileHome, input.profileHome, "darwin")) {
|
|
124
|
+
throw new Error(`Legacy service '${identity.id}' does not match the selected profile`);
|
|
125
|
+
}
|
|
126
|
+
const spec = buildServiceSpec({
|
|
127
|
+
platform: "darwin",
|
|
128
|
+
profile: details.profile,
|
|
129
|
+
userHome: input.userHome,
|
|
130
|
+
uid: input.uid,
|
|
131
|
+
nodePath: details.nodePath,
|
|
132
|
+
entryPath: details.entryPath,
|
|
133
|
+
profileHome: details.profileHome,
|
|
134
|
+
legacyServiceId: true,
|
|
135
|
+
});
|
|
136
|
+
const status = await serviceStatus(spec, input.runner);
|
|
137
|
+
return { spec, snapshot: { installed: true, running: status.running } };
|
|
138
|
+
}
|
|
139
|
+
async function discoverWindows(input, identity) {
|
|
140
|
+
const script = [
|
|
141
|
+
"$ErrorActionPreference='Stop';",
|
|
142
|
+
"$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[0]));",
|
|
143
|
+
"$tasks=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});",
|
|
144
|
+
"if($tasks.Count -ne 1){[pscustomobject]@{count=$tasks.Count}|ConvertTo-Json -Compress;exit 0};",
|
|
145
|
+
"$task=$tasks[0];$actions=@($task.Actions);$me=[Security.Principal.WindowsIdentity]::GetCurrent().Name;",
|
|
146
|
+
"[pscustomobject]@{count=1;state=[int]$task.State;actionCount=$actions.Count;execute=if($actions.Count -eq 1){$actions[0].Execute}else{$null};arguments=if($actions.Count -eq 1){$actions[0].Arguments}else{$null};principalMatches=([String]::Equals($task.Principal.UserId,$me,[StringComparison]::OrdinalIgnoreCase)-and [int]$task.Principal.LogonType -eq 3 -and [int]$task.Principal.RunLevel -eq 0)}|ConvertTo-Json -Compress;",
|
|
147
|
+
].join("");
|
|
148
|
+
const result = await input.runner("powershell.exe", [
|
|
149
|
+
"-NoProfile", "-NonInteractive", "-Command", script, encoded(identity.id),
|
|
150
|
+
]);
|
|
151
|
+
if (result.exitCode !== 0) {
|
|
152
|
+
throw new Error(`Unable to inspect legacy scheduled task: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
153
|
+
}
|
|
154
|
+
let definition;
|
|
155
|
+
try {
|
|
156
|
+
definition = JSON.parse(result.stdout);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw new Error("Legacy scheduled task returned invalid JSON");
|
|
160
|
+
}
|
|
161
|
+
if (definition.count === 0)
|
|
162
|
+
return null;
|
|
163
|
+
const match = typeof definition.arguments === "string"
|
|
164
|
+
? /^"([^"]+)" "serve" "--profile" "([^"]+)" "--daemon-home" "([^"]+)"$/.exec(definition.arguments)
|
|
165
|
+
: null;
|
|
166
|
+
if (definition.count !== 1
|
|
167
|
+
|| definition.actionCount !== 1
|
|
168
|
+
|| definition.principalMatches !== true
|
|
169
|
+
|| !win32.isAbsolute(definition.execute ?? "")
|
|
170
|
+
|| match === null
|
|
171
|
+
|| match[2] !== input.desired.profile
|
|
172
|
+
|| !samePath(match[3], input.profileHome, "win32")
|
|
173
|
+
|| daemonGlobalInstallation(match[1], "win32") === null) {
|
|
174
|
+
throw new Error(`Legacy service '${identity.id}' does not match a generated NowCrew service`);
|
|
175
|
+
}
|
|
176
|
+
const spec = buildServiceSpec({
|
|
177
|
+
platform: "win32",
|
|
178
|
+
profile: input.desired.profile,
|
|
179
|
+
userHome: input.userHome,
|
|
180
|
+
uid: input.uid,
|
|
181
|
+
nodePath: definition.execute,
|
|
182
|
+
entryPath: match[1],
|
|
183
|
+
profileHome: input.profileHome,
|
|
184
|
+
legacyServiceId: true,
|
|
185
|
+
});
|
|
186
|
+
if (definition.arguments !== spec.daemonCommand.slice(1).map(windowsCommandArg).join(" ")) {
|
|
187
|
+
throw new Error(`Legacy service '${identity.id}' does not match a generated NowCrew service`);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
spec,
|
|
191
|
+
snapshot: { installed: true, running: definition.state === 4 },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export async function discoverExactLegacyService(input) {
|
|
195
|
+
const identity = legacyIdentity(input);
|
|
196
|
+
if (identity.id === input.desired.id)
|
|
197
|
+
return null;
|
|
198
|
+
const legacy = input.desired.platform === "linux"
|
|
199
|
+
? await discoverLinux(input, identity)
|
|
200
|
+
: input.desired.platform === "darwin"
|
|
201
|
+
? await discoverDarwin(input, identity)
|
|
202
|
+
: await discoverWindows(input, identity);
|
|
203
|
+
if (legacy !== null && input.managedServices?.some((record) => record.platform === legacy.spec.platform
|
|
204
|
+
&& (record.serviceId === legacy.spec.id
|
|
205
|
+
|| (legacy.spec.descriptorPath !== null && record.descriptorPath === legacy.spec.descriptorPath))) === true) {
|
|
206
|
+
throw new Error(`Legacy service '${legacy.spec.id}' is registered and cannot be migrated automatically`);
|
|
207
|
+
}
|
|
208
|
+
return legacy;
|
|
209
|
+
}
|
package/dist/computer-cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { acquireDaemonInstallationLease, } from "./daemon-installation-lease.js"
|
|
|
12
12
|
import { assertManagedServiceIdentity, buildManagedServiceRecord, installManagedService, uninstallUnregisteredManagedService, uninstallManagedService, } from "./managed-service-lifecycle.js";
|
|
13
13
|
import { inspectManagedServiceDiagnostics } from "./managed-service-diagnostics.js";
|
|
14
14
|
import { readManagedServiceRegistry } from "./managed-service-registry.js";
|
|
15
|
+
import { installDaemonAutomatically, } from "./automatic-daemon-installer.js";
|
|
15
16
|
const COMPUTER_COMMANDS = new Set(["profile", "doctor", "install", "uninstall", "start", "stop", "restart", "status", "upgrade"]);
|
|
16
17
|
export function builtDaemonEntry(moduleUrl = import.meta.url) {
|
|
17
18
|
return moduleUrl.endsWith(".js")
|
|
@@ -37,6 +38,7 @@ function defaults() {
|
|
|
37
38
|
return result.stdout.trim();
|
|
38
39
|
},
|
|
39
40
|
acquireInstallationLease: acquireDaemonInstallationLease,
|
|
41
|
+
installAutomatically: installDaemonAutomatically,
|
|
40
42
|
readStdin: async () => {
|
|
41
43
|
if (process.stdin.isTTY)
|
|
42
44
|
throw new Error("--token-stdin requires a token on standard input");
|
|
@@ -57,6 +59,7 @@ function usage(td) {
|
|
|
57
59
|
td("Computer lifecycle:"),
|
|
58
60
|
" crew-daemon profile save <name> --server-url <url> [--agents-root <path>] [--token-stdin]",
|
|
59
61
|
" crew-daemon profile list|show|remove [<name>]",
|
|
62
|
+
" crew-daemon install --server-url <url> --api-key <sk_machine_*> [--profile <custom-name>]",
|
|
60
63
|
" crew-daemon doctor|install|uninstall|start|stop|restart|status --profile <name>",
|
|
61
64
|
" crew-daemon upgrade [--profile <name>]",
|
|
62
65
|
"",
|
|
@@ -96,6 +99,8 @@ export async function runComputerCommand(argv, overrides = {}) {
|
|
|
96
99
|
"server-url": { type: "string" },
|
|
97
100
|
"agents-root": { type: "string" },
|
|
98
101
|
"token-stdin": { type: "boolean", default: false },
|
|
102
|
+
"api-key": { type: "string" },
|
|
103
|
+
token: { type: "string" },
|
|
99
104
|
help: { type: "boolean", short: "h", default: false },
|
|
100
105
|
},
|
|
101
106
|
});
|
|
@@ -104,6 +109,36 @@ export async function runComputerCommand(argv, overrides = {}) {
|
|
|
104
109
|
return 0;
|
|
105
110
|
}
|
|
106
111
|
const [, action, positionalName] = parsed.positionals;
|
|
112
|
+
const profileName = parsed.values.profile;
|
|
113
|
+
const automaticInstall = command === "install" && parsed.values["server-url"] !== undefined;
|
|
114
|
+
const tokenOption = parsed.values["api-key"] !== undefined
|
|
115
|
+
? "--api-key"
|
|
116
|
+
: parsed.values.token !== undefined
|
|
117
|
+
? "--token"
|
|
118
|
+
: null;
|
|
119
|
+
if (tokenOption !== null && !automaticInstall) {
|
|
120
|
+
throw new Error(`Unknown option '${tokenOption}'`);
|
|
121
|
+
}
|
|
122
|
+
if (automaticInstall) {
|
|
123
|
+
if (parsed.values["agents-root"] !== undefined || parsed.values["token-stdin"]) {
|
|
124
|
+
throw new Error(td("Automatic installation derives its directories and accepts the machine token through --api-key"));
|
|
125
|
+
}
|
|
126
|
+
if (parsed.values["api-key"] !== undefined && parsed.values.token !== undefined) {
|
|
127
|
+
throw new Error(td("Choose one machine token source: --api-key or --token"));
|
|
128
|
+
}
|
|
129
|
+
const machineToken = (parsed.values["api-key"]
|
|
130
|
+
?? parsed.values.token
|
|
131
|
+
?? deps.env.CREW_MACHINE_TOKEN)?.trim();
|
|
132
|
+
if (!machineToken)
|
|
133
|
+
throw new Error(td("Missing machine token for automatic installation"));
|
|
134
|
+
const result = await deps.installAutomatically({
|
|
135
|
+
serverUrl: parsed.values["server-url"],
|
|
136
|
+
machineToken,
|
|
137
|
+
...(profileName === undefined ? {} : { requestedProfile: profileName }),
|
|
138
|
+
});
|
|
139
|
+
deps.stdout(`${td("Installed daemon {{version}} for profile '{{profile}}'; service '{{service}}' is running.", { version: result.version, profile: result.profile, service: result.serviceId })}\n`);
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
107
142
|
const home = daemonHome(deps.env);
|
|
108
143
|
const profileStorage = {
|
|
109
144
|
platform: deps.platform,
|
|
@@ -157,7 +192,6 @@ export async function runComputerCommand(argv, overrides = {}) {
|
|
|
157
192
|
deps.stdout(`${td("Saved profile '{{name}}' with private credentials.", { name })}\n`);
|
|
158
193
|
return 0;
|
|
159
194
|
}
|
|
160
|
-
const profileName = parsed.values.profile;
|
|
161
195
|
if (command === "upgrade") {
|
|
162
196
|
const installation = daemonGlobalInstallation(deps.entryPath, deps.platform);
|
|
163
197
|
const restartProfile = profileName
|
package/dist/computer-profile.js
CHANGED
|
@@ -9,6 +9,7 @@ import { acquireProfileSaveLock, } from "./computer-profile-lock.js";
|
|
|
9
9
|
import { formatDaemonText } from "./i18n.js";
|
|
10
10
|
import { atomicPrivateWrite } from "./atomic-private-write.js";
|
|
11
11
|
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
12
|
+
export const MachineTokenSchema = z.string().startsWith("sk_machine_").max(4096);
|
|
12
13
|
const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
|
|
13
14
|
const url = new URL(value);
|
|
14
15
|
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
|
|
@@ -17,7 +18,7 @@ const ProfileSchema = z.object({
|
|
|
17
18
|
version: z.literal(1),
|
|
18
19
|
name: z.string().regex(PROFILE_NAME),
|
|
19
20
|
serverUrl: ServerUrlSchema,
|
|
20
|
-
machineToken:
|
|
21
|
+
machineToken: MachineTokenSchema,
|
|
21
22
|
agentsRoot: z.string().min(1).optional(),
|
|
22
23
|
runtimePath: z.string().min(1).max(32768).refine((value) => !value.includes("\0") && !value.includes("\n") && !value.includes("\r"), "runtimePath must be a single line").optional(),
|
|
23
24
|
}).strict();
|
package/dist/computer-service.js
CHANGED
|
@@ -137,6 +137,40 @@ async function checked(runner, command, args, action) {
|
|
|
137
137
|
}
|
|
138
138
|
return result;
|
|
139
139
|
}
|
|
140
|
+
function sameWindowsPath(left, right) {
|
|
141
|
+
return left !== undefined
|
|
142
|
+
&& left.replaceAll("/", "\\").toLowerCase() === right.replaceAll("/", "\\").toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
async function windowsTaskDefinitionMatches(spec, runner) {
|
|
145
|
+
const encodedName = Buffer.from(spec.id, "utf16le").toString("base64");
|
|
146
|
+
const script = [
|
|
147
|
+
"$ErrorActionPreference='Stop';",
|
|
148
|
+
"$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[0]));",
|
|
149
|
+
"$tasks=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});",
|
|
150
|
+
"if($tasks.Count -ne 1){[pscustomobject]@{count=$tasks.Count}|ConvertTo-Json -Compress;exit 0};",
|
|
151
|
+
"$task=$tasks[0];$actions=@($task.Actions);$me=[Security.Principal.WindowsIdentity]::GetCurrent().Name;",
|
|
152
|
+
"[pscustomobject]@{count=1;actionCount=$actions.Count;execute=if($actions.Count -eq 1){$actions[0].Execute}else{$null};arguments=if($actions.Count -eq 1){$actions[0].Arguments}else{$null};principalMatches=([String]::Equals($task.Principal.UserId,$me,[StringComparison]::OrdinalIgnoreCase)-and [int]$task.Principal.LogonType -eq 3 -and [int]$task.Principal.RunLevel -eq 0)}|ConvertTo-Json -Compress;",
|
|
153
|
+
].join("");
|
|
154
|
+
const result = await runner("powershell.exe", [
|
|
155
|
+
"-NoProfile", "-NonInteractive", "-Command", script, encodedName,
|
|
156
|
+
]);
|
|
157
|
+
if (result.exitCode !== 0) {
|
|
158
|
+
throw new Error(`Unable to inspect scheduled task definition: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
159
|
+
}
|
|
160
|
+
let definition;
|
|
161
|
+
try {
|
|
162
|
+
definition = JSON.parse(result.stdout);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new Error("Scheduled task definition returned invalid JSON");
|
|
166
|
+
}
|
|
167
|
+
const expectedArguments = spec.daemonCommand.slice(1).map(windowsCommandArg).join(" ");
|
|
168
|
+
return definition.count === 1
|
|
169
|
+
&& definition.actionCount === 1
|
|
170
|
+
&& definition.principalMatches === true
|
|
171
|
+
&& sameWindowsPath(definition.execute, spec.daemonCommand[0])
|
|
172
|
+
&& definition.arguments === expectedArguments;
|
|
173
|
+
}
|
|
140
174
|
async function windowsTaskState(spec, runner) {
|
|
141
175
|
const encodedName = Buffer.from(spec.id, "utf16le").toString("base64");
|
|
142
176
|
const script = [
|
|
@@ -413,6 +447,38 @@ export async function serviceStatus(spec, runner = systemCommandRunner) {
|
|
|
413
447
|
: result.exitCode === 0;
|
|
414
448
|
return { ...result, installed: true, loaded: spec.platform === "darwin" ? result.exitCode === 0 : true, running };
|
|
415
449
|
}
|
|
450
|
+
export async function captureExactService(spec, runner) {
|
|
451
|
+
const status = await serviceStatus(spec, runner);
|
|
452
|
+
if (!status.installed)
|
|
453
|
+
return { installed: false, running: false, exact: true };
|
|
454
|
+
const exact = spec.platform === "win32"
|
|
455
|
+
? await windowsTaskDefinitionMatches(spec, runner)
|
|
456
|
+
: spec.descriptor !== null && await readServiceDescriptor(spec) === spec.descriptor;
|
|
457
|
+
return { installed: true, running: status.running, exact };
|
|
458
|
+
}
|
|
459
|
+
export async function replaceExactService(spec, runner = systemCommandRunner) {
|
|
460
|
+
const snapshot = await captureExactService(spec, runner);
|
|
461
|
+
if (snapshot.installed && !snapshot.exact) {
|
|
462
|
+
throw new Error(`Installed service '${spec.id}' does not match the requested identity`);
|
|
463
|
+
}
|
|
464
|
+
if (snapshot.installed)
|
|
465
|
+
await uninstallService(spec, runner);
|
|
466
|
+
await installService(spec, runner);
|
|
467
|
+
return { installed: snapshot.installed, running: snapshot.running };
|
|
468
|
+
}
|
|
469
|
+
export async function restoreExactService(spec, snapshot, runner = systemCommandRunner) {
|
|
470
|
+
const current = await captureExactService(spec, runner);
|
|
471
|
+
if (current.installed && !current.exact) {
|
|
472
|
+
throw new Error(`Installed service '${spec.id}' changed before recovery`);
|
|
473
|
+
}
|
|
474
|
+
if (current.installed)
|
|
475
|
+
await uninstallService(spec, runner);
|
|
476
|
+
if (!snapshot.installed)
|
|
477
|
+
return;
|
|
478
|
+
await installService(spec, runner);
|
|
479
|
+
if (snapshot.running)
|
|
480
|
+
await serviceAction(spec, "start", runner);
|
|
481
|
+
}
|
|
416
482
|
export async function doctorService(spec, profilePrivate, runner = systemCommandRunner) {
|
|
417
483
|
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
418
484
|
const tool = spec.platform === "darwin" ? "launchctl" : spec.platform === "linux" ? "systemctl" : "schtasks.exe";
|
package/dist/i18n.js
CHANGED
|
@@ -33,6 +33,10 @@ const zh = {
|
|
|
33
33
|
"activities": "活动数",
|
|
34
34
|
"Computer lifecycle:": "电脑生命周期:",
|
|
35
35
|
"profile save reads the machine token from CREW_MACHINE_TOKEN, or stdin with --token-stdin.": "profile save 从 CREW_MACHINE_TOKEN 或 --token-stdin 的标准输入读取机器令牌。",
|
|
36
|
+
"Automatic installation derives its directories and accepts the machine token through --api-key": "自动安装会自行派生目录,并通过 --api-key 接收机器令牌",
|
|
37
|
+
"Choose one machine token source: --api-key or --token": "--api-key 与 --token 只能选择一种机器令牌来源",
|
|
38
|
+
"Missing machine token for automatic installation": "自动安装缺少机器令牌",
|
|
39
|
+
"Installed daemon {{version}} for profile '{{profile}}'; service '{{service}}' is running.": "已为配置 '{{profile}}' 安装 daemon {{version}};服务 '{{service}}' 正在运行。",
|
|
36
40
|
"Missing {{name}}": "缺少 {{name}}",
|
|
37
41
|
"Service '{{id}}' is still installed; uninstall it before removing the profile": "服务 '{{id}}' 仍已安装;请先卸载服务再删除配置",
|
|
38
42
|
"Removed profile '{{name}}'.": "已删除配置 '{{name}}'。",
|
|
@@ -18,7 +18,7 @@ const LegacyLaunchAgentSchema = z.object({
|
|
|
18
18
|
KeepAlive: z.literal(true),
|
|
19
19
|
ProcessType: z.literal("Background"),
|
|
20
20
|
}).strict();
|
|
21
|
-
export async function
|
|
21
|
+
export async function inspectLegacyServiceInstallationDetails(descriptorPath, platform, userHome = homedir(), uid = process.getuid?.(), runner = systemCommandRunner) {
|
|
22
22
|
if (platform !== "darwin" || uid === undefined)
|
|
23
23
|
return null;
|
|
24
24
|
const result = await runner("/usr/bin/plutil", ["-convert", "json", "-o", "-", descriptorPath]);
|
|
@@ -52,5 +52,15 @@ export async function inspectLegacyServiceInstallation(descriptorPath, platform,
|
|
|
52
52
|
return null;
|
|
53
53
|
if (await readFile(descriptorPath, "utf8").catch(() => null) !== spec.descriptor)
|
|
54
54
|
return null;
|
|
55
|
-
return {
|
|
55
|
+
return {
|
|
56
|
+
npmPrefix: installation.npmPrefix,
|
|
57
|
+
nodePath,
|
|
58
|
+
entryPath,
|
|
59
|
+
profile,
|
|
60
|
+
profileHome,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export async function inspectLegacyServiceInstallation(descriptorPath, platform, userHome = homedir(), uid = process.getuid?.(), runner = systemCommandRunner) {
|
|
64
|
+
const details = await inspectLegacyServiceInstallationDetails(descriptorPath, platform, userHome, uid, runner);
|
|
65
|
+
return details === null ? null : { npmPrefix: details.npmPrefix };
|
|
56
66
|
}
|