@nowcrew/daemon 0.6.77-beta.1 → 0.6.77-beta.10

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.
Files changed (50) hide show
  1. package/README.md +27 -0
  2. package/dist/agent-ability/resolver.js +1 -1
  3. package/dist/attachment-limits.js +4 -0
  4. package/dist/attachments.js +33 -27
  5. package/dist/automatic-daemon-installer.js +44 -24
  6. package/dist/automatic-install-target.js +34 -9
  7. package/dist/computer-cli.js +43 -14
  8. package/dist/computer-profile.js +15 -2
  9. package/dist/computer-service.js +26 -5
  10. package/dist/config.js +34 -6
  11. package/dist/daemon-installation-lease.js +7 -4
  12. package/dist/daemon-local-home.js +108 -0
  13. package/dist/daemon-migration-controller.js +15 -4
  14. package/dist/daemon-registry.js +20 -0
  15. package/dist/daemon-update-controller.js +1 -0
  16. package/dist/daemon-update-eligibility.js +17 -7
  17. package/dist/daemon-updater.js +56 -30
  18. package/dist/execution-protocol.js +5 -2
  19. package/dist/execution-runner.js +7 -3
  20. package/dist/host-execution-coordinator.js +4 -3
  21. package/dist/local-executor.js +7 -0
  22. package/dist/local-memory/runtime.js +5 -3
  23. package/dist/machine-info.js +2 -0
  24. package/dist/main.js +33 -14
  25. package/dist/managed-service-diagnostics.js +1 -0
  26. package/dist/managed-service-registry.js +12 -7
  27. package/dist/managed-service-startup.js +4 -1
  28. package/dist/npm-command.js +9 -0
  29. package/dist/project-dependencies.js +4 -4
  30. package/dist/project-skills/runtime-root-bootstrap.js +6 -0
  31. package/dist/project-skills/runtime-root-startup.js +2 -1
  32. package/dist/project-skills/serve-startup.js +23 -0
  33. package/dist/repository-identity.js +2 -0
  34. package/dist/runner.js +3 -0
  35. package/dist/runtime-capabilities.js +3 -1
  36. package/dist/serve.js +7 -6
  37. package/dist/slog.js +14 -1
  38. package/dist/storage-control.js +12 -1
  39. package/dist/storage-identity.js +15 -1
  40. package/dist/storage-layout.js +2 -1
  41. package/dist/storage-migration-controller.js +25 -4
  42. package/dist/storage-ownership.js +55 -0
  43. package/dist/storage-runtime-controller.js +29 -7
  44. package/dist/storage-runtime-readiness.js +26 -4
  45. package/dist/storage-runtime.js +18 -2
  46. package/dist/storage-status-delivery.js +4 -0
  47. package/dist/storage-status-replay.js +16 -1
  48. package/dist/storage-worker-executor.js +1 -0
  49. package/dist/storage-worker-operation.js +4 -0
  50. package/package.json +1 -1
package/README.md CHANGED
@@ -479,6 +479,33 @@ failure matrix, rollout limits, and the server-native follow-up design.
479
479
 
480
480
  ## Code Map
481
481
 
482
+ ## Local daemon installation root (experimental)
483
+
484
+ New installations use `~/.nowwork` for machine-local daemon configuration and npm runtimes. The
485
+ Workspace persistence root is still selected independently by `NOWWORK_HOME`; it may be a local
486
+ directory or an ECS-specific NAS directory. Runtime packages, locks, sockets, PIDs and temporary
487
+ files must remain on the machine running the daemon.
488
+
489
+ The same local root also owns shared daemon control state:
490
+ `daemon/managed-services/registry.json`, `daemon/managed-services/installation-leases/`,
491
+ `daemon/compute-execution/`, and `logs/sls-spool/`. This keeps service ownership, upgrade leases,
492
+ host-wide execution admission, and offline log delivery on the daemon host rather than on NAS.
493
+
494
+ The selector is controlled by `NOWWORK_LOCAL_HOME_MODE`:
495
+
496
+ - `auto` (default): use an existing `.nowwork` installation, otherwise preserve an existing `.crew`
497
+ installation; a brand-new profile is created under `.nowwork`.
498
+ - `nowwork`: require the `.nowwork` installation root.
499
+ - `legacy`: force the old `.crew` root for rollback.
500
+
501
+ `NOWWORK_STORAGE_MODE` is accepted as a compatibility alias. `NOWWORK_LOCAL_HOME` can override the
502
+ local root and should point to a local filesystem, never to the NAS Workspace root; the mount/device
503
+ check remains a doctor/migration gate. If the same profile
504
+ is found in both roots, startup/install is refused until the duplicate is resolved explicitly. The
505
+ old `.crew` tree is not deleted by this compatibility layer.
506
+ For shared machine control state, `auto` prefers an existing `~/.nowwork` root; during a mixed
507
+ migration window, set `NOWWORK_LOCAL_HOME_MODE=legacy` on services that still use the old registry.
508
+
482
509
  - `src/serve.ts`: connection, negotiation, routing, sync, and legacy boundary.
