@nowcrew/daemon 0.6.73 → 0.6.75

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
@@ -11,6 +11,13 @@ For a temporary foreground connection, run:
11
11
  npx @nowcrew/daemon@latest --server-url https://nowwork.example --api-key sk_machine_...
12
12
  ```
13
13
 
14
+ The package has two release channels. The `in` environment consumes stable `x.y.z` releases through
15
+ the `latest` tag; the `dev` environment consumes `x.y.z-beta.n` prereleases through the `beta` tag.
16
+ The parser also accepts the historical `x.y.z-beta-n` spelling and normalizes it for ordering; new
17
+ published versions should use the dotted SemVer form.
18
+ Never use `latest` for a dev installation or `beta` for an in installation. See the
19
+ [Daemon Release Runbook](../docs/releases/daemon-release-runbook.md) for release and rollback procedures.
20
+
14
21
  This compatibility form stays resident in the current terminal and reconnects with backoff. New
15
22
  **Add Computer** commands use the managed automatic installation described below. For local development, run
16
23
  `pnpm --filter @nowcrew/daemon daemon`. `crew-daemon run --agent <handle> --channel <id>` is the legacy
@@ -22,7 +29,7 @@ Persist a named connection without putting its machine token in shell history or
22
29
 
23
30
  ```bash
24
31
  printf '%s' "$CREW_MACHINE_TOKEN" | crew-daemon profile save work \
25
- --server-url https://nowwork.example --token-stdin
32
+ --server-url https://nowwork.example --release-tag latest --token-stdin
26
33
  crew-daemon profile list
27
34
  crew-daemon profile show work # reports configured=true; never prints the token
28
35
  crew-daemon serve --profile work
@@ -36,8 +43,9 @@ The connection command generated by **Add Computer** installs the package into a
36
43
  saves the private profile, installs the native user service, starts it, and verifies that it is running:
37
44
 
38
45
  ```bash
39
- npx -y --registry https://registry.npmjs.org @nowcrew/daemon@latest install \
40
- --server-url https://nowwork-dev.nowcoder.com --api-key sk_machine_...
46
+ npx -y --registry https://registry.npmjs.org @nowcrew/daemon@beta install \
47
+ --server-url https://nowwork-dev.nowcoder.com --api-key sk_machine_... \
48
+ --release-tag beta --registry https://registry.npmjs.org
41
49
  ```
42
50
 
43
51
  The installer selects the fixed `in` layout for `nowwork-in.nowcoder.com` and the fixed `dev` layout for
@@ -45,8 +53,8 @@ The installer selects the fixed `in` layout for `nowwork-in.nowcoder.com` and th
45
53
  An unknown hostname is rejected before any write and reports how to retry with an explicit custom profile:
46
54
 
47
55
  ```bash
48
- npx -y --registry https://registry.npmjs.org @nowcrew/daemon@latest install \
49
- --server-url https://lab.example.com --api-key sk_machine_... --profile lab
56
+ npx -y --registry https://registry.npmjs.org @nowcrew/daemon@beta install \
57
+ --server-url https://lab.example.com --api-key sk_machine_... --profile lab --release-tag beta
50
58
  ```
51
59
 
52
60
  Custom profiles use `~/.crew/daemon-<profile>`, `~/.crew/agents-<profile>`, and
@@ -95,13 +103,16 @@ Upgrade the global daemon package, optionally restarting one installed profile a
95
103
  ```bash
96
104
  crew-daemon upgrade
97
105
  crew-daemon upgrade --profile work
106
+ crew-daemon upgrade --profile work --release-tag beta
98
107
  ```
99
108
 
100
109
  With `--profile`, upgrade validates isolation, stops the native service, acquires the installation lease,
101
110
  replaces only the entrypoint-derived npm prefix, releases the lease, and starts the service. If package
102
111
  replacement fails, it still attempts to start the previous installation. Without `--profile`, upgrade
103
- refuses to replace a prefix currently leased by a running daemon; use the profile form for a managed
104
- service.
112
+ refuses to replace a prefix currently leased by a running daemon; it also permits only the currently
113
+ installed daemon's release channel. Cross-channel upgrades (for example installing `beta` from a stable
114
+ global package) require `--profile`, so the target server environment and profile isolation are verified.
115
+ Use the profile form for any managed service.
105
116
 
106
117
  ## Managed Self-Update
107
118
 
@@ -171,7 +182,7 @@ daemon home also contributes a stable hash to the native service ID, so equal pr
171
182
 
172
183
  Before replacing files, the daemon refuses new work, requires zero local active or queued work, and
173
184
  non-blockingly acquires every host execution slot. It then installs only the server-selected exact
