@nowcrew/daemon 0.6.77-beta.7 → 0.6.77-beta.9

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 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.
@@ -13,6 +13,7 @@ import { daemonReleaseChannel, isDaemonReleaseVersion } from "./daemon-release-v
13
13
  import { daemonRegistryCandidates } from "./daemon-registry.js";
14
14
  import { npmInvocation } from "./npm-command.js";
15
15
  import { discoverExactLegacyService, } from "./automatic-service-migration.js";
16
+ import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
16
17
  function profileStorage(platform, userHome, runner) {
17
18
  return {
18
19
  platform,
@@ -31,6 +32,7 @@ function sameHostname(left, right) {
31
32
  function defaultDependencies() {
32
33
  const platform = process.platform;
33
34
  const userHome = homedir();
35
+ const localHome = resolveDaemonLocalStateRootFromEnv({ userHome, platform, environment: process.env });
34
36
  const runner = systemCommandRunner;
35
37
  const storage = profileStorage(platform, userHome, runner);
36
38
  let managedRecords = [];
@@ -39,6 +41,7 @@ function defaultDependencies() {
39
41
  platform,
40
42
  userHome,
41
43
  uid: process.getuid?.(),
44
+ environment: process.env,
42
45
  nodePath: process.execPath,
43
46
  version: daemonVersion(),
44
47
  ...(process.env.PATH ? { runtimePath: process.env.PATH } : {}),
@@ -58,7 +61,7 @@ function defaultDependencies() {
58
61
  },
59
62
  captureService: async (spec, target) => {
60
63
  if (platform !== "win32") {
61
- managedRecords = await readManagedServiceRegistry({ userHome });
64
+ managedRecords = await readManagedServiceRegistry({ userHome, localHome });
62
65
  const entryPath = spec.daemonCommand[1];
63
66
  const installation = daemonGlobalInstallation(entryPath, platform);
64
67
  if (installation === null) {
@@ -92,7 +95,7 @@ function defaultDependencies() {
92
95
  managedServices: managedRecords,
93
96
  }),
94
97
  stopService: (spec) => serviceAction(spec, "stop", runner),
95
- acquireInstallationLease: (npmPrefix) => acquireDaemonInstallationLease(npmPrefix, { userHome }),
98
+ acquireInstallationLease: (npmPrefix) => acquireDaemonInstallationLease(npmPrefix, { userHome, localHome }),
96
99
  installPackage: installExactDaemonPackage,
97
100
  saveTargetProfile: async (profile, daemonHome) => {
98
101
  await saveProfile(profile, daemonHome, storage);
@@ -119,7 +122,7 @@ function defaultDependencies() {
119
122
  spec,
120
123
  record,
121
124
  runner,
122
- registry: { userHome },
125
+ registry: { userHome, localHome },
123
126
  });
124
127
  },
125
128
  removeLegacyService: async (legacy) => {
@@ -146,13 +149,13 @@ function defaultDependencies() {
146
149
  await restoreExactService(spec, previous, runner);
147
150
  return;
148
151
  }
149
- const current = (await readManagedServiceRegistry({ userHome })).find((record) => record.serviceId === spec.id);
152
+ const current = (await readManagedServiceRegistry({ userHome, localHome })).find((record) => record.serviceId === spec.id);
150
153
  if (current !== undefined && JSON.stringify(current) !== JSON.stringify(previousManagedRecord)) {
151
154
  await uninstallManagedService({
152
155
  spec,
153
156
  record: current,
154
157
  runner,
155
- registry: { userHome },
158
+ registry: { userHome, localHome },
156
159
  });
157
160
  }
158
161
  if (previousManagedRecord !== undefined) {
@@ -160,7 +163,7 @@ function defaultDependencies() {
160
163
  spec,
161
164
  record: previousManagedRecord,
162
165
  runner,
163
- registry: { userHome },
166
+ registry: { userHome, localHome },
164
167
  });
165
168
  if (previous.running)
166
169
  await serviceAction(spec, "start", runner);
@@ -231,6 +234,10 @@ function recoveryError(primary, failures) {
231
234
  }
232
235
  export async function installDaemonAutomatically(input, dependencies = defaultDependencies()) {
233
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;
234
241
  const target = resolveAutomaticInstallTarget({
235
242
  serverUrl: input.serverUrl,
236
243
  ...(input.requestedProfile === undefined ? {} : { requestedProfile: input.requestedProfile }),
@@ -238,6 +245,8 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
238
245
  ...(input.requestedRegistryUrl === undefined ? {} : { requestedRegistryUrl: input.requestedRegistryUrl }),
239
246
  userHome: dependencies.userHome,
240
247
  platform: dependencies.platform,
248
+ ...(requestedLocalHome === undefined ? {} : { requestedLocalHome }),
249
+ ...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
241
250
  });
242
251
  if (!isDaemonReleaseVersion(dependencies.version)) {
243
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,6 +1,6 @@
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";
@@ -18,6 +18,7 @@ import { assertManagedServiceIdentity, buildManagedServiceRecord, installManaged
18
18
  import { inspectManagedServiceDiagnostics } from "./managed-service-diagnostics.js";
19
19
  import { readManagedServiceRegistry } from "./managed-service-registry.js";
20
20
  import { installDaemonAutomatically, } from "./automatic-daemon-installer.js";
21
+ import { resolveDaemonLocalHome, resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
21
22
  const COMPUTER_COMMANDS = new Set(["profile", "doctor", "install", "uninstall", "start", "stop", "restart", "status", "upgrade"]);
22
23
  export function builtDaemonEntry(moduleUrl = import.meta.url) {
23
24
  return moduleUrl.endsWith(".js")
@@ -67,6 +68,7 @@ function usage(td) {
67
68
  " crew-daemon install --server-url <url> --api-key <sk_machine_*> [--profile <custom-name>] [--release-tag <latest|beta>] [--registry <https-url>]",
68
69
  " crew-daemon doctor|install|uninstall|start|stop|restart|status --profile <name>",
69
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",
70
72
  "",
71
73
  td("profile save reads the machine token from CREW_MACHINE_TOKEN, or stdin with --token-stdin."),
72
74
  ].join("\n") + "\n";
@@ -154,6 +156,7 @@ export async function runComputerCommand(argv, overrides = {}) {
154
156
  ?? deps.env.CREW_MACHINE_TOKEN)?.trim();
155
157
  if (!machineToken)
156
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;
157
160
  const result = await deps.installAutomatically({
158
161
  serverUrl: parsed.values["server-url"],
159
162
  machineToken,
@@ -162,11 +165,34 @@ export async function runComputerCommand(argv, overrides = {}) {
162
165
  ...(configuredRegistryUrl === undefined
163
166
  ? {}
164
167
  : { requestedRegistryUrl: configuredRegistryUrl }),
168
+ ...(deps.env.NOWWORK_LOCAL_HOME === undefined
169
+ ? {}
170
+ : { requestedLocalHome: deps.env.NOWWORK_LOCAL_HOME }),
171
+ ...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
165
172
  });
166
173
  deps.stdout(`${td("Installed daemon {{version}} for profile '{{profile}}'; service '{{service}}' is running.", { version: result.version, profile: result.profile, service: result.serviceId })}\n`);
167
174
  return 0;
168
175
  }
169
- 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
+ });
170
196
  const profileStorage = {
171
197
  platform: deps.platform,
172
198
  userHome: deps.userHome,
@@ -221,7 +247,9 @@ export async function runComputerCommand(argv, overrides = {}) {
221
247
  }),
222
248
  ...(parsed.values["agents-root"]
223
249
  ? { agentsRoot: resolveAgentsRoot(parsed.values["agents-root"], deps.userHome, deps.platform) }
224
- : {}),
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
+ : {}),
225
253
  ...(deps.env.PATH ? { runtimePath: deps.env.PATH } : {}),
226
254
  };
227
255
  await saveProfile(profile, home, profileStorage);
@@ -276,7 +304,7 @@ export async function runComputerCommand(argv, overrides = {}) {
276
304
  try {
277
305
  installationLease = installation === null
278
306
  ? null
279
- : await deps.acquireInstallationLease(installation.npmPrefix, { userHome: deps.userHome });
307
+ : await deps.acquireInstallationLease(installation.npmPrefix, { userHome: deps.userHome, localHome });
280
308
  await upgradeDaemon(deps.platform, deps.runner, installation?.npmPrefix, releaseTag, configuredRegistryUrl === undefined
281
309
  ? restartProfile?.registryUrl
282
310
  ?? configuredRegistryUrl
@@ -358,7 +386,7 @@ export async function runComputerCommand(argv, overrides = {}) {
358
386
  spec,
359
387
  record: managedRecord,
360
388
  runner: deps.runner,
361
- registry: { userHome: deps.userHome },
389
+ registry: { userHome: deps.userHome, localHome },
362
390
  });
363
391
  deps.stdout(`${td("Installed '{{id}}'. Use status to confirm runtime state.", { id: spec.id })}\n`);
364
392
  return 0;
@@ -367,7 +395,7 @@ export async function runComputerCommand(argv, overrides = {}) {
367
395
  if (managedRecord === null)
368
396
  await uninstallService(spec, deps.runner);
369
397
  else {
370
- 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
371
399
  && record.daemonHome === managedRecord.daemonHome
372
400
  && record.profile === name);
373
401
  if (current === undefined) {
@@ -378,7 +406,7 @@ export async function runComputerCommand(argv, overrides = {}) {
378
406
  daemonHome: home,
379
407
  npmPrefix: installation.npmPrefix,
380
408
  runner: deps.runner,
381
- registry: { userHome: deps.userHome },
409
+ registry: { userHome: deps.userHome, localHome },
382
410
  acquireInstallationLease: deps.acquireInstallationLease,
383
411
  });
384
412
  }
@@ -398,7 +426,7 @@ export async function runComputerCommand(argv, overrides = {}) {
398
426
  daemonHome: home,
399
427
  npmPrefix: installation.npmPrefix,
400
428
  runner: deps.runner,
401
- registry: { userHome: deps.userHome },
429
+ registry: { userHome: deps.userHome, localHome },
402
430
  acquireInstallationLease: deps.acquireInstallationLease,
403
431
  });
404
432
  }
@@ -408,7 +436,7 @@ export async function runComputerCommand(argv, overrides = {}) {
408
436
  spec,
409
437
  record: current,
410
438
  runner: deps.runner,
411
- registry: { userHome: deps.userHome },
439
+ registry: { userHome: deps.userHome, localHome },
412
440
  });
413
441
  }
414
442
  }
@@ -420,7 +448,7 @@ export async function runComputerCommand(argv, overrides = {}) {
420
448
  await assertManagedServiceIdentity({
421
449
  spec,
422
450
  record: managedRecord,
423
- registry: { userHome: deps.userHome },
451
+ registry: { userHome: deps.userHome, localHome },
424
452
  });
425
453
  }
426
454
  await serviceAction(spec, command, deps.runner);
@@ -451,7 +479,7 @@ export async function runComputerCommand(argv, overrides = {}) {
451
479
  : await inspectManagedServiceDiagnostics({
452
480
  spec,
453
481
  expectedRecord: managedRecord,
454
- registry: { userHome: deps.userHome },
482
+ registry: { userHome: deps.userHome, localHome },
455
483
  });
456
484
  const checks = [
457
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;
@@ -3,6 +3,7 @@ import { realpathSync } from "node:fs";
3
3
  import { mkdir, open } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { resolve } from "node:path";
6
+ import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
6
7
  import { defaultProcessController } from "./execution-journal.js";
7
8
  import { createJournalLease, createJournalLeaseRegistry, inspectJournalLock, } from "./execution-journal-lock.js";
8
9
  const registry = createJournalLeaseRegistry();
@@ -18,9 +19,10 @@ function canonicalPrefix(npmPrefix) {
18
19
  return absolute;
19
20
  }
20
21
  }
21
- function leaseDirectory(npmPrefix, userHome) {
22
+ function leaseDirectory(npmPrefix, userHome, localHome) {
22
23
  const fingerprint = createHash("sha256").update(resolve(npmPrefix)).digest("hex");
23
- return resolve(userHome, ".crew", "daemon", "managed-services", "installation-leases", fingerprint);
24
+ const root = localHome ?? resolveDaemonLocalStateRoot({ userHome, platform: process.platform });
25
+ return resolve(root, "daemon", "managed-services", "installation-leases", fingerprint);
24
26
  }
25
27
  async function syncDirectory(path) {
26
28
  if (process.platform === "win32")
@@ -35,7 +37,8 @@ async function syncDirectory(path) {
35
37
  }
36
38
  export async function acquireDaemonInstallationLease(npmPrefix, options = {}) {
37
39
  const normalizedPrefix = canonicalPrefix(npmPrefix);
38
- const directory = leaseDirectory(normalizedPrefix, options.userHome ?? homedir());
40
+ const userHome = options.userHome ?? homedir();
41
+ const directory = leaseDirectory(normalizedPrefix, userHome, options.localHome);
39
42
  await mkdir(directory, { recursive: true, mode: 0o700 });
40
43
  const lease = createJournalLease({
41
44
  directory,
@@ -75,7 +78,7 @@ export function daemonInstallationLeaseHeld(npmPrefix) {
75
78
  export async function inspectDaemonInstallationLease(npmPrefix, options = {}) {
76
79
  const normalizedPrefix = canonicalPrefix(npmPrefix);
77
80
  return inspectJournalLock({
78
- directory: leaseDirectory(normalizedPrefix, options.userHome ?? homedir()),
81
+ directory: leaseDirectory(normalizedPrefix, options.userHome ?? homedir(), options.localHome),
79
82
  inspectIdentity: options.inspectIdentity ?? defaultProcessController.inspectIdentity,
80
83
  });
81
84
  }
@@ -0,0 +1,108 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { posix, win32 } from "node:path";
4
+ const PROFILE = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
5
+ const CONTROL = /[\0\r\n]/u;
6
+ const pathsFor = (platform) => platform === "win32" ? win32 : posix;
7
+ export function parseDaemonLocalHomeMode(value) {
8
+ if (value === undefined || value.trim() === "")
9
+ return "auto";
10
+ const normalized = value.trim().toLowerCase();
11
+ if (normalized === "auto" || normalized === "nowwork" || normalized === "legacy")
12
+ return normalized;
13
+ throw new Error("NOWWORK_LOCAL_HOME_MODE must be auto, nowwork or legacy");
14
+ }
15
+ function absoluteRoot(value, userHome, platform) {
16
+ const paths = pathsFor(platform);
17
+ if (value.length === 0 || CONTROL.test(value))
18
+ throw new Error("NOWWORK_LOCAL_HOME must be a non-empty single-line path");
19
+ const expanded = value === "~"
20
+ ? userHome
21
+ : value.startsWith("~/") || (platform === "win32" && value.startsWith("~\\"))
22
+ ? paths.join(userHome, value.slice(2))
23
+ : value;
24
+ if (!paths.isAbsolute(expanded))
25
+ throw new Error("NOWWORK_LOCAL_HOME must be absolute or start with '~/'");
26
+ return paths.normalize(expanded);
27
+ }
28
+ function legacyDaemonDirectory(profile) {
29
+ if (profile === "in" || profile === "production")
30
+ return "daemon";
31
+ if (profile === "dev")
32
+ return "daemon-dev";
33
+ if (profile.startsWith("nw-"))
34
+ return `daemon-${profile.slice(3)}`;
35
+ return `daemon-${profile}`;
36
+ }
37
+ function resolution(mode, root, profile, platform, source) {
38
+ const paths = pathsFor(platform);
39
+ return Object.freeze({
40
+ mode,
41
+ root,
42
+ daemonHome: mode === "nowwork" ? paths.join(root, "config") : paths.join(root, legacyDaemonDirectory(profile)),
43
+ runtimePrefix: paths.join(root, "daemon-runtimes", profile),
44
+ source,
45
+ });
46
+ }
47
+ export function resolveDaemonLocalHome(input) {
48
+ if (!PROFILE.test(input.profile))
49
+ throw new Error("daemon profile is invalid");
50
+ const mode = parseDaemonLocalHomeMode(input.mode);
51
+ const paths = pathsFor(input.platform);
52
+ const nowworkRoot = absoluteRoot(input.explicit ?? paths.join(input.userHome, ".nowwork"), input.userHome, input.platform);
53
+ const legacyRoot = absoluteRoot(paths.join(input.userHome, ".crew"), input.userHome, input.platform);
54
+ if (input.explicit !== undefined && mode !== "legacy") {
55
+ return resolution("nowwork", nowworkRoot, input.profile, input.platform, "explicit");
56
+ }
57
+ if (mode === "nowwork")
58
+ return resolution("nowwork", nowworkRoot, input.profile, input.platform, "nowwork_forced");
59
+ if (mode === "legacy")
60
+ return resolution("legacy", legacyRoot, input.profile, input.platform, "legacy_forced");
61
+ const exists = input.exists ?? existsSync;
62
+ const nowwork = resolution("nowwork", nowworkRoot, input.profile, input.platform, "auto_nowwork_existing");
63
+ const legacy = resolution("legacy", legacyRoot, input.profile, input.platform, "auto_legacy_existing");
64
+ const hasNowwork = exists(nowwork.runtimePrefix) || exists(paths.join(nowwork.daemonHome, "profiles", `${input.profile}.json`));
65
+ const hasLegacy = exists(legacy.runtimePrefix) || exists(paths.join(legacy.daemonHome, "profiles", `${input.profile}.json`));
66
+ if (hasNowwork && hasLegacy) {
67
+ throw new Error(`Daemon local home conflict: profile '${input.profile}' exists under both .crew and .nowwork`);
68
+ }
69
+ if (hasNowwork)
70
+ return nowwork;
71
+ if (hasLegacy)
72
+ return legacy;
73
+ return resolution("nowwork", nowworkRoot, input.profile, input.platform, "auto_new");
74
+ }
75
+ /**
76
+ * Resolve the local machine state root shared by service metadata, leases,
77
+ * host coordination and offline logs. It intentionally stays separate from
78
+ * NOWWORK_HOME, which may point at a NAS persistent data root.
79
+ */
80
+ export function resolveDaemonLocalStateRoot(input) {
81
+ const paths = pathsFor(input.platform);
82
+ const mode = parseDaemonLocalHomeMode(input.mode);
83
+ const nowworkRoot = absoluteRoot(input.explicit ?? paths.join(input.userHome, ".nowwork"), input.userHome, input.platform);
84
+ const legacyRoot = absoluteRoot(paths.join(input.userHome, ".crew"), input.userHome, input.platform);
85
+ if (mode === "legacy")
86
+ return legacyRoot;
87
+ if (input.explicit !== undefined || mode === "nowwork")
88
+ return nowworkRoot;
89
+ const exists = input.exists ?? existsSync;
90
+ // Once the new root exists, shared state must follow it even while the
91
+ // legacy root is retained as a rollback source.
92
+ if (exists(nowworkRoot))
93
+ return nowworkRoot;
94
+ if (exists(legacyRoot))
95
+ return legacyRoot;
96
+ return nowworkRoot;
97
+ }
98
+ export function resolveDaemonLocalStateRootFromEnv(input = {}) {
99
+ const environment = input.environment ?? process.env;
100
+ return resolveDaemonLocalStateRoot({
101
+ userHome: input.userHome ?? homedir(),
102
+ platform: input.platform ?? process.platform,
103
+ ...(environment.NOWWORK_LOCAL_HOME === undefined ? {} : { explicit: environment.NOWWORK_LOCAL_HOME }),
104
+ ...(environment.NOWWORK_LOCAL_HOME_MODE === undefined && environment.NOWWORK_STORAGE_MODE === undefined
105
+ ? {}
106
+ : { mode: environment.NOWWORK_LOCAL_HOME_MODE ?? environment.NOWWORK_STORAGE_MODE }),
107
+ });
108
+ }
@@ -21,6 +21,9 @@ const MigrationMessageSchema = z.object({
21
21
  function migrationBackupRoot(input, id) {
22
22
  return resolve(input.backupRoot ?? resolve(input.userHome ?? homedir(), ".crew", "migration-backups"), id);
23
23
  }
24
+ function legacyLocalHome(input) {
25
+ return resolve(input.userHome ?? homedir(), ".crew");
26
+ }
24
27
  async function assertEmptyOrMissing(path) {
25
28
  try {
26
29
  const entries = await readdir(path);
@@ -77,10 +80,10 @@ async function backupAndCopy(input, id, targetHome, targetAgents, service) {
77
80
  }
78
81
  async function cleanupCurrentServiceDefault(spec, input) {
79
82
  const userHome = input.userHome ?? homedir();
80
- const records = await readManagedServiceRegistry({ userHome });
83
+ const records = await readManagedServiceRegistry({ userHome, localHome: legacyLocalHome(input) });
81
84
  const record = records.find((candidate) => candidate.profile === spec.profile && candidate.daemonHome === input.currentDaemonHome);
82
85
  if (record !== undefined && (spec.platform === "darwin" || spec.platform === "linux")) {
83
- await uninstallManagedService({ spec, record, runner: systemCommandRunner, registry: { userHome } });
86
+ await uninstallManagedService({ spec, record, runner: systemCommandRunner, registry: { userHome, localHome: legacyLocalHome(input) } });
84
87
  return;
85
88
  }
86
89
  // Windows and legacy descriptors are handled by the normal CLI/service lifecycle.
@@ -125,7 +128,15 @@ export function createDaemonMigrationController(input) {
125
128
  if (currentProfile.machineToken !== input.machineToken)
126
129
  throw new Error("active profile token does not match this daemon");
127
130
  currentRegistryUrl = currentProfile.registryUrl;
128
- const target = resolveAutomaticInstallTarget({ serverUrl: input.serverUrl, requestedProfile: message.targetProfile, userHome, platform });
131
+ const target = resolveAutomaticInstallTarget({
132
+ serverUrl: input.serverUrl,
133
+ requestedProfile: message.targetProfile,
134
+ userHome,
135
+ platform,
136
+ // This controller migrates the legacy service layout; the new local-home
137
+ // standard is handled by the storage migration controller.
138
+ requestedLocalHomeMode: "legacy",
139
+ });
129
140
  if (!isAbsolute(message.targetDaemonHome) || !isAbsolute(message.targetAgentsRoot)
130
141
  || resolve(message.targetDaemonHome) !== resolve(target.daemonHome)
131
142
  || resolve(message.targetAgentsRoot) !== resolve(target.agentsRoot))
@@ -140,7 +151,7 @@ export function createDaemonMigrationController(input) {
140
151
  throw new Error("current managed service identity is not exact and running");
141
152
  currentService = { installed: exact.installed, running: exact.running };
142
153
  if (platform === "darwin" || platform === "linux") {
143
- const records = await (input.readManagedRecords ?? (() => readManagedServiceRegistry({ userHome })))();
154
+ const records = await (input.readManagedRecords ?? (() => readManagedServiceRegistry({ userHome, localHome: legacyLocalHome(input) })))();
144
155
  managedRecord = records.find((record) => record.serviceId === currentSpec.id && record.profile === input.profileName && record.daemonHome === input.currentDaemonHome) ?? null;
145
156
  if (managedRecord === null)
146
157
  throw new Error("current managed service registry record is missing");
@@ -9,6 +9,7 @@ import { daemonGlobalInstallation } from "./daemon-installation.js";
9
9
  import { daemonInstallationLeaseHeld } from "./daemon-installation-lease.js";
10
10
  import { inspectLegacyServiceInstallation } from "./legacy-service-installation.js";
11
11
  import { inspectWindowsServiceUpdateScope } from "./windows-scheduled-task.js";
12
+ import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
12
13
  import { assertRegistryCoversDescriptors, listManagedServiceDescriptorPaths, managedServiceIdentityMatches, readManagedServiceRegistry, serviceDescriptorSha256, } from "./managed-service-registry.js";
13
14
  export async function managedDaemonCapabilities(eligibility) {
14
15
  try {
@@ -20,24 +21,31 @@ export async function managedDaemonCapabilities(eligibility) {
20
21
  return [];
21
22
  }
22
23
  }
23
- function defaults() {
24
+ function defaults(profileName) {
25
+ const userHome = homedir();
26
+ const localHome = resolveDaemonLocalStateRoot({
27
+ userHome,
28
+ platform: process.platform,
29
+ explicit: process.env.NOWWORK_LOCAL_HOME,
30
+ mode: process.env.NOWWORK_LOCAL_HOME_MODE ?? process.env.NOWWORK_STORAGE_MODE,
31
+ });
24
32
  return {
25
33
  platform: process.platform,
26
- profileHome: daemonHome(),
27
- userHome: homedir(),
34
+ profileHome: daemonHome(process.env, profileName),
35
+ userHome,
28
36
  uid: process.getuid?.(),
29
37
  nodePath: process.execPath,
30
38
  entryPath: builtDaemonEntry(),
31
39
  loadProfile,
32
- readManagedServices: () => readManagedServiceRegistry(),
33
- listManagedDescriptorPaths: () => listManagedServiceDescriptorPaths(process.platform, homedir()),
40
+ readManagedServices: () => readManagedServiceRegistry({ userHome, localHome }),
41
+ listManagedDescriptorPaths: () => listManagedServiceDescriptorPaths(process.platform, userHome),
34
42
  readServiceDescriptor,
35
43
  serviceStatus,
36
44
  assertWritable: (path) => access(path, constants.W_OK),
37
45
  installationLeaseHeld: daemonInstallationLeaseHeld,
38
46
  installationRealpath: realpathSync.native,
39
47
  inspectWindowsServiceUpdateScope,
40
- inspectLegacyDescriptorInstallation: (descriptorPath) => inspectLegacyServiceInstallation(descriptorPath, process.platform, homedir(), process.getuid?.()),
48
+ inspectLegacyDescriptorInstallation: (descriptorPath) => inspectLegacyServiceInstallation(descriptorPath, process.platform, userHome, process.getuid?.()),
41
49
  };
42
50
  }
43
51
  async function registryCoversUpdateScope(services, descriptorPaths, targetDescriptorPath, npmPrefix, inspectLegacyDescriptorInstallation) {
@@ -57,7 +65,7 @@ async function registryCoversUpdateScope(services, descriptorPaths, targetDescri
57
65
  return true;
58
66
  }
59
67
  export async function detectDaemonUpdateEligibility(profileName, overrides = {}) {
60
- const deps = { ...defaults(), ...overrides };
68
+ const deps = { ...defaults(profileName), ...overrides };
61
69
  if (deps.platform !== "darwin" && deps.platform !== "linux" && deps.platform !== "win32") {
62
70
  return { eligible: false, reason: "unsupported_platform" };
63
71
  }