@nowcrew/daemon 0.5.43 → 0.5.45

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.
@@ -315,8 +315,30 @@ setTimeout(() => {
315
315
  let restarted;
316
316
  if (spec.platform === "darwin") {
317
317
  restarted = spawnSync("launchctl", ["kickstart", "-k", spec.target], { stdio: "ignore", shell: false });
318
- } else {
318
+ } else if (spec.platform === "linux") {
319
319
  restarted = spawnSync("systemctl", ["--user", "--no-block", "restart", spec.id], { stdio: "ignore", shell: false });
320
+ } else {
321
+ const encodedName = Buffer.from(spec.id, "utf16le").toString("base64");
322
+ const encodedExecutable = Buffer.from(spec.executable, "utf16le").toString("base64");
323
+ const encodedArguments = Buffer.from(spec.arguments, "utf16le").toString("base64");
324
+ const script = [
325
+ "$ErrorActionPreference='Stop';",
326
+ "$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[1]));",
327
+ "$exe=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[2]));",
328
+ "$argv=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[3]));",
329
+ "$task=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});if($task.Count -ne 1){throw 'scheduled task identity changed'};",
330
+ "$actions=@($task[0].Actions);if($actions.Count -ne 1 -or -not [String]::Equals($actions[0].Execute,$exe,[StringComparison]::OrdinalIgnoreCase) -or $actions[0].Arguments -cne $argv){throw 'scheduled task action changed'};",
331
+ "$p=Get-Process -Id ([int]$args[0]) -ErrorAction Stop;",
332
+ "if($p.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() -cne $args[4]){throw 'daemon process identity changed'};",
333
+ "$p.Kill();",
334
+ "if(-not $p.WaitForExit(10000)){throw 'daemon exit timeout'};",
335
+ "$deadline=[DateTime]::UtcNow.AddSeconds(10);",
336
+ "do{$task=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});if($task.Count -ne 1){throw 'scheduled task identity changed'};if([int]$task[0].State -ne 4){break};Start-Sleep -Milliseconds 100}while([DateTime]::UtcNow -lt $deadline);",
337
+ "if([int]$task[0].State -eq 4){throw 'scheduled task stop timeout'};",
338
+ "$actions=@($task[0].Actions);if($actions.Count -ne 1 -or -not [String]::Equals($actions[0].Execute,$exe,[StringComparison]::OrdinalIgnoreCase) -or $actions[0].Arguments -cne $argv){throw 'scheduled task action changed'};",
339
+ "& schtasks.exe /Run /TN $n | Out-Null;if($LASTEXITCODE -ne 0){throw 'scheduled task start failed'};",
340
+ ].join("");
341
+ restarted = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, String(spec.daemonPid), encodedName, encodedExecutable, encodedArguments, spec.processIdentity], { stdio: "ignore", shell: false });
320
342
  }
321
343
  if (restarted.status !== 0) process.send?.({ type: "restart-failed", status: restarted.status });