483
510
  - `src/daemon-update-eligibility.ts`, `src/daemon-updater.ts`, and `src/daemon-update-controller.ts`:
484
511
  managed-update eligibility, exact npm replacement, host-wide admission barrier, and restart handoff.
@@ -185,7 +185,7 @@ const materializeGit = async (cacheRoot, repositoryUrl, commit, target) => {
185
185
  await git(["--git-dir", bare, "fetch", "--no-tags", "--prune", "origin", commit]);
186
186
  const temp = `${target}.tmp-${randomUUID()}`;
187
187
  await rm(temp, { recursive: true, force: true });
188
- await git(["clone", "--no-checkout", "--shared", bare, temp]);
188
+ await git(["clone", "--shared", bare, temp]);
189
189
  await git(["checkout", "--detach", commit], temp);
190
190
  await rm(join(temp, ".git"), { recursive: true, force: true });
191
191
  await mkdir(dirname(target), { recursive: true });
@@ -0,0 +1,4 @@
1
+ export const ATTACHMENT_MAX_FILE_BYTES = 300 * 1024 * 1024;
2
+ export const ATTACHMENT_MAX_TOTAL_BYTES = ATTACHMENT_MAX_FILE_BYTES;
3
+ export const ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
4
+ export const ATTACHMENT_NATIVE_IMAGE_MAX_BYTES = 25 * 1024 * 1024;
@@ -1,7 +1,6 @@
1
- import { mkdir, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, open, rm } from "node:fs/promises";
2
2
  import { basename, extname, resolve, sep } from "node:path";
3
- export const ATTACHMENT_MAX_FILE_BYTES = 25 * 1024 * 1024;
4
- export const ATTACHMENT_MAX_TOTAL_BYTES = 50 * 1024 * 1024;
3
+ import { ATTACHMENT_DOWNLOAD_TIMEOUT_MS, ATTACHMENT_MAX_FILE_BYTES, ATTACHMENT_MAX_TOTAL_BYTES, } from "./attachment-limits.js";
5
4
  const throwIfAborted = (signal) => {
6
5
  if (signal?.aborted)
7
6
  throw signal.reason ?? new Error("Attachment materialization aborted");
@@ -46,7 +45,7 @@ function checkedRedirect(value, base) {
46
45
  }
47
46
  return url;
48
47
  }
49
- async function readBounded(response, maxBytes) {
48
+ async function writeBounded(response, path, maxBytes, signal) {
50
49
  const declared = Number(response.headers.get("content-length") ?? "");
51
50
  if (Number.isFinite(declared) && declared > maxBytes) {
52
51
  throw new Error(`Attachment exceeds the ${maxBytes} byte limit`);
@@ -54,21 +53,33 @@ async function readBounded(response, maxBytes) {
54
53
  if (!response.body)
55
54
  throw new Error("Attachment response body is empty");
56
55
  const reader = response.body.getReader();
57
- const chunks = [];
58
56
  let size = 0;
59
- while (true) {
60
- const part = await reader.read();
61
- if (part.done)
62
- break;
63
- const chunk = Buffer.from(part.value);
64
- size += chunk.length;
65
- if (size > maxBytes) {
66
- await reader.cancel();
67
- throw new Error(`Attachment exceeds the ${maxBytes} byte limit`);
57
+ try {
58
+ const file = await open(path, "wx", 0o600);
59
+ try {
60
+ while (true) {
61
+ throwIfAborted(signal);
62
+ const part = await reader.read();
63
+ if (part.done)
64
+ break;
65
+ const chunk = Buffer.from(part.value);
66
+ size += chunk.length;
67
+ if (size > maxBytes) {
68
+ throw new Error(`Attachment exceeds the ${maxBytes} byte limit`);
69
+ }
70
+ await file.writeFile(chunk);
71
+ }
72
+ throwIfAborted(signal);
73
+ return size;
74
+ }
75
+ finally {
76
+ await file.close();
68
77
  }
69
- chunks.push(chunk);
70
78
  }
71
- return Buffer.concat(chunks, size);
79
+ catch (error) {
80
+ await reader.cancel().catch(() => { });
81
+ throw error;
82
+ }
72
83
  }
73
84
  async function downloadAttachment(input) {
74
85
  const initial = new URL(`/agent/attachments/${encodeURIComponent(input.attachment.id)}/download`, input.serverUrl);
@@ -104,12 +115,11 @@ async function downloadAttachment(input) {
104
115
  }
105
116
  if (!response.ok)
106
117
  throw new Error(`Attachment download failed with status ${response.status}`);
107
- const data = await readBounded(response, input.maxFileBytes);
118
+ const size = await writeBounded(response, input.path, input.maxFileBytes, input.signal);
108
119
  throwIfAborted(input.signal);
109
- if (data.length !== input.attachment.sizeBytes) {
110
- throw new Error(`Attachment size mismatch: expected ${input.attachment.sizeBytes}, received ${data.length}`);
120
+ if (size !== input.attachment.sizeBytes) {
121
+ throw new Error(`Attachment size mismatch: expected ${input.attachment.sizeBytes}, received ${size}`);
111
122
  }
112
- return data;
113
123
  }
114
124
  catch (error) {
115
125
  if (input.signal?.aborted) {
@@ -161,22 +171,18 @@ export async function materializeAttachments(input) {
161
171
  }
162
172
  used.add(filename.toLowerCase());
163
173
  const path = containedPath(directory, filename);
164
- const data = await downloadAttachment({
174
+ await downloadAttachment({
165
175
  serverUrl: input.serverUrl,
166
176
  token: input.token,
167
177
  attachment,
168
178
  fetchImpl: input.fetchImpl ?? fetch,
169
179
  maxFileBytes,
170
180
  maxRedirects: input.maxRedirects ?? 3,
171
- timeoutMs: input.timeoutMs ?? 10_000,
181
+ timeoutMs: input.timeoutMs ?? ATTACHMENT_DOWNLOAD_TIMEOUT_MS,
182
+ path,
172
183
  ...(input.signal === undefined ? {} : { signal: input.signal }),
173
184
  });
174
185
  throwIfAborted(input.signal);
175
- await writeFile(path, data, {
176
- flag: "wx",
177
- mode: 0o600,
178
- ...(input.signal === undefined ? {} : { signal: input.signal }),
179
- });
180
186
  materialized.push({ ...attachment, filename, path });
181
187
  }
182
188
  return { directory, attachments: materialized };
@@ -10,8 +10,10 @@ import { acquireDaemonInstallationLease, } from "./daemon-installation-lease.js"
10
10
  import { daemonVersion } from "./machine-info.js";
11
11
  import { daemonReleaseProvenance } from "./release-provenance.js";
12
12
  import { daemonReleaseChannel, isDaemonReleaseVersion } from "./daemon-release-version.js";
13
- import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
13
+ import { daemonRegistryCandidates } from "./daemon-registry.js";
14
+ import { npmInvocation } from "./npm-command.js";
14
15
  import { discoverExactLegacyService, } from "./automatic-service-migration.js";
16
+ import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
15
17
  function profileStorage(platform, userHome, runner) {
16
18
  return {
17
19
  platform,
@@ -30,6 +32,7 @@ function sameHostname(left, right) {
30
32
  function defaultDependencies() {
31
33
  const platform = process.platform;
32
34
  const userHome = homedir();
35
+ const localHome = resolveDaemonLocalStateRootFromEnv({ userHome, platform, environment: process.env });
33
36
  const runner = systemCommandRunner;
34
37
  const storage = profileStorage(platform, userHome, runner);
35
38
  let managedRecords = [];
@@ -38,6 +41,7 @@ function defaultDependencies() {
38
41
  platform,
39
42
  userHome,
40
43
  uid: process.getuid?.(),
44
+ environment: process.env,
41
45
  nodePath: process.execPath,
42
46
  version: daemonVersion(),
43
47
  ...(process.env.PATH ? { runtimePath: process.env.PATH } : {}),
@@ -57,7 +61,7 @@ function defaultDependencies() {
57
61
  },
58
62
  captureService: async (spec, target) => {
59
63
  if (platform !== "win32") {
60
- managedRecords = await readManagedServiceRegistry({ userHome });
64
+ managedRecords = await readManagedServiceRegistry({ userHome, localHome });
61
65
  const entryPath = spec.daemonCommand[1];
62
66
  const installation = daemonGlobalInstallation(entryPath, platform);
63
67
  if (installation === null) {
@@ -91,7 +95,7 @@ function defaultDependencies() {
91
95
  managedServices: managedRecords,
92
96
  }),
93
97
  stopService: (spec) => serviceAction(spec, "stop", runner),
94
- acquireInstallationLease: (npmPrefix) => acquireDaemonInstallationLease(npmPrefix, { userHome }),
98
+ acquireInstallationLease: (npmPrefix) => acquireDaemonInstallationLease(npmPrefix, { userHome, localHome }),
95
99
  installPackage: installExactDaemonPackage,
96
100
  saveTargetProfile: async (profile, daemonHome) => {
97
101
  await saveProfile(profile, daemonHome, storage);
@@ -118,7 +122,7 @@ function defaultDependencies() {
118
122
  spec,
119
123
  record,
120
124
  runner,
121
- registry: { userHome },
125
+ registry: { userHome, localHome },
122
126
  });
123
127
  },
124
128
  removeLegacyService: async (legacy) => {
@@ -145,13 +149,13 @@ function defaultDependencies() {
145
149
  await restoreExactService(spec, previous, runner);
146
150
  return;
147
151
  }
148
- const current = (await readManagedServiceRegistry({ userHome })).find((record) => record.serviceId === spec.id);
152
+ const current = (await readManagedServiceRegistry({ userHome, localHome })).find((record) => record.serviceId === spec.id);
149
153
  if (current !== undefined && JSON.stringify(current) !== JSON.stringify(previousManagedRecord)) {
150
154
  await uninstallManagedService({
151
155
  spec,
152
156
  record: current,
153
157
  runner,
154
- registry: { userHome },
158
+ registry: { userHome, localHome },
155
159
  });
156
160
  }
157
161
  if (previousManagedRecord !== undefined) {
@@ -159,7 +163,7 @@ function defaultDependencies() {
159
163
  spec,
160
164
  record: previousManagedRecord,
161
165
  runner,
162
- registry: { userHome },
166
+ registry: { userHome, localHome },
163
167
  });
164
168
  if (previous.running)
165
169
  await serviceAction(spec, "start", runner);
@@ -178,24 +182,34 @@ export function stableDaemonEntry(target, platform) {
178
182
  ? paths.join(target.npmPrefix, "node_modules", "@nowcrew", "daemon", "dist", "main.js")
179
183
  : paths.join(target.npmPrefix, "lib", "node_modules", "@nowcrew", "daemon", "dist", "main.js");
180
184
  }
181
- export async function installExactDaemonPackage(npmPrefix, version, platform, runner, registryUrl) {
182
- const command = platform === "win32" ? "npm.cmd" : "npm";
183
- const result = await runner(command, [
184
- "install",
185
- "--global",
186
- "--prefix",
187
- npmPrefix,
188
- "--ignore-scripts",
189
- "--no-audit",
190
- "--no-fund",
191
- ...(registryUrl === undefined ? [] : ["--registry", normalizeDaemonNpmRegistry(registryUrl, {
192
- allowLoopbackHttp: daemonReleaseChannel(version) === "beta",
193
- })]),
194
- `@nowcrew/daemon@${version}`,
195
- ]);
196
- if (result.exitCode !== 0) {
197
- throw new Error(`Daemon package installation failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`}`);
185
+ export async function installExactDaemonPackage(npmPrefix, version, platform, runner, registryUrl, nodePath = process.execPath) {
186
+ const registries = daemonRegistryCandidates({
187
+ betaUpdate: daemonReleaseChannel(version) === "beta",
188
+ ...(registryUrl === undefined ? {} : { registryUrl }),
189
+ });
190
+ if (registries === null)
191
+ throw new Error("Daemon package installation failed: invalid registry");
192
+ const npm = npmInvocation(platform, nodePath);
193
+ let lastFailure = "";
194
+ for (const registry of registries) {
195
+ const result = await runner(npm.command, [
196
+ ...npm.argsPrefix,
197
+ "install",
198
+ "--global",
199
+ "--prefix",
200
+ npmPrefix,
201
+ "--ignore-scripts",
202
+ "--no-audit",
203
+ "--no-fund",
204
+ "--registry",
205
+ registry,
206
+ `@nowcrew/daemon@${version}`,
207
+ ]);
208
+ if (result.exitCode === 0)
209
+ return;
210
+ lastFailure = result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`;
198
211
  }
212
+ throw new Error(`Daemon package installation failed: ${lastFailure}`);
199
213
  }
200
214
  export async function waitForServiceRunning(spec, status, options = {}) {
201
215
  const timeoutMs = options.timeoutMs ?? 10_000;
@@ -220,6 +234,10 @@ function recoveryError(primary, failures) {
220
234
  }
221
235
  export async function installDaemonAutomatically(input, dependencies = defaultDependencies()) {
222
236
  const machineToken = MachineTokenSchema.parse(input.machineToken);
237
+ const requestedLocalHome = input.requestedLocalHome ?? dependencies.environment.NOWWORK_LOCAL_HOME;
238
+ const requestedLocalHomeMode = input.requestedLocalHomeMode
239
+ ?? dependencies.environment.NOWWORK_LOCAL_HOME_MODE
240
+ ?? dependencies.environment.NOWWORK_STORAGE_MODE;
223
241
  const target = resolveAutomaticInstallTarget({
224
242
  serverUrl: input.serverUrl,
225
243
  ...(input.requestedProfile === undefined ? {} : { requestedProfile: input.requestedProfile }),
@@ -227,6 +245,8 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
227
245
  ...(input.requestedRegistryUrl === undefined ? {} : { requestedRegistryUrl: input.requestedRegistryUrl }),
228
246
  userHome: dependencies.userHome,
229
247
  platform: dependencies.platform,
248
+ ...(requestedLocalHome === undefined ? {} : { requestedLocalHome }),
249
+ ...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
230
250
  });
231
251
  if (!isDaemonReleaseVersion(dependencies.version)) {
232
252
  throw new Error(`Invoking daemon package has an invalid release version '${dependencies.version}'`);
@@ -1,6 +1,7 @@
1
1
  import { posix, win32 } from "node:path";
2
2
  import { profileLayout, validateLocalInstanceName } from "./profile-layout.js";
3
3
  import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
4
+ import { resolveDaemonLocalHome } from "./daemon-local-home.js";
4
5
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
5
6
  const KNOWN_TARGETS = {
6
7
  "nowwork-in.nowcoder.com": {
@@ -72,13 +73,21 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
72
73
  const legacyProfile = environment === "production" ? "in" : environment;
73
74
  assertReleaseTagForServerUrl(input.serverUrl, input.requestedReleaseTag);
74
75
  if (requestedProfile === undefined || requestedProfile === legacyProfile) {
76
+ const local = resolveDaemonLocalHome({
77
+ userHome: input.userHome,
78
+ platform: input.platform,
79
+ profile: legacyProfile,
80
+ ...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
81
+ ...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
82
+ ...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
83
+ });
75
84
  return Object.freeze({
76
85
  kind: "known",
77
86
  profile: legacyProfile,
78
87
  serverUrl: normalizedServerUrl(parsed),
79
- daemonHome: paths.join(input.userHome, ".crew", environment === "production" ? "daemon" : "daemon-dev"),
80
- agentsRoot: paths.join(input.userHome, ".crew", environment === "production" ? "agents" : "agents-dev"),
81
- npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", legacyProfile),
88
+ daemonHome: local.daemonHome,
89
+ agentsRoot: paths.join(local.root, environment === "production" ? "agents" : "agents-dev"),
90
+ npmPrefix: local.runtimePrefix,
82
91
  releaseTag: environment === "dev" ? "beta" : "latest",
83
92
  });
84
93
  }
@@ -99,15 +108,23 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
99
108
  userHome: input.userHome,
100
109
  platform: input.platform,
101
110
  });
111
+ const local = resolveDaemonLocalHome({
112
+ userHome: input.userHome,
113
+ platform: input.platform,
114
+ profile: layout.profile,
115
+ ...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
116
+ ...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
117
+ ...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
118
+ });
102
119
  return Object.freeze({
103
120
  kind: "known",
104
121
  environment,
105
122
  localInstanceName,
106
123
  profile: layout.profile,
107
124
  serverUrl: normalizedServerUrl(parsed),
108
- daemonHome: layout.daemonHome,
109
- agentsRoot: layout.agentsRoot,
110
- npmPrefix: layout.npmPrefix,
125
+ daemonHome: local.daemonHome,
126
+ agentsRoot: paths.join(local.root, "agents-" + layout.environment + "-" + layout.localInstanceName),
127
+ npmPrefix: local.runtimePrefix,
111
128
  releaseTag: environment === "dev" ? "beta" : "latest",
112
129
  });
113
130
  }
@@ -127,13 +144,21 @@ export function resolveAutomaticInstallTarget(input) {
127
144
  return registryUrl === undefined ? target : Object.freeze({ ...target, registryUrl });
128
145
  }
129
146
  const profile = requireCustomProfile(hostname, input.requestedProfile);
147
+ const local = resolveDaemonLocalHome({
148
+ userHome: input.userHome,
149
+ platform: input.platform,
150
+ profile,
151
+ ...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
152
+ ...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
153
+ ...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
154
+ });
130
155
  return Object.freeze({
131
156
  kind: "custom",
132
157
  profile,
133
158
  serverUrl: normalizedServerUrl(parsed),
134
- daemonHome: paths.join(input.userHome, ".crew", `daemon-${profile}`),
135
- agentsRoot: paths.join(input.userHome, ".crew", `agents-${profile}`),
136
- npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", profile),
159
+ daemonHome: local.daemonHome,
160
+ agentsRoot: paths.join(local.root, `agents-${profile}`),
161
+ npmPrefix: local.runtimePrefix,
137
162
  releaseTag: input.requestedReleaseTag ?? "latest",
138
163
  ...(registryUrl === undefined ? {} : { registryUrl }),
139
164
  });
@@ -1,11 +1,12 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { homedir } from "node:os";
3
- import { resolve } from "node:path";
3
+ import { posix, resolve, win32 } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { assertProfileAgentsRootUnique, daemonHome, inspectProfileAgentsRoot, listProfiles, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, profileIsPrivate, profilePath, publicProfile, removeProfile, resolveAgentsRoot, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
6
6
  import { buildServiceSpec, doctorService, hardenWindowsProfile, installService, readServiceDescriptor, serviceAction, serviceStatus, systemCommandRunner, uninstallService, upgradeDaemon, windowsProfileIsPrivate, } from "./computer-service.js";
7
7
  import { releaseTagForServerUrl, assertReleaseTagForServerUrl } from "./automatic-install-target.js";
8
8
  import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
9
+ import { npmInvocation } from "./npm-command.js";
9
10
  import { defaultProcessController } from "./execution-journal.js";
10
11
  import { inspectJournalLock } from "./execution-journal-lock.js";
11
12
  import { detectDaemonLang, formatDaemonText } from "./i18n.js";
@@ -17,6 +18,7 @@ import { assertManagedServiceIdentity, buildManagedServiceRecord, installManaged
17
18
  import { inspectManagedServiceDiagnostics } from "./managed-service-diagnostics.js";
18
19
  import { readManagedServiceRegistry } from "./managed-service-registry.js";
19
20
  import { installDaemonAutomatically, } from "./automatic-daemon-installer.js";
21
+ import { resolveDaemonLocalHome, resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
20
22
  const COMPUTER_COMMANDS = new Set(["profile", "doctor", "install", "uninstall", "start", "stop", "restart", "status", "upgrade"]);
21
23
  export function builtDaemonEntry(moduleUrl = import.meta.url) {
22
24
  return moduleUrl.endsWith(".js")
@@ -24,7 +26,7 @@ export function builtDaemonEntry(moduleUrl = import.meta.url) {
24
26
  : "";
25
27
  }
26
28
  function defaults() {
27
- const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
29
+ const npm = npmInvocation(process.platform, process.execPath);
28
30
  return {
29
31
  env: process.env,
30
32
  platform: process.platform,
@@ -35,7 +37,7 @@ function defaults() {
35
37
  runner: systemCommandRunner,
36
38
  ...(process.platform === "win32" ? { profileProtector: windowsDpapiProtector } : {}),
37
39
  resolveGlobalNodeModules: async () => {
38
- const result = await systemCommandRunner(npmCommand, ["root", "--global"]);
40
+ const result = await systemCommandRunner(npm.command, [...npm.argsPrefix, "root", "--global"]);
39
41
  if (result.exitCode !== 0 || !result.stdout.trim()) {
40
42
  throw new Error(`Unable to locate global npm modules: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
41
43
  }
@@ -66,6 +68,7 @@ function usage(td) {
66
68
  " crew-daemon install --server-url <url> --api-key <sk_machine_*> [--profile <custom-name>] [--release-tag <latest|beta>] [--registry <https-url>]",
67
69
  " crew-daemon doctor|install|uninstall|start|stop|restart|status --profile <name>",
68
70
  " crew-daemon upgrade [--profile <name>] [--release-tag <latest|beta>] [--registry <https-url>]",
71
+ " NOWWORK_LOCAL_HOME_MODE=auto|nowwork|legacy NOWWORK_LOCAL_HOME=~/.nowwork",
69
72
  "",
70
73
  td("profile save reads the machine token from CREW_MACHINE_TOKEN, or stdin with --token-stdin."),
71
74
  ].join("\n") + "\n";
@@ -153,6 +156,7 @@ export async function runComputerCommand(argv, overrides = {}) {
153
156
  ?? deps.env.CREW_MACHINE_TOKEN)?.trim();
154
157
  if (!machineToken)
155
158
  throw new Error(td("Missing machine token for automatic installation"));
159
+ const requestedLocalHomeMode = deps.env.NOWWORK_LOCAL_HOME_MODE ?? deps.env.NOWWORK_STORAGE_MODE;
156
160
  const result = await deps.installAutomatically({
157
161
  serverUrl: parsed.values["server-url"],
158
162
  machineToken,
@@ -161,11 +165,34 @@ export async function runComputerCommand(argv, overrides = {}) {
161
165
  ...(configuredRegistryUrl === undefined
162
166
  ? {}
163
167
  : { requestedRegistryUrl: configuredRegistryUrl }),
168
+ ...(deps.env.NOWWORK_LOCAL_HOME === undefined
169
+ ? {}
170
+ : { requestedLocalHome: deps.env.NOWWORK_LOCAL_HOME }),
171
+ ...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
164
172
  });
165
173
  deps.stdout(`${td("Installed daemon {{version}} for profile '{{profile}}'; service '{{service}}' is running.", { version: result.version, profile: result.profile, service: result.serviceId })}\n`);
166
174
  return 0;
167
175
  }
168
- const home = daemonHome(deps.env);
176
+ const resolvedProfile = profileName ?? positionalName;
177
+ const configuredLocalHomeMode = deps.env.NOWWORK_LOCAL_HOME_MODE ?? deps.env.NOWWORK_STORAGE_MODE;
178
+ const home = resolvedProfile !== undefined
179
+ && (deps.env.NOWWORK_LOCAL_HOME !== undefined
180
+ || deps.env.NOWWORK_LOCAL_HOME_MODE !== undefined
181
+ || deps.env.NOWWORK_STORAGE_MODE !== undefined)
182
+ ? resolveDaemonLocalHome({
183
+ userHome: deps.userHome,
184
+ platform: deps.platform,
185
+ profile: resolvedProfile,
186
+ ...(deps.env.NOWWORK_LOCAL_HOME === undefined ? {} : { explicit: deps.env.NOWWORK_LOCAL_HOME }),
187
+ ...(configuredLocalHomeMode === undefined ? {} : { mode: configuredLocalHomeMode }),
188
+ }).daemonHome
189
+ : daemonHome(deps.env);
190
+ const localHome = resolveDaemonLocalStateRoot({
191
+ userHome: deps.userHome,
192
+ platform: deps.platform,
193
+ explicit: deps.env.NOWWORK_LOCAL_HOME,
194
+ mode: configuredLocalHomeMode,
195
+ });
169
196
  const profileStorage = {
170
197
  platform: deps.platform,
171
198
  userHome: deps.userHome,
@@ -220,7 +247,9 @@ export async function runComputerCommand(argv, overrides = {}) {
220
247
  }),
221
248
  ...(parsed.values["agents-root"]
222
249
  ? { agentsRoot: resolveAgentsRoot(parsed.values["agents-root"], deps.userHome, deps.platform) }
223
- : {}),
250
+ : (deps.env.NOWWORK_LOCAL_HOME !== undefined || configuredLocalHomeMode !== undefined)
251
+ ? { agentsRoot: resolveAgentsRoot((deps.platform === "win32" ? win32 : posix).join((deps.platform === "win32" ? win32 : posix).dirname(home), `agents-${name}`), deps.userHome, deps.platform) }
252
+ : {}),
224
253
  ...(deps.env.PATH ? { runtimePath: deps.env.PATH } : {}),
225
254
  };
226
255
  await saveProfile(profile, home, profileStorage);
@@ -275,11 +304,11 @@ export async function runComputerCommand(argv, overrides = {}) {
275
304
  try {
276
305
  installationLease = installation === null
277
306
  ? null
278
- : await deps.acquireInstallationLease(installation.npmPrefix, { userHome: deps.userHome });
307
+ : await deps.acquireInstallationLease(installation.npmPrefix, { userHome: deps.userHome, localHome });
279
308
  await upgradeDaemon(deps.platform, deps.runner, installation?.npmPrefix, releaseTag, configuredRegistryUrl === undefined
280
309
  ? restartProfile?.registryUrl
281
310
  ?? configuredRegistryUrl
282
- : normalizeDaemonNpmRegistry(configuredRegistryUrl, { allowLoopbackHttp: releaseTag === "beta" }));
311
+ : normalizeDaemonNpmRegistry(configuredRegistryUrl, { allowLoopbackHttp: releaseTag === "beta" }), deps.nodePath);
283
312
  }
284
313
  catch (error) {
285
314
  upgradeError = error;
@@ -357,7 +386,7 @@ export async function runComputerCommand(argv, overrides = {}) {
357
386
  spec,
358
387
  record: managedRecord,
359
388
  runner: deps.runner,
360
- registry: { userHome: deps.userHome },
389
+ registry: { userHome: deps.userHome, localHome },
361
390
  });
362
391
  deps.stdout(`${td("Installed '{{id}}'. Use status to confirm runtime state.", { id: spec.id })}\n`);
363
392
  return 0;
@@ -366,7 +395,7 @@ export async function runComputerCommand(argv, overrides = {}) {
366
395
  if (managedRecord === null)
367
396
  await uninstallService(spec, deps.runner);
368
397
  else {
369
- const current = (await readManagedServiceRegistry({ userHome: deps.userHome })).find((record) => record.serviceId === spec.id
398
+ const current = (await readManagedServiceRegistry({ userHome: deps.userHome, localHome })).find((record) => record.serviceId === spec.id
370
399
  && record.daemonHome === managedRecord.daemonHome
371
400
  && record.profile === name);
372
401
  if (current === undefined) {
@@ -377,7 +406,7 @@ export async function runComputerCommand(argv, overrides = {}) {
377
406
  daemonHome: home,
378
407
  npmPrefix: installation.npmPrefix,
379
408
  runner: deps.runner,
380
- registry: { userHome: deps.userHome },
409
+ registry: { userHome: deps.userHome, localHome },
381
410
  acquireInstallationLease: deps.acquireInstallationLease,
382
411
  });
383
412
  }
@@ -397,7 +426,7 @@ export async function runComputerCommand(argv, overrides = {}) {
397
426
  daemonHome: home,
398
427
  npmPrefix: installation.npmPrefix,
399
428
  runner: deps.runner,
400
- registry: { userHome: deps.userHome },
429
+ registry: { userHome: deps.userHome, localHome },
401
430
  acquireInstallationLease: deps.acquireInstallationLease,
402
431
  });
403
432
  }
@@ -407,7 +436,7 @@ export async function runComputerCommand(argv, overrides = {}) {
407
436
  spec,
408
437
  record: current,
409
438
  runner: deps.runner,
410
- registry: { userHome: deps.userHome },
439
+ registry: { userHome: deps.userHome, localHome },
411
440
  });
412
441
  }
413
442
  }
@@ -419,7 +448,7 @@ export async function runComputerCommand(argv, overrides = {}) {
419
448
  await assertManagedServiceIdentity({
420
449
  spec,
421
450
  record: managedRecord,
422
- registry: { userHome: deps.userHome },
451
+ registry: { userHome: deps.userHome, localHome },
423
452
  });
424
453
  }
425
454
  await serviceAction(spec, command, deps.runner);
@@ -450,7 +479,7 @@ export async function runComputerCommand(argv, overrides = {}) {
450
479
  : await inspectManagedServiceDiagnostics({
451
480
  spec,
452
481
  expectedRecord: managedRecord,
453
- registry: { userHome: deps.userHome },
482
+ registry: { userHome: deps.userHome, localHome },
454
483
  });
455
484
  const checks = [
456
485
  ...serviceChecks,
@@ -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
  import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
12
+ import { resolveDaemonLocalHome } from "./daemon-local-home.js";
12
13
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
13
14
  export const MachineTokenSchema = z.string().startsWith("sk_machine_").max(4096);
14
15
  const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
@@ -94,8 +95,20 @@ export class ProfileAgentsRootConflictError extends Error {
94
95
  }
95
96
  const quotePosixArgument = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
96
97
  const quotePowerShellArgument = (value) => `'${value.replaceAll("'", "''")}'`;
97
- export function daemonHome(env = process.env) {
98
- return resolve(env.CREW_DAEMON_HOME ?? resolve(homedir(), ".crew/daemon"));
98
+ export function daemonHome(env = process.env, profile) {
99
+ if (env.CREW_DAEMON_HOME !== undefined)
100
+ return resolve(env.CREW_DAEMON_HOME);
101
+ const mode = env.NOWWORK_LOCAL_HOME_MODE ?? env.NOWWORK_STORAGE_MODE;
102
+ if (profile !== undefined && (env.NOWWORK_LOCAL_HOME !== undefined || mode !== undefined)) {
103
+ return resolveDaemonLocalHome({
104
+ userHome: homedir(),
105
+ platform: process.platform,
106
+ profile,
107
+ ...(env.NOWWORK_LOCAL_HOME === undefined ? {} : { explicit: env.NOWWORK_LOCAL_HOME }),
108
+ ...(mode === undefined ? {} : { mode }),
109
+ }).daemonHome;
110
+ }
111
+ return resolve(homedir(), ".crew/daemon");
99
112
  }
100
113
  export function resolveAgentsRoot(configured, userHome = homedir(), platform = process.platform) {
101
114
  const pathApi = platform === "win32" ? win32 : posix;
@@ -5,6 +5,8 @@ import { createHash, randomUUID } from "node:crypto";
5
5
  import { promisify } from "node:util";
6
6
  import { dirname, resolve } from "node:path";
7
7
  import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
8
+ import { daemonRegistryCandidates } from "./daemon-registry.js";
9
+ import { npmInvocation } from "./npm-command.js";
8
10
  const execFileAsync = promisify(execFile);
9
11
  const WINDOWS_STOP_TIMEOUT_MS = 5_000;
10
12
  const WINDOWS_STOP_POLL_MS = 100;
@@ -542,10 +544,12 @@ export async function doctorService(spec, profilePrivate, runner = systemCommand
542
544
  { name: "service-running", ok: status.running === true, detail: status.running ? "running" : "not running" },
543
545
  ];
544
546
  }
545
- export function buildUpgradeCommand(platform, npmPrefix, releaseTag = "latest", registryUrl) {
547
+ export function buildUpgradeCommand(platform, npmPrefix, releaseTag = "latest", registryUrl, nodePath = process.execPath) {
548
+ const npm = npmInvocation(platform, nodePath);
546
549
  return [
547
- platform === "win32" ? "npm.cmd" : "npm",
550
+ npm.command,
548
551
  [
552
+ ...npm.argsPrefix,
549
553
  "install",
550
554
  "--global",
551
555
  "--ignore-scripts",
@@ -557,9 +561,26 @@ export function buildUpgradeCommand(platform, npmPrefix, releaseTag = "latest",
557
561
  ],
558
562
  ];
559
563
  }
560
- export async function upgradeDaemon(platform, runner = systemCommandRunner, npmPrefix, releaseTag = "latest", registryUrl) {
561
- const [command, args] = buildUpgradeCommand(platform, npmPrefix, releaseTag, registryUrl);
562
- await checked(runner, command, args, "Daemon upgrade");
564
+ export async function upgradeDaemon(platform, runner = systemCommandRunner, npmPrefix, releaseTag = "latest", registryUrl, nodePath = process.execPath) {
565
+ const registries = daemonRegistryCandidates({
566
+ betaUpdate: releaseTag === "beta",
567
+ ...(registryUrl === undefined ? {} : { registryUrl }),
568
+ env: process.env,
569
+ });
570
+ if (registries === null)
571
+ throw new Error("Daemon upgrade failed: invalid registry");
572
+ let lastFailure = "";
573
+ for (const registry of registries) {
574
+ const [command, args] = buildUpgradeCommand(platform, npmPrefix, releaseTag, registry, nodePath);
575
+ try {
576
+ await checked(runner, command, args, "Daemon upgrade");
577
+ return;
578
+ }
579
+ catch (error) {
580
+ lastFailure = error instanceof Error ? error.message : String(error);
581
+ }
582
+ }
583
+ throw new Error(lastFailure || "Daemon upgrade failed");
563
584
  }
564
585
  const WINDOWS_SID_SCRIPT = "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value";
565
586
  export async function hardenWindowsProfile(path, runner = systemCommandRunner) {