174
- `x.y.z` version with npm using the prefix derived from the running global entrypoint, verifies the installed
185
+ exact `x.y.z` or `x.y.z-beta.n` version with npm using the prefix derived from the running global entrypoint, verifies the installed
175
186
  package version, and schedules the existing native service restart from a detached helper. macOS uses
176
187
  `launchctl kickstart -k` to avoid the `bootout`/`bootstrap` race; Linux uses `systemctl --user --no-block
177
188
  restart`. The helper reports manager failure over IPC. A failed handoff releases both execution barriers and
@@ -195,11 +206,11 @@ Failures are recoverable and keep a bounded code in the machine record:
195
206
  | `update_in_progress` | wait for the active request to reach a terminal state |
196
207
  | `update_timeout` | inspect `crew-daemon status --profile work`, restart if needed, then retry |
197
208
 
198
- Remote downgrade is intentionally unsupported. To roll back, install a known exact release and restart
199
- the service manually:
209
+ The Web UI supports owner/admin rollback to an exact release in the configured channel. When the managed
210
+ control plane is unavailable, install a known exact release and restart the service manually:
200
211
 
201
212
  ```bash
202
- npm install --global --prefix <environment-prefix> --ignore-scripts --no-audit --no-fund @nowcrew/daemon@<previous-x.y.z>
213
+ npm install --global --prefix <environment-prefix> --ignore-scripts --no-audit --no-fund @nowcrew/daemon@<previous-release>
203
214
  crew-daemon restart --profile work
204
215
  crew-daemon status --profile work
205
216
  ```
