@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.
@@ -0,0 +1,38 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { basename, dirname, resolve } from "node:path";
3
+ function canonicalPath(path, realpath) {
4
+ try {
5
+ return realpath(path);
6
+ }
7
+ catch (error) {
8
+ const code = error.code;
9
+ return code === "ENOENT" || code === "ENOTDIR" ? resolve(path) : null;
10
+ }
11
+ }
12
+ export function daemonGlobalInstallation(entryPath, platform, realpath = realpathSync.native) {
13
+ if (platform !== "darwin" && platform !== "linux")
14
+ 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")
19
+ return null;
20
+ if (resolve(entryPath) !== resolve(packageRoot, "dist", "main.js"))
21
+ return null;
22
+ if (resolve(packageRoot) !== resolve(globalNodeModules, "@nowcrew", "daemon"))
23
+ return null;
24
+ const canonicalPackageRoot = canonicalPath(packageRoot, realpath);
25
+ const canonicalGlobalNodeModules = canonicalPath(globalNodeModules, realpath);
26
+ const canonicalNpmPrefix = canonicalPath(dirname(libDirectory), realpath);
27
+ if (canonicalPackageRoot === null || canonicalGlobalNodeModules === null || canonicalNpmPrefix === null)
28
+ return null;
29
+ if (canonicalPackageRoot !== resolve(canonicalGlobalNodeModules, "@nowcrew", "daemon"))
30
+ return null;
31
+ if (canonicalGlobalNodeModules !== resolve(canonicalNpmPrefix, "lib", "node_modules"))
32
+ return null;
33
+ return {
34
+ packageRoot: canonicalPackageRoot,
35
+ globalNodeModules: canonicalGlobalNodeModules,
36
+ npmPrefix: canonicalNpmPrefix,
37
+ };
38
+ }
@@ -7,6 +7,7 @@ const DaemonUpdateMessageSchema = z.object({
7
7
  export function createDaemonUpdateController(deps) {
8
8
  const handled = new Set();
9
9
  let running = null;
10
+ let restartHandoff = null;
10
11
  const failed = (updateId, errorCode) => {
11
12
  deps.sendStatus({ type: "daemon:update-status", updateId, status: "failed", errorCode });
12
13
  };
@@ -19,6 +20,7 @@ export function createDaemonUpdateController(deps) {
19
20
  const installed = await deps.install({
20
21
  targetVersion: message.targetVersion,
21
22
  packageRoot: eligibility.packageRoot,
23
+ npmPrefix: eligibility.npmPrefix,
22
24
  onInstalling: () => {
23
25
  deps.sendStatus({ type: "daemon:update-status", updateId: message.updateId, status: "installing" });
24
26
  },
@@ -27,16 +29,42 @@ export function createDaemonUpdateController(deps) {
27
29
  failed(message.updateId, installed.errorCode);
28
30
  return;
29
31
  }
32
+ let released = false;
33
+ const releaseOnce = async () => {
34
+ if (released)
35
+ return;
36
+ released = true;
37
+ await installed.release();
38
+ };
30
39
  deps.sendStatus({ type: "daemon:update-status", updateId: message.updateId, status: "restarting" });
31
40
  try {
32
- deps.scheduleRestart(eligibility.serviceSpec);
41
+ const handoff = deps.scheduleRestart(eligibility.serviceSpec);
42
+ const state = {
43
+ release: releaseOnce,
44
+ shutdownBegun: false,
45
+ failureTask: Promise.resolve(),
46
+ };
47
+ state.failureTask = handoff.failed.then(async () => {
48
+ if (state.shutdownBegun)
49
+ return;
50
+ await state.release();
51
+ failed(message.updateId, "restart_failed");
52
+ });
53
+ restartHandoff = state;
33
54
  }
34
55
  catch {
35
- await installed.release();
56
+ await releaseOnce();
36
57
  failed(message.updateId, "restart_failed");
37
58
  }
38
59
  };
39
60
  return {
61
+ drain: async () => {
62
+ await running?.done;
63
+ if (restartHandoff !== null) {
64
+ restartHandoff.shutdownBegun = true;
65
+ await restartHandoff.release();
66
+ }
67
+ },
40
68
  handle: async (input) => {
41
69
  const parsed = DaemonUpdateMessageSchema.safeParse(input);
42
70
  if (!parsed.success)
@@ -1,12 +1,13 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { resolve } from "node:path";
5
- import { daemonHome, listProfiles } from "./computer-profile.js";
6
- import { builtDaemonEntry, isGlobalDaemonEntry } from "./computer-cli.js";
7
- import { buildServiceSpec, serviceStatus, systemCommandRunner, } from "./computer-service.js";
4
+ import { daemonHome, loadProfile, resolveAgentsRoot } from "./computer-profile.js";
5
+ import { builtDaemonEntry } from "./computer-cli.js";
6
+ import { buildServiceSpec, readServiceDescriptor, serviceStatus, } from "./computer-service.js";
7
+ import { daemonGlobalInstallation } from "./daemon-installation.js";
8
+ import { daemonInstallationLeaseHeld } from "./daemon-installation-lease.js";
9
+ import { assertRegistryCoversDescriptors, listManagedServiceDescriptorPaths, managedServiceIdentityMatches, readManagedServiceRegistry, serviceDescriptorSha256, } from "./managed-service-registry.js";
8
10
  function defaults() {
9
- const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
10
11
  return {
11
12
  platform: process.platform,
12
13
  profileHome: daemonHome(),
@@ -14,16 +15,13 @@ function defaults() {
14
15
  uid: process.getuid?.(),
15
16
  nodePath: process.execPath,
16
17
  entryPath: builtDaemonEntry(),
17
- resolveGlobalNodeModules: async () => {
18
- const result = await systemCommandRunner(npmCommand, ["root", "--global"]);
19
- if (result.exitCode !== 0 || !result.stdout.trim()) {
20
- throw new Error(result.stderr.trim() || "global npm root unavailable");
21
- }
22
- return result.stdout.trim();
23
- },
24
- listProfiles,
18
+ loadProfile,
19
+ readManagedServices: () => readManagedServiceRegistry(),
20
+ listManagedDescriptorPaths: () => listManagedServiceDescriptorPaths(process.platform, homedir()),
21
+ readServiceDescriptor,
25
22
  serviceStatus,
26
23
  assertWritable: (path) => access(path, constants.W_OK),
24
+ installationLeaseHeld: daemonInstallationLeaseHeld,
27
25
  };
28
26
  }
29
27
  export async function detectDaemonUpdateEligibility(profileName, overrides = {}) {
@@ -33,39 +31,73 @@ export async function detectDaemonUpdateEligibility(profileName, overrides = {})
33
31
  }
34
32
  if (!profileName)
35
33
  return { eligible: false, reason: "profile_required" };
36
- let globalNodeModules;
34
+ const installation = daemonGlobalInstallation(deps.entryPath, deps.platform);
35
+ if (installation === null)
36
+ return { eligible: false, reason: "global_install_required" };
37
+ let profile;
37
38
  try {
38
- globalNodeModules = await deps.resolveGlobalNodeModules();
39
+ profile = await deps.loadProfile(profileName, deps.profileHome, {
40
+ platform: deps.platform,
41
+ userHome: deps.userHome,
42
+ });
39
43
  }
40
44
  catch {
41
- return { eligible: false, reason: "global_install_required" };
45
+ return { eligible: false, reason: "managed_service_identity_mismatch" };
42
46
  }
43
- if (!isGlobalDaemonEntry(deps.entryPath, globalNodeModules)) {
44
- return { eligible: false, reason: "global_install_required" };
47
+ const spec = buildServiceSpec({
48
+ platform: deps.platform,
49
+ profile: profileName,
50
+ userHome: deps.userHome,
51
+ uid: deps.uid,
52
+ nodePath: deps.nodePath,
53
+ entryPath: deps.entryPath,
54
+ profileHome: deps.profileHome,
55
+ });
56
+ const status = await deps.serviceStatus(spec);
57
+ if (!status.running)
58
+ return { eligible: false, reason: "service_not_running" };
59
+ let services;
60
+ try {
61
+ services = await deps.readManagedServices();
62
+ }
63
+ catch {
64
+ return { eligible: false, reason: "managed_service_registry_invalid" };
45
65
  }
46
- const profiles = await deps.listProfiles(deps.profileHome);
47
- const installed = [];
48
- for (const profile of profiles) {
49
- const spec = buildServiceSpec({
66
+ const registered = services.find((record) => record.serviceId === spec.id
67
+ || (record.daemonHome === deps.profileHome && record.profile === profileName));
68
+ if (registered === undefined) {
69
+ return { eligible: false, reason: "managed_service_not_registered" };
70
+ }
71
+ try {
72
+ assertRegistryCoversDescriptors(services, await deps.listManagedDescriptorPaths());
73
+ }
74
+ catch {
75
+ return { eligible: false, reason: "managed_service_registry_incomplete" };
76
+ }
77
+ const descriptor = await deps.readServiceDescriptor(spec).catch(() => null);
78
+ if (descriptor === null || spec.descriptorPath === null || spec.descriptor === null
79
+ || !managedServiceIdentityMatches(registered, {
80
+ version: 1,
50
81
  platform: deps.platform,
51
- profile,
52
- userHome: deps.userHome,
53
- uid: deps.uid,
82
+ serviceId: spec.id,
83
+ profile: profileName,
84
+ daemonHome: deps.profileHome,
85
+ agentsRoot: resolveAgentsRoot(profile.agentsRoot, deps.userHome, deps.platform),
54
86
  nodePath: deps.nodePath,
55
87
  entryPath: deps.entryPath,
56
- profileHome: deps.profileHome,
57
- });
58
- const status = await deps.serviceStatus(spec);
59
- if (status.installed)
60
- installed.push({ profile, spec, running: status.running });
88
+ packageRoot: installation.packageRoot,
89
+ npmPrefix: installation.npmPrefix,
90
+ descriptorPath: spec.descriptorPath,
91
+ descriptorSha256: serviceDescriptorSha256(descriptor),
92
+ })
93
+ || descriptor !== spec.descriptor) {
94
+ return { eligible: false, reason: "managed_service_identity_mismatch" };
95
+ }
96
+ if (!deps.installationLeaseHeld(installation.npmPrefix)) {
97
+ return { eligible: false, reason: "installation_lease_missing" };
61
98
  }
62
- const current = installed.find((entry) => entry.profile === profileName);
63
- if (!current?.running)
64
- return { eligible: false, reason: "service_not_running" };
65
- if (installed.length !== 1)
66
- return { eligible: false, reason: "multiple_managed_profiles" };
67
99
  try {
68
- await deps.assertWritable(globalNodeModules);
100
+ await deps.assertWritable(installation.globalNodeModules);
69
101
  }
70
102
  catch {
71
103
  return { eligible: false, reason: "global_root_not_writable" };
@@ -73,8 +105,7 @@ export async function detectDaemonUpdateEligibility(profileName, overrides = {})
73
105
  return {
74
106
  eligible: true,
75
107
  profileName,
76
- globalNodeModules,
77
- packageRoot: resolve(globalNodeModules, "@nowcrew", "daemon"),
78
- serviceSpec: current.spec,
108
+ ...installation,
109
+ serviceSpec: spec,
79
110
  };
80
111
  }
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { systemCommandRunner } from "./computer-service.js";
4
+ import { daemonGlobalInstallation } from "./daemon-installation.js";
4
5
  const RELEASED_VERSION_RE = /^\d+\.\d+\.\d+$/;
5
6
  async function readPackageVersion(packageRoot) {
6
7
  const body = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
@@ -10,6 +11,12 @@ export async function installExactDaemonUpdate(input) {
10
11
  if (!RELEASED_VERSION_RE.test(input.targetVersion)) {
11
12
  return { ok: false, errorCode: "ineligible" };
12
13
  }
14
+ const installation = daemonGlobalInstallation(input.currentEntryPath ?? process.argv[1] ?? "", input.platform ?? process.platform);
15
+ if (installation === null
16
+ || installation.packageRoot !== input.packageRoot
17
+ || installation.npmPrefix !== input.npmPrefix) {
18
+ return { ok: false, errorCode: "ineligible" };
19
+ }
13
20
  const local = input.localSlots.tryAcquireExclusive();
14
21
  if (local === null)
15
22
  return { ok: false, errorCode: "runtime_busy" };
@@ -26,6 +33,13 @@ export async function installExactDaemonUpdate(input) {
26
33
  await host.release();
27
34
  local.release();
28
35
  };
36
+ const lockedInstallation = daemonGlobalInstallation(input.currentEntryPath ?? process.argv[1] ?? "", input.platform ?? process.platform);
37
+ if (lockedInstallation === null
38
+ || lockedInstallation.packageRoot !== input.packageRoot
39
+ || lockedInstallation.npmPrefix !== input.npmPrefix) {
40
+ await release();
41
+ return { ok: false, errorCode: "ineligible" };
42
+ }
29
43
  const runner = input.runner ?? systemCommandRunner;
30
44
  try {
31
45
  await input.onInstalling?.();
@@ -37,6 +51,8 @@ export async function installExactDaemonUpdate(input) {
37
51
  const result = await runner(process.platform === "win32" ? "npm.cmd" : "npm", [
38
52
  "install",
39
53
  "--global",
54
+ "--prefix",
55
+ input.npmPrefix,
40
56
  "--ignore-scripts",
41
57
  "--no-audit",
42
58
  "--no-fund",
package/dist/i18n.js CHANGED
@@ -42,7 +42,7 @@ const zh = {
42
42
  "Saved profile '{{name}}' with private credentials.": "已保存配置 '{{name}}',凭证仅私有可读。",
43
43
  "Service '{{id}}' is not installed": "服务 '{{id}}' 尚未安装",
44
44
  "Upgraded daemon and restart request accepted for '{{name}}'. Verify with status.": "daemon 已升级,并已请求重启 '{{name}}';请用 status 确认。",
45
- "Upgraded daemon but skipped restart for '{{name}}': {{reason}}": "daemon 已升级,但已跳过 '{{name}}' 的重启:{{reason}}",
45
+ "Refused daemon upgrade for '{{name}}': {{reason}}": "已拒绝升级 daemon '{{name}}'{{reason}}",
46
46
  "Upgraded daemon. Installed services were not restarted; pass --profile to restart one.": "daemon 已升级;已安装服务尚未重启,可传入 --profile 重启指定服务。",
47
47
  "Service lifecycle requires the built daemon entry (.js), not a TypeScript development entry": "服务生命周期必须使用已构建的 daemon 入口(.js),不能使用 TypeScript 开发入口",
48
48
  "Installed '{{id}}'. Use status to confirm runtime state.": "已安装 '{{id}}';请用 status 确认运行状态。",
package/dist/main.js CHANGED
@@ -14,10 +14,13 @@ import { runAgent } from "./runner.js";
14
14
  import { serve } from "./serve.js";
15
15
  import { initSlog, flushSlog } from "./slog.js";
16
16
  import { formatDaemonLogLine } from "./log-format.js";
17
- import { applyProfileToEnv, assertProfileAgentsRootUnique, daemonHome, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, } from "./computer-profile.js";
18
- import { runComputerCommand } from "./computer-cli.js";
17
+ import { applyProfileToEnv, assertProfileAgentsRootUnique, daemonHome, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, resolveAgentsRoot, } from "./computer-profile.js";
18
+ import { builtDaemonEntry, runComputerCommand } from "./computer-cli.js";
19
19
  import { runServeLifecycle } from "./serve-lifecycle.js";
20
20
  import { formatDaemonStartupError } from "./daemon-startup-error.js";
21
+ import { assertManagedServiceStartup } from "./managed-service-startup.js";
22
+ import { daemonGlobalInstallation } from "./daemon-installation.js";
23
+ import { acquireDaemonInstallationLease, runWithDaemonInstallationLease, } from "./daemon-installation-lease.js";
21
24
  async function main() {
22
25
  const computerResult = await runComputerCommand(process.argv.slice(2));
23
26
  if (computerResult !== null) {
@@ -57,8 +60,19 @@ async function main() {
57
60
  if (values.profile) {
58
61
  const home = daemonHome();
59
62
  const profile = await loadProfile(values.profile, home);
60
- if (cmd === "serve")
63
+ if (cmd === "serve") {
61
64
  await assertProfileAgentsRootUnique(profile, home, homedir());
65
+ await assertManagedServiceStartup({
66
+ platform: process.platform,
67
+ profile: profile.name,
68
+ userHome: homedir(),
69
+ uid: process.getuid?.(),
70
+ daemonHome: home,
71
+ agentsRoot: resolveAgentsRoot(profile.agentsRoot, homedir(), process.platform),
72
+ nodePath: process.execPath,
73
+ entryPath: builtDaemonEntry(),
74
+ });
75
+ }
62
76
  applyProfileToEnv(profile, process.env);
63
77
  }
64
78
  // 命令行参数优先于环境变量,填回 env 供 loadConfig 读取
@@ -80,10 +94,18 @@ async function main() {
80
94
  }
81
95
  if (cmd === "serve") {
82
96
  process.stdout.write(formatDaemonLogLine(`🛰️ crew-daemon v${daemonVersion()} (cli v${cliVersion()}) ${td("resident, connecting to")} ${config.serverUrl} ${td("control plane")}...`) + "\n");
83
- const service = serve(config, {
84
- ...(values.profile === undefined ? {} : { profileName: values.profile }),
97
+ const installation = values.profile === undefined
98
+ ? null
99
+ : daemonGlobalInstallation(builtDaemonEntry(), process.platform);
100
+ const installationLease = installation === null
101
+ ? null
102
+ : await acquireDaemonInstallationLease(installation.npmPrefix);
103
+ await runWithDaemonInstallationLease(installationLease, async () => {
104
+ const service = serve(config, {
105
+ ...(values.profile === undefined ? {} : { profileName: values.profile }),
106
+ });
107
+ await runServeLifecycle(service);
85
108
  });
86
- await runServeLifecycle(service);
87
109
  return;
88
110
  }
89
111
  if (!values.agent || !values.channel) {
@@ -0,0 +1,92 @@
1
+ import { inspectDaemonInstallationLease } from "./daemon-installation-lease.js";
2
+ import { readServiceDescriptor } from "./computer-service.js";
3
+ import { assertRegistryCoversDescriptors, listManagedServiceDescriptorPaths, managedServiceIdentityMatches, readManagedServiceRegistry, } from "./managed-service-registry.js";
4
+ function errorDetail(error) {
5
+ return error instanceof Error ? error.message : String(error);
6
+ }
7
+ export async function inspectManagedServiceDiagnostics(input) {
8
+ const registry = input.registry ?? {};
9
+ const userHome = registry.userHome;
10
+ const readServices = input.readManagedServices
11
+ ?? (() => readManagedServiceRegistry(registry));
12
+ const listDescriptors = input.listManagedDescriptorPaths
13
+ ?? (() => listManagedServiceDescriptorPaths(input.spec.platform, userHome));
14
+ const readDescriptor = input.readDescriptor
15
+ ?? (() => readServiceDescriptor(input.spec));
16
+ const inspectLease = input.inspectInstallationLease
17
+ ?? (() => inspectDaemonInstallationLease(input.expectedRecord.npmPrefix, {
18
+ ...(userHome === undefined ? {} : { userHome }),
19
+ }));
20
+ let services = null;
21
+ let registryCheck;
22
+ try {
23
+ services = await readServices();
24
+ assertRegistryCoversDescriptors(services, await listDescriptors());
25
+ registryCheck = {
26
+ name: "managed-service-registry",
27
+ ok: true,
28
+ detail: `${services.length} registered service(s); all descriptors are covered`,
29
+ };
30
+ }
31
+ catch (error) {
32
+ registryCheck = {
33
+ name: "managed-service-registry",
34
+ ok: false,
35
+ detail: errorDetail(error),
36
+ };
37
+ }
38
+ let identityCheck;
39
+ const registered = services?.find((record) => record.serviceId === input.expectedRecord.serviceId
40
+ || (record.daemonHome === input.expectedRecord.daemonHome
41
+ && record.profile === input.expectedRecord.profile));
42
+ if (registered === undefined) {
43
+ identityCheck = {
44
+ name: "managed-service-identity",
45
+ ok: false,
46
+ detail: services === null
47
+ ? "identity unavailable because the registry is invalid"
48
+ : `managed service '${input.expectedRecord.serviceId}' is not registered`,
49
+ };
50
+ }
51
+ else {
52
+ try {
53
+ const { generation: _generation, createdAt: _createdAt, ...expectedIdentity } = input.expectedRecord;
54
+ const descriptor = await readDescriptor();
55
+ const matches = descriptor === input.spec.descriptor
56
+ && managedServiceIdentityMatches(registered, expectedIdentity);
57
+ identityCheck = {
58
+ name: "managed-service-identity",
59
+ ok: matches,
60
+ detail: matches
61
+ ? `registry and descriptor match '${input.expectedRecord.serviceId}'`
62
+ : `registry or descriptor identity does not match '${input.expectedRecord.serviceId}'`,
63
+ };
64
+ }
65
+ catch (error) {
66
+ identityCheck = {
67
+ name: "managed-service-identity",
68
+ ok: false,
69
+ detail: errorDetail(error),
70
+ };
71
+ }
72
+ }
73
+ let installationLease;
74
+ try {
75
+ installationLease = await inspectLease();
76
+ }
77
+ catch (error) {
78
+ installationLease = { status: "corrupt", detail: errorDetail(error) };
79
+ }
80
+ const leaseCheck = {
81
+ name: "installation-lease",
82
+ ok: installationLease.status === "owned" && installationLease.ownerAlive === true,
83
+ detail: installationLease.detail,
84
+ };
85
+ const checks = [registryCheck, identityCheck, leaseCheck];
86
+ return {
87
+ healthy: checks.every((check) => check.ok),
88
+ npmPrefix: input.expectedRecord.npmPrefix,
89
+ checks,
90
+ installationLease,
91
+ };
92
+ }
@@ -0,0 +1,189 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { acquireDaemonInstallationLease } from "./daemon-installation-lease.js";
3
+ import { installService, ensureServiceEnabled, readServiceDescriptor, serviceAction, uninstallService, } from "./computer-service.js";
4
+ import { ManagedServiceConflictError, ManagedServiceRegistryError, assertRegistryCoversDescriptors, listManagedServiceDescriptorPaths, managedServiceIdentityMatches, managedServiceConflict, normalizeManagedServiceRecord, serviceDescriptorSha256, withManagedServiceRegistryLock, } from "./managed-service-registry.js";
5
+ function requirePortableManagedSpec(spec) {
6
+ if ((spec.platform !== "darwin" && spec.platform !== "linux")
7
+ || spec.descriptorPath === null
8
+ || spec.descriptor === null) {
9
+ throw new ManagedServiceRegistryError("Managed service registry supports launchd and systemd descriptors only");
10
+ }
11
+ }
12
+ export function buildManagedServiceRecord(input) {
13
+ requirePortableManagedSpec(input.spec);
14
+ return normalizeManagedServiceRecord({
15
+ version: 1,
16
+ generation: (input.generation ?? randomUUID)(),
17
+ platform: input.spec.platform,
18
+ serviceId: input.spec.id,
19
+ profile: input.spec.profile,
20
+ daemonHome: input.daemonHome,
21
+ agentsRoot: input.agentsRoot,
22
+ nodePath: input.nodePath,
23
+ entryPath: input.entryPath,
24
+ packageRoot: input.packageRoot,
25
+ npmPrefix: input.npmPrefix,
26
+ descriptorPath: input.spec.descriptorPath,
27
+ descriptorSha256: serviceDescriptorSha256(input.spec.descriptor),
28
+ createdAt: (input.now ?? (() => new Date()))().toISOString(),
29
+ });
30
+ }
31
+ function sameRecord(left, right) {
32
+ return JSON.stringify(left) === JSON.stringify(right);
33
+ }
34
+ function sameManagedIdentity(left, right) {
35
+ const { generation: _generation, createdAt: _createdAt, ...expected } = right;
36
+ return managedServiceIdentityMatches(left, expected);
37
+ }
38
+ function assertRecordDescribesSpec(input) {
39
+ requirePortableManagedSpec(input.spec);
40
+ const { spec, record } = input;
41
+ const normalizedDescriptorPath = normalizeManagedServiceRecord({
42
+ ...record,
43
+ descriptorPath: spec.descriptorPath,
44
+ }).descriptorPath;
45
+ if (record.platform !== spec.platform
46
+ || record.serviceId !== spec.id
47
+ || record.profile !== spec.profile
48
+ || record.descriptorPath !== normalizedDescriptorPath
49
+ || record.descriptorSha256 !== serviceDescriptorSha256(spec.descriptor)) {
50
+ throw new ManagedServiceRegistryError(`Managed service record does not describe '${spec.id}'`);
51
+ }
52
+ }
53
+ export async function installManagedService(input) {
54
+ assertRecordDescribesSpec(input);
55
+ requirePortableManagedSpec(input.spec);
56
+ const acquire = input.acquireInstallationLease ?? acquireDaemonInstallationLease;
57
+ await withManagedServiceRegistryLock(input.registry ?? {}, async ({ services, replace }) => {
58
+ const exact = services.find((record) => sameManagedIdentity(record, input.record));
59
+ if (exact !== undefined) {
60
+ const descriptor = await readServiceDescriptor(input.spec);
61
+ if (descriptor === input.spec.descriptor) {
62
+ await ensureServiceEnabled(input.spec, input.runner);
63
+ return;
64
+ }
65
+ if (descriptor !== null) {
66
+ throw new ManagedServiceRegistryError(`Installed descriptor for '${input.spec.id}' does not match its registry record`);
67
+ }
68
+ const lease = await acquire(input.record.npmPrefix, {
69
+ ...(input.registry?.userHome === undefined ? {} : { userHome: input.registry.userHome }),
70
+ });
71
+ try {
72
+ await installService(input.spec, input.runner);
73
+ }
74
+ finally {
75
+ await lease.close();
76
+ }
77
+ return;
78
+ }
79
+ const conflict = managedServiceConflict(services, input.record);
80
+ if (conflict !== null) {
81
+ throw new ManagedServiceConflictError(conflict.resource, input.record, conflict.owner);
82
+ }
83
+ const existingDescriptor = await readServiceDescriptor(input.spec);
84
+ if (existingDescriptor === input.spec.descriptor) {
85
+ await ensureServiceEnabled(input.spec, input.runner);
86
+ await replace([...services, input.record].sort((left, right) => left.serviceId.localeCompare(right.serviceId)));
87
+ return;
88
+ }
89
+ if (existingDescriptor !== null) {
90
+ throw new ManagedServiceRegistryError(`Existing descriptor for '${input.spec.id}' conflicts with the requested identity`);
91
+ }
92
+ const lease = await acquire(input.record.npmPrefix, {
93
+ ...(input.registry?.userHome === undefined ? {} : { userHome: input.registry.userHome }),
94
+ });
95
+ try {
96
+ const descriptor = await readServiceDescriptor(input.spec);
97
+ if (descriptor === null) {
98
+ await installService(input.spec, input.runner);
99
+ await input.hooks?.afterDescriptorInstall?.();
100
+ }
101
+ else if (descriptor !== input.spec.descriptor) {
102
+ throw new ManagedServiceRegistryError(`Existing descriptor for '${input.spec.id}' conflicts with the requested identity`);
103
+ }
104
+ await replace([...services, input.record].sort((left, right) => left.serviceId.localeCompare(right.serviceId)));
105
+ }
106
+ finally {
107
+ await lease.close();
108
+ }
109
+ });
110
+ }
111
+ export async function uninstallUnregisteredManagedService(input) {
112
+ requirePortableManagedSpec(input.spec);
113
+ await withManagedServiceRegistryLock(input.registry ?? {}, async ({ services }) => {
114
+ const registered = services.find((record) => record.serviceId === input.spec.id
115
+ || (record.daemonHome === input.daemonHome && record.profile === input.spec.profile)
116
+ || record.descriptorPath === input.spec.descriptorPath);
117
+ if (registered !== undefined) {
118
+ throw new ManagedServiceRegistryError(`Managed service '${input.spec.id}' became registered before removal`);
119
+ }
120
+ const descriptor = await readServiceDescriptor(input.spec);
121
+ if (descriptor !== input.spec.descriptor) {
122
+ throw new ManagedServiceRegistryError(`Managed service '${input.spec.id}' is not registered and its descriptor does not match the requested identity`);
123
+ }
124
+ await serviceAction(input.spec, "stop", input.runner);
125
+ const acquire = input.acquireInstallationLease ?? acquireDaemonInstallationLease;
126
+ const lease = await acquire(input.npmPrefix, {
127
+ ...(input.registry?.userHome === undefined ? {} : { userHome: input.registry.userHome }),
128
+ });
129
+ try {
130
+ await uninstallService(input.spec, input.runner);
131
+ }
132
+ finally {
133
+ await lease.close();
134
+ }
135
+ });
136
+ }
137
+ export async function uninstallManagedService(input) {
138
+ assertRecordDescribesSpec(input);
139
+ requirePortableManagedSpec(input.spec);
140
+ await withManagedServiceRegistryLock(input.registry ?? {}, async ({ services, replace }) => {
141
+ const index = services.findIndex((record) => record.serviceId === input.record.serviceId
142
+ && record.daemonHome === input.record.daemonHome
143
+ && record.profile === input.record.profile);
144
+ if (index < 0 || !sameRecord(services[index], input.record)) {
145
+ throw new ManagedServiceRegistryError(`Managed service '${input.spec.id}' identity changed before removal`);
146
+ }
147
+ const descriptor = await readServiceDescriptor(input.spec);
148
+ if (descriptor !== null && descriptor !== input.spec.descriptor) {
149
+ throw new ManagedServiceRegistryError(`Installed descriptor for '${input.spec.id}' does not match its registry record`);
150
+ }
151
+ const acquire = input.acquireInstallationLease ?? acquireDaemonInstallationLease;
152
+ if (descriptor !== null) {
153
+ await serviceAction(input.spec, "stop", input.runner);
154
+ }
155
+ const lease = await acquire(input.record.npmPrefix, {
156
+ ...(input.registry?.userHome === undefined ? {} : { userHome: input.registry.userHome }),
157
+ });
158
+ try {
159
+ if (descriptor !== null)
160
+ await uninstallService(input.spec, input.runner);
161
+ await replace(services.filter((_, candidateIndex) => candidateIndex !== index));
162
+ }
163
+ finally {
164
+ await lease.close();
165
+ }
166
+ });
167
+ }
168
+ export async function assertManagedServiceIdentity(input) {
169
+ assertRecordDescribesSpec({
170
+ ...input,
171
+ runner: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
172
+ });
173
+ requirePortableManagedSpec(input.spec);
174
+ await withManagedServiceRegistryLock(input.registry ?? {}, async ({ services }) => {
175
+ assertRegistryCoversDescriptors(services, await listManagedServiceDescriptorPaths(input.spec.platform, input.registry?.userHome));
176
+ const registered = services.find((record) => record.serviceId === input.record.serviceId
177
+ || (record.daemonHome === input.record.daemonHome && record.profile === input.record.profile));
178
+ if (registered === undefined) {
179
+ throw new ManagedServiceRegistryError(`Managed service '${input.spec.id}' is not registered; run install first`);
180
+ }
181
+ if (!sameManagedIdentity(registered, input.record)) {
182
+ throw new ManagedServiceRegistryError(`Managed service '${input.spec.id}' identity does not match the registry`);
183
+ }
184
+ const descriptor = await readServiceDescriptor(input.spec);
185
+ if (descriptor !== input.spec.descriptor) {
186
+ throw new ManagedServiceRegistryError(`Installed descriptor for '${input.spec.id}' does not match its registry record`);
187
+ }
188
+ });
189
+ }