322
344
  setTimeout(() => {
@@ -326,16 +348,27 @@ setTimeout(() => {
326
348
  }, 250);
327
349
  `;
328
350
  export function scheduleServiceRestart(spec, spawnDetached = systemDetachedSpawn) {
329
- if (spec.platform === "win32")
330
- throw new Error("Windows detached restart is not supported");
331
- if (spec.descriptorPath === null)
351
+ if (spec.platform !== "win32" && spec.descriptorPath === null) {
332
352
  throw new Error("service descriptor is required for restart");
353
+ }
333
354
  const payload = spec.platform === "darwin"
334
355
  ? {
335
356
  platform: spec.platform,
336
357
  target: `${spec.managerDomain}/${spec.id}`,
337
358
  }
338
- : { platform: spec.platform, id: spec.id };
359
+ : spec.platform === "linux"
360
+ ? { platform: spec.platform, id: spec.id }
361
+ : {
362
+ platform: spec.platform,
363
+ id: spec.id,
364
+ daemonPid: process.pid,
365
+ executable: spec.daemonCommand[0],
366
+ arguments: spec.daemonCommand.slice(1).map(windowsCommandArg).join(" "),
367
+ processIdentity: spec.runtimeProcessIdentity,
368
+ };
369
+ if (spec.platform === "win32" && !payload.processIdentity) {
370
+ throw new Error("Windows daemon process identity is required for restart");
371
+ }
339
372
  const child = spawnDetached(process.execPath, ["-e", RESTART_HELPER_SOURCE, JSON.stringify(payload)], { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"], shell: false });
340
373
  child.unref();
341
374
  let settleFailure;
@@ -1,38 +1,55 @@
1
1
  import { realpathSync } from "node:fs";
2
- import { basename, dirname, resolve } from "node:path";
3
- function canonicalPath(path, realpath) {
2
+ import { posix, win32 } from "node:path";
3
+ function canonicalPath(path, platform, realpath) {
4
+ const paths = platform === "win32" ? win32 : posix;
4
5
  try {
5
6
  return realpath(path);
6
7
  }
7
8
  catch (error) {
8
9
  const code = error.code;
9
- return code === "ENOENT" || code === "ENOTDIR" ? resolve(path) : null;
10
+ return code === "ENOENT" || code === "ENOTDIR" ? paths.resolve(path) : null;
10
11
  }
11
12
  }
13
+ function samePath(left, right, platform) {
14
+ return platform === "win32"
15
+ ? left.toLowerCase() === right.toLowerCase()
16
+ : left === right;
17
+ }
12
18
  export function daemonGlobalInstallation(entryPath, platform, realpath = realpathSync.native) {
13
- if (platform !== "darwin" && platform !== "linux")
19
+ if (platform !== "darwin" && platform !== "linux" && platform !== "win32")
20
+ return null;
21
+ const paths = platform === "win32" ? win32 : posix;
22
+ const packageRoot = paths.resolve(paths.dirname(entryPath), "..");
23
+ const globalNodeModules = paths.resolve(packageRoot, "..", "..");
24
+ const modulesParent = paths.dirname(globalNodeModules);
25
+ if (paths.basename(globalNodeModules).toLowerCase() !== "node_modules")
14
26
  return null;
15
- const packageRoot = resolve(dirname(entryPath), "..");
16
- const globalNodeModules = resolve(packageRoot, "..", "..");
17
- const libDirectory = dirname(globalNodeModules);
18
- if (basename(globalNodeModules) !== "node_modules" || basename(libDirectory) !== "lib")
27
+ if (platform !== "win32" && paths.basename(modulesParent) !== "lib")
19
28
  return null;
20
- if (resolve(entryPath) !== resolve(packageRoot, "dist", "main.js"))
29
+ if (!samePath(paths.resolve(entryPath), paths.resolve(packageRoot, "dist", "main.js"), platform))
21
30
  return null;
22
- if (resolve(packageRoot) !== resolve(globalNodeModules, "@nowcrew", "daemon"))
31
+ if (!samePath(paths.resolve(packageRoot), paths.resolve(globalNodeModules, "@nowcrew", "daemon"), platform))
23
32
  return null;
24
- const canonicalPackageRoot = canonicalPath(packageRoot, realpath);
25
- const canonicalGlobalNodeModules = canonicalPath(globalNodeModules, realpath);
26
- const canonicalNpmPrefix = canonicalPath(dirname(libDirectory), realpath);
33
+ const npmPrefix = platform === "win32" ? modulesParent : paths.dirname(modulesParent);
34
+ const canonicalPackageRoot = canonicalPath(packageRoot, platform, realpath);
35
+ const canonicalGlobalNodeModules = canonicalPath(globalNodeModules, platform, realpath);
36
+ const canonicalNpmPrefix = canonicalPath(npmPrefix, platform, realpath);
27
37
  if (canonicalPackageRoot === null || canonicalGlobalNodeModules === null || canonicalNpmPrefix === null)
28
38
  return null;
29
- if (canonicalPackageRoot !== resolve(canonicalGlobalNodeModules, "@nowcrew", "daemon"))
39
+ const normalizeCanonical = (path) => paths.resolve(path);
40
+ const normalizedPackageRoot = normalizeCanonical(canonicalPackageRoot);
41
+ const normalizedGlobalNodeModules = normalizeCanonical(canonicalGlobalNodeModules);
42
+ const normalizedNpmPrefix = normalizeCanonical(canonicalNpmPrefix);
43
+ if (!samePath(normalizedPackageRoot, paths.resolve(normalizedGlobalNodeModules, "@nowcrew", "daemon"), platform))
30
44
  return null;
31
- if (canonicalGlobalNodeModules !== resolve(canonicalNpmPrefix, "lib", "node_modules"))
45
+ const expectedModules = platform === "win32"
46
+ ? paths.resolve(normalizedNpmPrefix, "node_modules")
47
+ : paths.resolve(normalizedNpmPrefix, "lib", "node_modules");
48
+ if (!samePath(normalizedGlobalNodeModules, expectedModules, platform))
32
49
  return null;
33
50
  return {
34
- packageRoot: canonicalPackageRoot,
35
- globalNodeModules: canonicalGlobalNodeModules,
36
- npmPrefix: canonicalNpmPrefix,
51
+ packageRoot: normalizedPackageRoot,
52
+ globalNodeModules: normalizedGlobalNodeModules,
53
+ npmPrefix: normalizedNpmPrefix,
37
54
  };
38
55
  }
@@ -1,5 +1,6 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
+ import { realpathSync } from "node:fs";
3
4
  import { homedir } from "node:os";
4
5
  import { daemonHome, loadProfile, resolveAgentsRoot } from "./computer-profile.js";
5
6
  import { builtDaemonEntry } from "./computer-cli.js";
@@ -7,6 +8,7 @@ import { buildServiceSpec, readServiceDescriptor, serviceStatus, } from "./compu
7
8
  import { daemonGlobalInstallation } from "./daemon-installation.js";
8
9
  import { daemonInstallationLeaseHeld } from "./daemon-installation-lease.js";
9
10
  import { inspectLegacyServiceInstallation } from "./legacy-service-installation.js";
11
+ import { inspectWindowsServiceUpdateScope } from "./windows-scheduled-task.js";
10
12
  import { assertRegistryCoversDescriptors, listManagedServiceDescriptorPaths, managedServiceIdentityMatches, readManagedServiceRegistry, serviceDescriptorSha256, } from "./managed-service-registry.js";
11
13
  function defaults() {
12
14
  return {
@@ -23,6 +25,8 @@ function defaults() {
23
25
  serviceStatus,
24
26
  assertWritable: (path) => access(path, constants.W_OK),
25
27
  installationLeaseHeld: daemonInstallationLeaseHeld,
28
+ installationRealpath: realpathSync.native,
29
+ inspectWindowsServiceUpdateScope,
26
30
  inspectLegacyDescriptorInstallation: (descriptorPath) => inspectLegacyServiceInstallation(descriptorPath, process.platform, homedir(), process.getuid?.()),
27
31
  };
28
32
  }
@@ -44,12 +48,12 @@ async function registryCoversUpdateScope(services, descriptorPaths, targetDescri
44
48
  }
45
49
  export async function detectDaemonUpdateEligibility(profileName, overrides = {}) {
46
50
  const deps = { ...defaults(), ...overrides };
47
- if (deps.platform !== "darwin" && deps.platform !== "linux") {
51
+ if (deps.platform !== "darwin" && deps.platform !== "linux" && deps.platform !== "win32") {
48
52
  return { eligible: false, reason: "unsupported_platform" };
49
53
  }
50
54
  if (!profileName)
51
55
  return { eligible: false, reason: "profile_required" };
52
- const installation = daemonGlobalInstallation(deps.entryPath, deps.platform);
56
+ const installation = daemonGlobalInstallation(deps.entryPath, deps.platform, deps.installationRealpath);
53
57
  if (installation === null)
54
58
  return { eligible: false, reason: "global_install_required" };
55
59
  let profile;
@@ -71,6 +75,33 @@ export async function detectDaemonUpdateEligibility(profileName, overrides = {})
71
75
  entryPath: deps.entryPath,
72
76
  profileHome: deps.profileHome,
73
77
  });
78
+ if (deps.platform === "win32") {
79
+ const scope = await deps.inspectWindowsServiceUpdateScope(spec, installation.npmPrefix)
80
+ .catch(() => ({ valid: false }));
81
+ if (!scope.valid) {
82
+ return { eligible: false, reason: "managed_service_identity_mismatch" };
83
+ }
84
+ if (!scope.running)
85
+ return { eligible: false, reason: "service_not_running" };
86
+ if (!scope.processIdentity) {
87
+ return { eligible: false, reason: "managed_service_identity_mismatch" };
88
+ }
89
+ if (!deps.installationLeaseHeld(installation.npmPrefix)) {
90
+ return { eligible: false, reason: "installation_lease_missing" };
91
+ }
92
+ try {
93
+ await deps.assertWritable(installation.globalNodeModules);
94
+ }
95
+ catch {
96
+ return { eligible: false, reason: "global_root_not_writable" };
97
+ }
98
+ return {
99
+ eligible: true,
100
+ profileName,
101
+ ...installation,
102
+ serviceSpec: { ...spec, runtimeProcessIdentity: scope.processIdentity },
103
+ };
104
+ }
74
105
  let selectedSpec = spec;
75
106
  let status = await deps.serviceStatus(spec);
76
107
  let services;
@@ -48,7 +48,7 @@ export async function installExactDaemonUpdate(input) {
48
48
  await release();
49
49
  return { ok: false, errorCode: "install_failed" };
50
50
  }
51
- const result = await runner(process.platform === "win32" ? "npm.cmd" : "npm", [
51
+ const result = await runner((input.platform ?? process.platform) === "win32" ? "npm.cmd" : "npm", [
52
52
  "install",
53
53
  "--global",
54
54
  "--prefix",
@@ -0,0 +1,64 @@
1
+ import { systemCommandRunner, windowsCommandArg, } from "./computer-service.js";
2
+ import { win32 } from "node:path";
3
+ function encoded(value) {
4
+ return Buffer.from(value, "utf16le").toString("base64");
5
+ }
6
+ function sameWindowsPath(left, right) {
7
+ if (left === undefined)
8
+ return false;
9
+ return left.replaceAll("/", "\\").toLowerCase()
10
+ === right.replaceAll("/", "\\").toLowerCase();
11
+ }
12
+ export async function inspectWindowsServiceUpdateScope(spec, npmPrefix, daemonPid = process.pid, runner = systemCommandRunner) {
13
+ if (spec.platform !== "win32")
14
+ return { valid: false };
15
+ const expectedArguments = spec.daemonCommand.slice(1).map(windowsCommandArg).join(" ");
16
+ const entryMarker = windowsCommandArg(win32.resolve(npmPrefix, "node_modules", "@nowcrew", "daemon", "dist", "main.js"));
17
+ const script = [
18
+ "$ErrorActionPreference='Stop';",
19
+ "$n=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[0]));",
20
+ "$entry=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[1]));",
21
+ "$pidValue=[int]$args[2];",
22
+ "$tasks=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -ceq $n});",
23
+ "if($tasks.Count -ne 1){[pscustomobject]@{count=$tasks.Count}|ConvertTo-Json -Compress;exit 0};",
24
+ "$task=$tasks[0];",
25
+ "$scheduler=New-Object -ComObject 'Schedule.Service';$scheduler.Connect();",
26
+ "$registered=$scheduler.GetFolder('\\').GetTask($n);$instances=@($registered.GetInstances(0));",
27
+ "$current=Get-CimInstance Win32_Process -Filter ('ProcessId='+$pidValue) -ErrorAction Stop;",
28
+ "$ancestors=@();$cursor=$current;while($cursor.ParentProcessId -gt 0){$cursor=Get-CimInstance Win32_Process -Filter ('ProcessId='+$cursor.ParentProcessId) -ErrorAction SilentlyContinue;if($null -eq $cursor){break};$ancestors+=[int64]$cursor.ProcessId};",
29
+ "$processIdentity=(Get-Process -Id $pidValue -ErrorAction Stop).StartTime.ToUniversalTime().ToFileTimeUtc().ToString();",
30
+ "$actions=@($task.Actions);",
31
+ "$others=@(Get-ScheduledTask -ErrorAction Stop|Where-Object{$_.TaskPath -eq '\\' -and $_.TaskName -like 'NowCrew Daemon (*)' -and $_.TaskName -cne $n});",
32
+ "$conflicts=@($others|Where-Object{@($_.Actions|Where-Object{$_.Arguments.IndexOf($entry,[StringComparison]::OrdinalIgnoreCase) -ge 0}).Count -gt 0});",
33
+ "$enginePid=if($instances.Count -eq 1){[int64]$instances[0].EnginePID}else{0};",
34
+ "[pscustomobject]@{count=1;state=[int]$task.State;execute=if($actions.Count -eq 1){$actions[0].Execute}else{$null};arguments=if($actions.Count -eq 1){$actions[0].Arguments}else{$null};instanceCount=$instances.Count;enginePid=$enginePid;engineOwned=($enginePid -eq $pidValue -or $ancestors -contains $enginePid);processIdentity=$processIdentity;conflictingPrefixes=$conflicts.Count}|ConvertTo-Json -Compress;",
35
+ ].join("");
36
+ const result = await runner("powershell.exe", [
37
+ "-NoProfile", "-NonInteractive", "-Command", script,
38
+ encoded(spec.id), encoded(entryMarker), String(daemonPid),
39
+ ]);
40
+ if (result.exitCode !== 0)
41
+ return { valid: false };
42
+ let inspection;
43
+ try {
44
+ inspection = JSON.parse(result.stdout);
45
+ }
46
+ catch {
47
+ return { valid: false };
48
+ }
49
+ const running = inspection.state === 4;
50
+ if (inspection.count !== 1
51
+ || (running && (inspection.instanceCount !== 1 || inspection.engineOwned !== true
52
+ || typeof inspection.processIdentity !== "string" || inspection.processIdentity.length === 0))
53
+ || (!running && inspection.instanceCount !== 0)
54
+ || inspection.conflictingPrefixes !== 0
55
+ || !sameWindowsPath(inspection.execute, spec.daemonCommand[0])
56
+ || inspection.arguments !== expectedArguments) {
57
+ return { valid: false };
58
+ }
59
+ return {
60
+ valid: true,
61
+ running,
62
+ ...(running ? { processIdentity: inspection.processIdentity } : {}),
63
+ };
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.5.43",
3
+ "version": "0.5.45",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",