@@ -8,6 +8,9 @@ import { ManagedServiceConflictError, managedServiceConflict, managedServiceIden
8
8
  import { resolveAutomaticInstallTarget, } from "./automatic-install-target.js";
9
9
  import { acquireDaemonInstallationLease, } from "./daemon-installation-lease.js";
10
10
  import { daemonVersion } from "./machine-info.js";
11
+ import { daemonReleaseProvenance } from "./release-provenance.js";
12
+ import { daemonReleaseChannel, isDaemonReleaseVersion } from "./daemon-release-version.js";
13
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
11
14
  import { discoverExactLegacyService, } from "./automatic-service-migration.js";
12
15
  function profileStorage(platform, userHome, runner) {
13
16
  return {
@@ -175,7 +178,7 @@ export function stableDaemonEntry(target, platform) {
175
178
  ? paths.join(target.npmPrefix, "node_modules", "@nowcrew", "daemon", "dist", "main.js")
176
179
  : paths.join(target.npmPrefix, "lib", "node_modules", "@nowcrew", "daemon", "dist", "main.js");
177
180
  }
178
- export async function installExactDaemonPackage(npmPrefix, version, platform, runner) {
181
+ export async function installExactDaemonPackage(npmPrefix, version, platform, runner, registryUrl) {
179
182
  const command = platform === "win32" ? "npm.cmd" : "npm";
180
183
  const result = await runner(command, [
181
184
  "install",
@@ -185,6 +188,9 @@ export async function installExactDaemonPackage(npmPrefix, version, platform, ru
185
188
  "--ignore-scripts",
186
189
  "--no-audit",
187
190
  "--no-fund",
191
+ ...(registryUrl === undefined ? [] : ["--registry", normalizeDaemonNpmRegistry(registryUrl, {
192
+ allowLoopbackHttp: daemonReleaseChannel(version) === "beta",
193
+ })]),
188
194
  `@nowcrew/daemon@${version}`,
189
195
  ]);
190
196
  if (result.exitCode !== 0) {
@@ -217,9 +223,18 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
217
223
  const target = resolveAutomaticInstallTarget({
218
224
  serverUrl: input.serverUrl,
219
225
  ...(input.requestedProfile === undefined ? {} : { requestedProfile: input.requestedProfile }),
226
+ ...(input.requestedReleaseTag === undefined ? {} : { requestedReleaseTag: input.requestedReleaseTag }),
227
+ ...(input.requestedRegistryUrl === undefined ? {} : { requestedRegistryUrl: input.requestedRegistryUrl }),
220
228
  userHome: dependencies.userHome,
221
229
  platform: dependencies.platform,
222
230
  });
231
+ if (!isDaemonReleaseVersion(dependencies.version)) {
232
+ throw new Error(`Invoking daemon package has an invalid release version '${dependencies.version}'`);
233
+ }
234
+ const invokingTag = daemonReleaseProvenance(dependencies.version).releaseTag;
235
+ if (invokingTag !== target.releaseTag) {
236
+ throw new Error(`Invoking daemon release tag '${invokingTag}' does not match target '${target.releaseTag}'`);
237
+ }
223
238
  const spec = buildServiceSpec({
224
239
  platform: dependencies.platform,
225
240
  profile: target.profile,
@@ -251,7 +266,7 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
251
266
  const lease = await dependencies.acquireInstallationLease(target.npmPrefix);
252
267
  let installFailure;
253
268
  try {
254
- await dependencies.installPackage(target.npmPrefix, dependencies.version, dependencies.platform, dependencies.runner);
269
+ await dependencies.installPackage(target.npmPrefix, dependencies.version, dependencies.platform, dependencies.runner, target.registryUrl);
255
270
  }
256
271
  catch (error) {
257
272
  installFailure = error;
@@ -280,6 +295,8 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
280
295
  serverUrl: target.serverUrl,
281
296
  machineToken,
282
297
  agentsRoot: target.agentsRoot,
298
+ releaseTag: target.releaseTag,
299
+ ...(target.registryUrl === undefined ? {} : { registryUrl: target.registryUrl }),
283
300
  ...(dependencies.runtimePath === undefined ? {} : { runtimePath: dependencies.runtimePath }),
284
301
  }, target.daemonHome);
285
302
  serviceReplacementStarted = true;
@@ -1,5 +1,6 @@
1
1
  import { posix, win32 } from "node:path";
2
2
  import { profileLayout, validateLocalInstanceName } from "./profile-layout.js";
3
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
3
4
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
4
5
  const KNOWN_TARGETS = {
5
6
  "nowwork-in.nowcoder.com": {
@@ -13,6 +14,23 @@ const KNOWN_TARGETS = {
13
14
  agentsDirectory: "agents-dev",
14
15
  },
15
16
  };
17
+ export function releaseTagForServerUrl(serverUrl) {
18
+ const parsed = parseServerUrl(serverUrl);
19
+ const known = KNOWN_TARGETS[parsed.hostname.toLowerCase()];
20
+ return known?.environment === "dev" ? "beta" : "latest";
21
+ }
22
+ export function assertReleaseTagForServerUrl(serverUrl, requested) {
23
+ if (requested === undefined)
24
+ return;
25
+ const parsed = parseServerUrl(serverUrl);
26
+ const known = KNOWN_TARGETS[parsed.hostname.toLowerCase()];
27
+ if (known === undefined)
28
+ return;
29
+ const expected = known.environment === "dev" ? "beta" : "latest";
30
+ if (requested !== expected) {
31
+ throw new Error(`Server hostname '${parsed.hostname}' requires release tag '${expected}'`);
32
+ }
33
+ }
16
34
  function parseServerUrl(value) {
17
35
  let parsed;
18
36
  try {
@@ -52,6 +70,7 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
52
70
  // Keep the no-flag target readable by old daemons; any workspace-scoped profile below
53
71
  // uses the canonical environment token (production, never in).
54
72
  const legacyProfile = environment === "production" ? "in" : environment;
73
+ assertReleaseTagForServerUrl(input.serverUrl, input.requestedReleaseTag);
55
74
  if (requestedProfile === undefined || requestedProfile === legacyProfile) {
56
75
  return Object.freeze({
57
76
  kind: "known",
@@ -60,6 +79,7 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
60
79
  daemonHome: paths.join(input.userHome, ".crew", environment === "production" ? "daemon" : "daemon-dev"),
61
80
  agentsRoot: paths.join(input.userHome, ".crew", environment === "production" ? "agents" : "agents-dev"),
62
81
  npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", legacyProfile),
82
+ releaseTag: environment === "dev" ? "beta" : "latest",
63
83
  });
64
84
  }
65
85
  if (requestedProfile === "in" || requestedProfile === "dev") {
@@ -88,6 +108,7 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
88
108
  daemonHome: layout.daemonHome,
89
109
  agentsRoot: layout.agentsRoot,
90
110
  npmPrefix: layout.npmPrefix,
111
+ releaseTag: environment === "dev" ? "beta" : "latest",
91
112
  });
92
113
  }
93
114
  export function resolveAutomaticInstallTarget(input) {
@@ -95,8 +116,15 @@ export function resolveAutomaticInstallTarget(input) {
95
116
  const hostname = parsed.hostname.toLowerCase();
96
117
  const paths = input.platform === "win32" ? win32 : posix;
97
118
  const known = KNOWN_TARGETS[hostname];
119
+ const targetReleaseTag = known?.environment === "dev"
120
+ ? "beta"
121
+ : input.requestedReleaseTag ?? "latest";
122
+ const registryUrl = input.requestedRegistryUrl === undefined
123
+ ? undefined
124
+ : normalizeDaemonNpmRegistry(input.requestedRegistryUrl, { allowLoopbackHttp: targetReleaseTag === "beta" });
98
125
  if (known !== undefined) {
99
- return knownTargetForProfile(known.environment, input.requestedProfile, input, parsed);
126
+ const target = knownTargetForProfile(known.environment, input.requestedProfile, input, parsed);
127
+ return registryUrl === undefined ? target : Object.freeze({ ...target, registryUrl });
100
128
  }
101
129
  const profile = requireCustomProfile(hostname, input.requestedProfile);
102
130
  return Object.freeze({
@@ -106,5 +134,7 @@ export function resolveAutomaticInstallTarget(input) {
106
134
  daemonHome: paths.join(input.userHome, ".crew", `daemon-${profile}`),
107
135
  agentsRoot: paths.join(input.userHome, ".crew", `agents-${profile}`),
108
136
  npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", profile),
137
+ releaseTag: input.requestedReleaseTag ?? "latest",
138
+ ...(registryUrl === undefined ? {} : { registryUrl }),
109
139
  });
110
140
  }
@@ -4,10 +4,14 @@ import { resolve } 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
+ import { releaseTagForServerUrl, assertReleaseTagForServerUrl } from "./automatic-install-target.js";
8
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
7
9
  import { defaultProcessController } from "./execution-journal.js";
8
10
  import { inspectJournalLock } from "./execution-journal-lock.js";
9
11
  import { detectDaemonLang, formatDaemonText } from "./i18n.js";
10
12
  import { daemonGlobalInstallation } from "./daemon-installation.js";
13
+ import { daemonVersion } from "./machine-info.js";
14
+ import { daemonReleaseProvenance } from "./release-provenance.js";
11
15
  import { acquireDaemonInstallationLease, } from "./daemon-installation-lease.js";
12
16
  import { assertManagedServiceIdentity, buildManagedServiceRecord, installManagedService, uninstallUnregisteredManagedService, uninstallManagedService, } from "./managed-service-lifecycle.js";
13
17
  import { inspectManagedServiceDiagnostics } from "./managed-service-diagnostics.js";
@@ -57,11 +61,11 @@ export function isGlobalDaemonEntry(entryPath, globalNodeModules) {
57
61
  function usage(td) {
58
62
  return [
59
63
  td("Computer lifecycle:"),
60
- " crew-daemon profile save <name> --server-url <url> [--agents-root <path>] [--token-stdin]",
64
+ " crew-daemon profile save <name> --server-url <url> [--release-tag <latest|beta>] [--registry <https-url>] [--agents-root <path>] [--token-stdin]",
61
65
  " crew-daemon profile list|show|remove [<name>]",
62
- " crew-daemon install --server-url <url> --api-key <sk_machine_*> [--profile <custom-name>]",
66
+ " crew-daemon install --server-url <url> --api-key <sk_machine_*> [--profile <custom-name>] [--release-tag <latest|beta>] [--registry <https-url>]",
63
67
  " crew-daemon doctor|install|uninstall|start|stop|restart|status --profile <name>",
64
- " crew-daemon upgrade [--profile <name>]",
68
+ " crew-daemon upgrade [--profile <name>] [--release-tag <latest|beta>] [--registry <https-url>]",
65
69
  "",
66
70
  td("profile save reads the machine token from CREW_MACHINE_TOKEN, or stdin with --token-stdin."),
67
71
  ].join("\n") + "\n";
@@ -105,6 +109,8 @@ export async function runComputerCommand(argv, overrides = {}) {
105
109
  options: {
106
110
  profile: { type: "string" },
107
111
  "server-url": { type: "string" },
112
+ "release-tag": { type: "string" },
113
+ registry: { type: "string" },
108
114
  "agents-root": { type: "string" },
109
115
  "token-stdin": { type: "boolean", default: false },
110
116
  "api-key": { type: "string" },
@@ -118,6 +124,14 @@ export async function runComputerCommand(argv, overrides = {}) {
118
124
  }
119
125
  const [, action, positionalName] = parsed.positionals;
120
126
  const profileName = parsed.values.profile;
127
+ const requestedReleaseTag = parsed.values["release-tag"];
128
+ const requestedRegistryUrl = parsed.values.registry;
129
+ const configuredRegistryUrl = requestedRegistryUrl === undefined
130
+ ? deps.env.CREW_NPM_REGISTRY?.trim() || undefined
131
+ : requestedRegistryUrl;
132
+ if (requestedReleaseTag !== undefined && requestedReleaseTag !== "latest" && requestedReleaseTag !== "beta") {
133
+ throw new Error(td("Invalid release tag; expected latest or beta"));
134
+ }
121
135
  const automaticInstall = command === "install" && parsed.values["server-url"] !== undefined;
122
136
  const tokenOption = parsed.values["api-key"] !== undefined
123
137
  ? "--api-key"
@@ -143,6 +157,10 @@ export async function runComputerCommand(argv, overrides = {}) {
143
157
  serverUrl: parsed.values["server-url"],
144
158
  machineToken,
145
159
  ...(profileName === undefined ? {} : { requestedProfile: profileName }),
160
+ ...(requestedReleaseTag === undefined ? {} : { requestedReleaseTag }),
161
+ ...(configuredRegistryUrl === undefined
162
+ ? {}
163
+ : { requestedRegistryUrl: configuredRegistryUrl }),
146
164
  });
147
165
  deps.stdout(`${td("Installed daemon {{version}} for profile '{{profile}}'; service '{{service}}' is running.", { version: result.version, profile: result.profile, service: result.serviceId })}\n`);
148
166
  return 0;
@@ -187,10 +205,19 @@ export async function runComputerCommand(argv, overrides = {}) {
187
205
  const machineToken = (fromStdin ? await deps.readStdin() : deps.env.CREW_MACHINE_TOKEN)?.trim();
188
206
  if (!machineToken)
189
207
  throw new Error(td("Missing machine token; use CREW_MACHINE_TOKEN or --token-stdin"));
208
+ const serverUrl = requireValue(parsed.values["server-url"], "--server-url", td);
209
+ assertReleaseTagForServerUrl(serverUrl, requestedReleaseTag);
210
+ const profileReleaseTag = requestedReleaseTag ?? releaseTagForServerUrl(serverUrl);
190
211
  const profile = {
191
212
  name,
192
- serverUrl: requireValue(parsed.values["server-url"], "--server-url", td),
213
+ serverUrl,
193
214
  machineToken,
215
+ releaseTag: profileReleaseTag,
216
+ ...(configuredRegistryUrl === undefined ? {} : {
217
+ registryUrl: normalizeDaemonNpmRegistry(configuredRegistryUrl, {
218
+ allowLoopbackHttp: profileReleaseTag === "beta",
219
+ }),
220
+ }),
194
221
  ...(parsed.values["agents-root"]
195
222
  ? { agentsRoot: resolveAgentsRoot(parsed.values["agents-root"], deps.userHome, deps.platform) }
196
223
  : {}),
@@ -205,6 +232,19 @@ export async function runComputerCommand(argv, overrides = {}) {
205
232
  const restartProfile = profileName
206
233
  ? await loadProfile(profileName, home, profileStorage)
207
234
  : null;
235
+ if (!restartProfile && requestedReleaseTag !== undefined) {
236
+ const installedReleaseTag = daemonReleaseProvenance(daemonVersion(), deps.env).releaseTag;
237
+ if (requestedReleaseTag !== installedReleaseTag) {
238
+ throw new Error(td("Cross-channel daemon upgrade requires --profile so the server environment can be verified"));
239
+ }
240
+ }
241
+ const releaseTag = requestedReleaseTag
242
+ ?? restartProfile?.releaseTag
243
+ ?? (restartProfile === null
244
+ ? daemonReleaseProvenance(daemonVersion(), deps.env).releaseTag
245
+ : releaseTagForServerUrl(restartProfile.serverUrl));
246
+ if (restartProfile)
247
+ assertReleaseTagForServerUrl(restartProfile.serverUrl, releaseTag);
208
248
  const spec = restartProfile
209
249
  ? buildServiceSpec({
210
250
  ...deps,
@@ -215,7 +255,7 @@ export async function runComputerCommand(argv, overrides = {}) {
215
255
  : null;
216
256
  if (spec && restartProfile) {
217
257
  if (installation === null && !isGlobalDaemonEntry(deps.entryPath, await deps.resolveGlobalNodeModules())) {
218
- throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest"));
258
+ throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@{{tag}}", { tag: releaseTag }));
219
259
  }
220
260
  const before = await serviceStatus(spec, deps.runner);
221
261
  if (!before.installed)
@@ -236,7 +276,10 @@ export async function runComputerCommand(argv, overrides = {}) {
236
276
  installationLease = installation === null
237
277
  ? null
238
278
  : await deps.acquireInstallationLease(installation.npmPrefix, { userHome: deps.userHome });
239
- await upgradeDaemon(deps.platform, deps.runner, installation?.npmPrefix);
279
+ await upgradeDaemon(deps.platform, deps.runner, installation?.npmPrefix, releaseTag, configuredRegistryUrl === undefined
280
+ ? restartProfile?.registryUrl
281
+ ?? configuredRegistryUrl
282
+ : normalizeDaemonNpmRegistry(configuredRegistryUrl, { allowLoopbackHttp: releaseTag === "beta" }));
240
283
  }
241
284
  catch (error) {
242
285
  upgradeError = error;
@@ -274,6 +317,14 @@ export async function runComputerCommand(argv, overrides = {}) {
274
317
  }
275
318
  const name = requireValue(profileName, "--profile", td);
276
319
  const profile = await loadProfile(name, home, profileStorage);
320
+ const profileReleaseTag = requestedReleaseTag ?? profile.releaseTag ?? releaseTagForServerUrl(profile.serverUrl);
321
+ if (command === "install") {
322
+ assertReleaseTagForServerUrl(profile.serverUrl, profileReleaseTag);
323
+ const installedReleaseTag = daemonReleaseProvenance(daemonVersion(), deps.env).releaseTag;
324
+ if (installedReleaseTag !== profileReleaseTag) {
325
+ throw new Error(td("Installed daemon release tag '{{installed}}' does not match profile '{{profile}}' release tag '{{target}}'", { installed: installedReleaseTag, profile: name, target: profileReleaseTag }));
326
+ }
327
+ }
277
328
  if (command === "install" || command === "start" || command === "restart") {
278
329
  await assertProfileAgentsRootUnique(profile, home, deps.userHome, profileStorage);
279
330
  }
@@ -296,7 +347,8 @@ export async function runComputerCommand(argv, overrides = {}) {
296
347
  : null;
297
348
  if (command === "install") {
298
349
  if (installation === null && !isGlobalDaemonEntry(deps.entryPath, await deps.resolveGlobalNodeModules())) {
299
- throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@latest"));
350
+ const releaseTag = requestedReleaseTag ?? profile.releaseTag ?? releaseTagForServerUrl(profile.serverUrl);
351
+ throw new Error(td("Service lifecycle requires a global @nowcrew/daemon install; run npm install --global @nowcrew/daemon@{{tag}}", { tag: releaseTag }));
300
352
  }
301
353
  if (managedRecord === null)
302
354
  await installService(spec, deps.runner);
@@ -8,33 +8,57 @@ import { z } from "zod";
8
8
  import { acquireProfileSaveLock, } from "./computer-profile-lock.js";
9
9
  import { formatDaemonText } from "./i18n.js";
10
10
  import { atomicPrivateWrite } from "./atomic-private-write.js";
11
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
11
12
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
12
13
  export const MachineTokenSchema = z.string().startsWith("sk_machine_").max(4096);
13
14
  const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
14
15
  const url = new URL(value);
15
16
  return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
16
17
  }, "serverUrl must be an HTTP(S) URL without embedded credentials");
17
- const ProfileSchema = z.object({
18
+ const ProfileFields = z.object({
18
19
  version: z.literal(1),
19
20
  name: z.string().regex(PROFILE_NAME),
20
21
  serverUrl: ServerUrlSchema,
21
22
  machineToken: MachineTokenSchema,
22
23
  agentsRoot: z.string().min(1).optional(),
23
24
  runtimePath: z.string().min(1).max(32768).refine((value) => !value.includes("\0") && !value.includes("\n") && !value.includes("\r"), "runtimePath must be a single line").optional(),
25
+ releaseTag: z.enum(["latest", "beta"]).optional(),
26
+ registryUrl: z.string().url().optional(),
24
27
  codexHomeMode: z.enum(["shared", "private"]).optional(),
25
28
  }).strict();
26
- const ProfileAgentsRootCandidateSchema = ProfileSchema.pick({
29
+ function validateProfileRegistry(value, context) {
30
+ const fields = value;
31
+ if (fields.registryUrl === undefined)
32
+ return;
33
+ try {
34
+ const normalized = normalizeDaemonNpmRegistry(fields.registryUrl, {
35
+ allowLoopbackHttp: fields.releaseTag === "beta",
36
+ });
37
+ if (normalized !== fields.registryUrl) {
38
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["registryUrl"], message: "registryUrl must be normalized" });
39
+ }
40
+ }
41
+ catch (error) {
42
+ context.addIssue({
43
+ code: z.ZodIssueCode.custom,
44
+ path: ["registryUrl"],
45
+ message: error instanceof Error ? error.message : "registryUrl must be HTTPS",
46
+ });
47
+ }
48
+ }
49
+ const ProfileSchema = ProfileFields.superRefine(validateProfileRegistry);
50
+ const ProfileAgentsRootCandidateSchema = ProfileFields.pick({
27
51
  name: true,
28
52
  serverUrl: true,
29
53
  agentsRoot: true,
30
54
  }).strict();
31
55
  const StoredPlainProfileSchema = ProfileSchema;
32
- const StoredProtectedProfileSchema = ProfileSchema.omit({ machineToken: true }).extend({
56
+ const StoredProtectedProfileSchema = ProfileFields.omit({ machineToken: true }).extend({
33
57
  machineTokenProtected: z.object({
34
58
  scheme: z.literal("dpapi-current-user"),
35
59
  ciphertext: z.string().min(1).max(16384),
36
60
  }).strict(),
37
- }).strict();
61
+ }).strict().superRefine(validateProfileRegistry);
38
62
  const StoredProfileSchema = z.union([StoredPlainProfileSchema, StoredProtectedProfileSchema]);
39
63
  export const PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE = "Profile '{{profile}}' conflicts with profile '{{conflict}}': both resolve to agents root '{{agentsRoot}}'. Save it with a unique root, for example: {{command}}";
40
64
  export class ProfileAgentsRootConflictError extends Error {
@@ -218,6 +242,8 @@ export async function saveProfile(input, home = daemonHome(), options = {}) {
218
242
  serverUrl: profile.serverUrl,
219
243
  ...(profile.agentsRoot ? { agentsRoot: profile.agentsRoot } : {}),
220
244
  ...(profile.runtimePath ? { runtimePath: profile.runtimePath } : {}),
245
+ ...(profile.releaseTag === undefined ? {} : { releaseTag: profile.releaseTag }),
246
+ ...(profile.registryUrl === undefined ? {} : { registryUrl: profile.registryUrl }),
221
247
  ...(profile.codexHomeMode === undefined ? {} : { codexHomeMode: profile.codexHomeMode }),
222
248
  machineTokenProtected: { scheme: "dpapi-current-user", ciphertext },
223
249
  });
@@ -261,6 +287,8 @@ export async function loadProfile(name, home = daemonHome(), options = {}) {
261
287
  serverUrl: stored.serverUrl,
262
288
  ...(stored.agentsRoot ? { agentsRoot: stored.agentsRoot } : {}),
263
289
  ...(stored.runtimePath ? { runtimePath: stored.runtimePath } : {}),
290
+ ...(stored.releaseTag === undefined ? {} : { releaseTag: stored.releaseTag }),
291
+ ...(stored.registryUrl === undefined ? {} : { registryUrl: stored.registryUrl }),
264
292
  machineToken: await configured.protector.unprotect(stored.machineTokenProtected.ciphertext),
265
293
  });
266
294
  }
@@ -355,5 +383,13 @@ export function applyProfileToEnv(profile, env) {
355
383
  delete env.CREW_AGENTS_ROOT;
356
384
  if (profile.runtimePath)
357
385
  env.PATH = profile.runtimePath;
386
+ if (profile.releaseTag)
387
+ env.CREW_DAEMON_RELEASE_TAG = profile.releaseTag;
388
+ else
389
+ delete env.CREW_DAEMON_RELEASE_TAG;
390
+ if (profile.registryUrl)
391
+ env.CREW_NPM_REGISTRY = profile.registryUrl;
392
+ else
393
+ delete env.CREW_NPM_REGISTRY;
358
394
  env.CREW_CODEX_HOME_MODE = profile.codexHomeMode ?? "shared";
359
395
  }
@@ -4,6 +4,7 @@ import { execFile, spawn } from "node:child_process";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { promisify } from "node:util";
6
6
  import { dirname, resolve } from "node:path";
7
+ import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
7
8
  const execFileAsync = promisify(execFile);
8
9
  const WINDOWS_STOP_TIMEOUT_MS = 5_000;
9
10
  const WINDOWS_STOP_POLL_MS = 100;
@@ -541,19 +542,23 @@ export async function doctorService(spec, profilePrivate, runner = systemCommand
541
542
  { name: "service-running", ok: status.running === true, detail: status.running ? "running" : "not running" },
542
543
  ];
543
544
  }
544
- export function buildUpgradeCommand(platform, npmPrefix) {
545
+ export function buildUpgradeCommand(platform, npmPrefix, releaseTag = "latest", registryUrl) {
545
546
  return [
546
547
  platform === "win32" ? "npm.cmd" : "npm",
547
548
  [
548
549
  "install",
549
550
  "--global",
551
+ "--ignore-scripts",
552
+ "--no-audit",
553
+ "--no-fund",
550
554
  ...(npmPrefix === undefined ? [] : ["--prefix", npmPrefix]),
551
- "@nowcrew/daemon@latest",
555
+ ...(registryUrl === undefined ? [] : ["--registry", normalizeDaemonNpmRegistry(registryUrl, { allowLoopbackHttp: releaseTag === "beta" })]),
556
+ `@nowcrew/daemon@${releaseTag}`,
552
557
  ],
553
558
  ];
554
559
  }
555
- export async function upgradeDaemon(platform, runner = systemCommandRunner, npmPrefix) {
556
- const [command, args] = buildUpgradeCommand(platform, npmPrefix);
560
+ export async function upgradeDaemon(platform, runner = systemCommandRunner, npmPrefix, releaseTag = "latest", registryUrl) {
561
+ const [command, args] = buildUpgradeCommand(platform, npmPrefix, releaseTag, registryUrl);
557
562
  await checked(runner, command, args, "Daemon upgrade");
558
563
  }
559
564
  const WINDOWS_SID_SCRIPT = "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value";
@@ -119,10 +119,12 @@ export function createDaemonMigrationController(input) {
119
119
  let currentSpec;
120
120
  let currentService;
121
121
  let managedRecord = null;
122
+ let currentRegistryUrl;
122
123
  try {
123
124
  const currentProfile = await (input.loadCurrentProfile ?? loadProfile)(input.profileName, input.currentDaemonHome);
124
125
  if (currentProfile.machineToken !== input.machineToken)
125
126
  throw new Error("active profile token does not match this daemon");
127
+ currentRegistryUrl = currentProfile.registryUrl;
126
128
  const target = resolveAutomaticInstallTarget({ serverUrl: input.serverUrl, requestedProfile: message.targetProfile, userHome, platform });
127
129
  if (!isAbsolute(message.targetDaemonHome) || !isAbsolute(message.targetAgentsRoot)
128
130
  || resolve(message.targetDaemonHome) !== resolve(target.daemonHome)
@@ -165,6 +167,7 @@ export function createDaemonMigrationController(input) {
165
167
  serverUrl: input.serverUrl,
166
168
  machineToken: input.machineToken,
167
169
  requestedProfile: message.targetProfile,
170
+ ...(currentRegistryUrl === undefined ? {} : { requestedRegistryUrl: currentRegistryUrl }),
168
171
  });
169
172
  if (result.profile !== message.targetProfile || result.serviceId.length === 0) {
170
173
  throw new Error("target service identity did not match the requested profile");
@@ -0,0 +1,22 @@
1
+ import { compare, prerelease, valid } from "semver";
2
+ const CANONICAL_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta(?:\.|-)(0|[1-9]\d*))?$/u;
3
+ function normalizedVersion(input) {
4
+ return input.replace(/-beta-(0|[1-9]\d*)$/u, "-beta.$1");
5
+ }
6
+ export function isDaemonReleaseVersion(input) {
7
+ const raw = input.trim();
8
+ if (raw !== input || !CANONICAL_VERSION.test(raw) || valid(raw) !== raw)
9
+ return false;
10
+ const parts = prerelease(raw);
11
+ return parts === null || normalizedVersion(raw).includes("-beta.");
12
+ }
13
+ export function daemonReleaseChannel(version) {
14
+ if (!isDaemonReleaseVersion(version))
15
+ return null;
16
+ return prerelease(version) === null ? "stable" : "beta";
17
+ }
18
+ export function compareDaemonReleaseVersions(left, right) {
19
+ return isDaemonReleaseVersion(left) && isDaemonReleaseVersion(right)
20
+ ? compare(normalizedVersion(left), normalizedVersion(right))
21
+ : 0;
22
+ }
@@ -1,13 +1,14 @@
1
1
  import { z } from "zod";
2
+ import { isDaemonReleaseVersion } from "./daemon-release-version.js";
2
3
  const DaemonUpdateMessageSchema = z.object({
3
4
  type: z.literal("daemon:update"),
4
5
  updateId: z.string().uuid(),
5
- targetVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
6
+ targetVersion: z.string().refine(isDaemonReleaseVersion, "invalid daemon release version"),
6
7
  }).strict();
7
8
  const DaemonRestartMessageSchema = z.object({
8
9
  type: z.literal("daemon:restart"),
9
10
  updateId: z.string().uuid(),
10
- targetVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
11
+ targetVersion: z.string().refine(isDaemonReleaseVersion, "invalid daemon release version"),
11
12
  }).strict();
12
13
  const DaemonControlMessageSchema = z.union([DaemonUpdateMessageSchema, DaemonRestartMessageSchema]);
13
14
  export function createDaemonUpdateController(deps) {