@cydm/happy-elves 0.1.0-beta.72 → 0.1.0-beta.73

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 (94) hide show
  1. package/apps/cli/dist/commands/daemon.js +3 -0
  2. package/apps/cli/dist/commands/lib/account-snapshot.d.ts +1 -1
  3. package/apps/cli/dist/commands/lib/account-snapshot.js +36 -5
  4. package/apps/cli/dist/commands/lib/bootstrap-config.d.ts +4 -2
  5. package/apps/cli/dist/commands/lib/bootstrap-config.js +51 -35
  6. package/apps/cli/dist/commands/lib/bootstrap-daemon.d.ts +1 -0
  7. package/apps/cli/dist/commands/lib/bootstrap-daemon.js +33 -12
  8. package/apps/cli/dist/commands/lib/bootstrap-device-recovery.d.ts +20 -0
  9. package/apps/cli/dist/commands/lib/bootstrap-device-recovery.js +261 -134
  10. package/apps/cli/dist/commands/lib/bootstrap-join.js +3 -0
  11. package/apps/cli/dist/commands/lib/bootstrap-output.d.ts +11 -1
  12. package/apps/cli/dist/commands/lib/bootstrap-output.js +31 -16
  13. package/apps/cli/dist/commands/lib/bootstrap.d.ts +2 -0
  14. package/apps/cli/dist/commands/lib/bootstrap.js +200 -34
  15. package/apps/cli/dist/commands/lib/config.d.ts +8 -0
  16. package/apps/cli/dist/commands/lib/config.js +105 -2
  17. package/apps/cli/dist/commands/lib/daemon-enrollment-intent.d.ts +6 -0
  18. package/apps/cli/dist/commands/lib/daemon-enrollment-intent.js +10 -0
  19. package/apps/cli/dist/commands/lib/doctor.js +53 -14
  20. package/apps/cli/dist/commands/lib/exit.js +2 -5
  21. package/apps/cli/dist/commands/lib/fresh-start-intent.d.ts +26 -0
  22. package/apps/cli/dist/commands/lib/fresh-start-intent.js +169 -0
  23. package/apps/cli/dist/commands/lib/local-daemon.d.ts +50 -4
  24. package/apps/cli/dist/commands/lib/local-daemon.js +201 -5
  25. package/apps/cli/dist/commands/lib/paths.d.ts +1 -0
  26. package/apps/cli/dist/commands/lib/paths.js +1 -0
  27. package/apps/daemon/dist/account-config-lock.d.ts +10 -0
  28. package/apps/daemon/dist/account-config-lock.js +35 -0
  29. package/apps/daemon/dist/args.js +11 -1
  30. package/apps/daemon/dist/cli.js +50 -2
  31. package/apps/daemon/dist/lifecycle/detached-helper-bundle.mjs +4764 -1043
  32. package/apps/daemon/dist/lifecycle/helper.d.ts +1 -0
  33. package/apps/daemon/dist/lifecycle/helper.js +39 -5
  34. package/apps/daemon/dist/lifecycle/operation.d.ts +2 -0
  35. package/apps/daemon/dist/lifecycle/operation.js +46 -1
  36. package/apps/daemon/dist/lifecycle/restart.d.ts +1 -0
  37. package/apps/daemon/dist/lifecycle/restart.js +7 -3
  38. package/apps/daemon/dist/lifecycle/runtime.js +19 -0
  39. package/apps/daemon/dist/lifecycle/service.d.ts +30 -0
  40. package/apps/daemon/dist/lifecycle/service.js +86 -0
  41. package/apps/daemon/dist/lifecycle/types.d.ts +1 -1
  42. package/apps/daemon/dist/relay/connection.js +1 -1
  43. package/apps/daemon/dist/relay/register.d.ts +4 -2
  44. package/apps/daemon/dist/relay/register.js +24 -2
  45. package/apps/daemon/dist/runtime/external-provider.js +21 -4
  46. package/apps/daemon/dist/session/primitives.js +7 -1
  47. package/apps/daemon/dist/session/prompt.js +1 -0
  48. package/apps/daemon/dist/start.d.ts +19 -0
  49. package/apps/daemon/dist/start.js +87 -6
  50. package/apps/daemon/package.json +1 -1
  51. package/apps/relay/dist/account-access-revocation.d.ts +1 -0
  52. package/apps/relay/dist/account-access-revocation.js +18 -2
  53. package/apps/relay/dist/account-http-routes.js +324 -54
  54. package/apps/relay/dist/account-projections.d.ts +2 -0
  55. package/apps/relay/dist/account-projections.js +6 -1
  56. package/apps/relay/dist/db.js +48 -4
  57. package/apps/relay/dist/device-recovery-repository.d.ts +1 -0
  58. package/apps/relay/dist/device-recovery-repository.js +11 -4
  59. package/apps/relay/dist/enrollment-repository.d.ts +11 -0
  60. package/apps/relay/dist/enrollment-repository.js +18 -0
  61. package/apps/relay/dist/http-routes.js +185 -51
  62. package/apps/relay/dist/http-schemas.d.ts +6 -0
  63. package/apps/relay/dist/http-schemas.js +14 -0
  64. package/apps/relay/dist/machine-command-result-handlers.js +12 -7
  65. package/apps/relay/dist/projections.js +4 -1
  66. package/apps/relay/dist/relay-context.d.ts +5 -1
  67. package/apps/relay/dist/relay-context.js +10 -3
  68. package/apps/relay/dist/types.d.ts +4 -0
  69. package/apps/relay/dist/websocket.js +3 -0
  70. package/build-identity.json +2 -2
  71. package/npm-shrinkwrap.json +5 -5
  72. package/package.json +2 -1
  73. package/packages/client/dist/account-management.d.ts +39 -2
  74. package/packages/client/dist/account-management.js +214 -6
  75. package/packages/client/dist/account.d.ts +21 -1
  76. package/packages/client/dist/account.js +38 -3
  77. package/packages/client/dist/client.d.ts +5 -1
  78. package/packages/client/dist/client.js +2 -2
  79. package/packages/client/dist/index.d.ts +5 -4
  80. package/packages/client/dist/index.js +2 -2
  81. package/packages/client/dist/parsers.d.ts +2 -1
  82. package/packages/client/dist/parsers.js +8 -0
  83. package/packages/client/dist/transport.js +2 -0
  84. package/packages/daemon-service/dist/index.d.ts +172 -0
  85. package/packages/daemon-service/dist/index.js +2329 -0
  86. package/packages/shared/dist/account.d.ts +51 -2
  87. package/packages/shared/dist/account.js +16 -1
  88. package/packages/shared/dist/daemon-lifecycle.d.ts +35 -1
  89. package/packages/shared/dist/daemon-lifecycle.js +20 -2
  90. package/packages/shared/dist/protocol-schemas.d.ts +41 -3
  91. package/packages/shared/dist/protocol-schemas.js +2 -1
  92. package/packages/shared/dist/protocol-types.d.ts +8 -1
  93. package/packages/shared/dist/protocol.d.ts +6 -1
  94. package/packages/shared/dist/protocol.js +5 -0
@@ -329,6 +329,7 @@ export async function handleDaemon({ domain, action, flags }) {
329
329
  return {
330
330
  ...readiness.local,
331
331
  started: daemon.started,
332
+ ...(daemon.autostartNeedsAttention ? { needsAttention: daemon.autostartNeedsAttention } : {}),
332
333
  machineId: readiness.machine?.id ?? config.machineId,
333
334
  relayAuthenticated: readiness.relayAuthenticated,
334
335
  ...(readiness.relayAuthenticated === "verified" ? { online: true } : {}),
@@ -518,6 +519,8 @@ function writeHumanDaemonStartOutput(data) {
518
519
  if (data.relayAuthenticated === "unverified") {
519
520
  console.log("Local daemon identity is ready; relay authentication was not verified because this host has no matching Device credential.");
520
521
  }
522
+ if (data.needsAttention)
523
+ console.log(`Automatic startup needs attention: ${data.needsAttention}`);
521
524
  console.log("This command manages only the paired background daemon; it does not open a browser page.");
522
525
  console.log("For setup or browser onboarding, run: happy-elves start");
523
526
  }
@@ -6,7 +6,7 @@ type SnapshotMutationHooks = {
6
6
  afterTarget?(file: AccountSnapshotFile): Promise<void>;
7
7
  };
8
8
  export declare function recoverAccountSnapshotMutation(): Promise<void>;
9
- export declare function commitAccountSnapshotMutation(desired: AccountSnapshotContents, operation: "reset" | "restore", hooks?: SnapshotMutationHooks): Promise<{
9
+ export declare function commitAccountSnapshotMutation(desired: AccountSnapshotContents, operation: "reset" | "restore" | "adopt-device", hooks?: SnapshotMutationHooks): Promise<{
10
10
  restored: string[];
11
11
  removed: string[];
12
12
  }>;
@@ -71,7 +71,7 @@ function parseSnapshotIntent(raw) {
71
71
  if (value.version !== 1
72
72
  || typeof value.operationId !== "string"
73
73
  || !/^[a-f0-9]{48}$/u.test(value.operationId)
74
- || (value.operation !== "reset" && value.operation !== "restore")
74
+ || (value.operation !== "reset" && value.operation !== "restore" && value.operation !== "adopt-device")
75
75
  || (value.phase !== "apply" && value.phase !== "rollback")
76
76
  || value.bundle !== `${accountSnapshotBundlePrefix}${value.operationId}`
77
77
  || !Array.isArray(value.desired)
@@ -224,8 +224,23 @@ async function applySnapshot(intent, side, hooks) {
224
224
  desired: await readSnapshotSide(bundlePath, "desired", intent.desired),
225
225
  previous: await readSnapshotSide(bundlePath, "previous", intent.previous),
226
226
  };
227
+ if (intent.operation === "adopt-device") {
228
+ const expectedDaemon = snapshots.desired.get("daemon.json");
229
+ const previousDaemon = snapshots.previous.get("daemon.json");
230
+ const currentDaemon = await readRegularFile(daemonConfigPath, "Daemon configuration fence");
231
+ if (expectedDaemon === undefined
232
+ || previousDaemon === undefined
233
+ || currentDaemon === undefined
234
+ || expectedDaemon !== previousDaemon
235
+ || currentDaemon !== expectedDaemon) {
236
+ throw new CliError("The Daemon configuration changed during Device access recovery. The pending recovery was left unchanged.", "ACCOUNT_CONFIG_RECOVERY_REQUIRED");
237
+ }
238
+ }
227
239
  const entries = snapshots[side];
228
- for (const file of snapshotFiles) {
240
+ const targetFiles = intent.operation === "adopt-device"
241
+ ? ["config.json", "account.json"]
242
+ : snapshotFiles;
243
+ for (const file of targetFiles) {
229
244
  await hooks?.beforeTarget?.(file);
230
245
  const contents = entries.get(file);
231
246
  if (contents !== undefined) {
@@ -291,6 +306,15 @@ export async function recoverAccountSnapshotMutation() {
291
306
  export async function commitAccountSnapshotMutation(desired, operation, hooks) {
292
307
  await recoverAccountSnapshotMutation();
293
308
  const previous = await snapshotCurrentFiles();
309
+ if (operation === "adopt-device") {
310
+ if (desired["config.json"] === undefined
311
+ || desired["account.json"] === undefined
312
+ || desired["daemon.json"] === undefined
313
+ || previous["daemon.json"] === undefined
314
+ || desired["daemon.json"] !== previous["daemon.json"]) {
315
+ throw new CliError("Device access recovery requires an unchanged Daemon configuration fence.", "ACCOUNT_CONFIG_RECOVERY_REQUIRED");
316
+ }
317
+ }
294
318
  const operationId = randomBytes(24).toString("hex");
295
319
  const bundle = `${accountSnapshotBundlePrefix}${operationId}`;
296
320
  const bundlePath = path.join(configDir, bundle);
@@ -326,12 +350,19 @@ export async function commitAccountSnapshotMutation(desired, operation, hooks) {
326
350
  }
327
351
  if (error instanceof CliError)
328
352
  throw error;
329
- throw new CliError(`Account ${operation} failed; the previous configuration was restored: ${error instanceof Error ? error.message : String(error)}`, operation === "restore" ? "ACCOUNT_RESTORE_FAILED" : "ACCOUNT_RESET_FAILED");
353
+ throw new CliError(`Account ${operation} failed; the previous configuration was restored: ${error instanceof Error ? error.message : String(error)}`, operation === "restore"
354
+ ? "ACCOUNT_RESTORE_FAILED"
355
+ : operation === "adopt-device"
356
+ ? "DEVICE_ACCESS_REPAIR_FAILED"
357
+ : "ACCOUNT_RESET_FAILED");
330
358
  }
331
359
  const desiredFiles = new Set(desiredEntries.map((entry) => entry.file));
332
360
  const previousFiles = new Set(previousEntries.map((entry) => entry.file));
361
+ const reportedFiles = operation === "adopt-device"
362
+ ? ["config.json", "account.json"]
363
+ : snapshotFiles;
333
364
  return {
334
- restored: snapshotFiles.filter((file) => desiredFiles.has(file)).map(targetPath),
335
- removed: snapshotFiles.filter((file) => previousFiles.has(file) && !desiredFiles.has(file)).map(targetPath),
365
+ restored: reportedFiles.filter((file) => desiredFiles.has(file)).map(targetPath),
366
+ removed: reportedFiles.filter((file) => previousFiles.has(file) && !desiredFiles.has(file)).map(targetPath),
336
367
  };
337
368
  }
@@ -1,12 +1,13 @@
1
1
  import type { CliConfig, DaemonConfig } from "./types.js";
2
2
  import { type AccountConfigTransaction } from "./config.js";
3
+ import type { FreshStartIntentV1 } from "./fresh-start-intent.js";
3
4
  export type StartConfigPreflight = {
4
5
  kind: "fresh" | "controller-only" | "device-recovery" | "ready";
5
6
  relayUrl: string;
6
7
  explicitRelay: boolean;
7
8
  controller?: CliConfig;
8
9
  daemon?: DaemonConfig;
9
- recoveryReason?: "device-missing" | "device-account-mismatch" | "device-relay-mismatch";
10
+ recoveryReason?: "device-missing" | "device-account-mismatch" | "device-relay-mismatch" | "account-binding-mismatch";
10
11
  };
11
12
  /**
12
13
  * Read and classify both local identities before `start` performs any network
@@ -30,13 +31,14 @@ export declare function ensureControllerConfig(params: {
30
31
  transaction: AccountConfigTransaction;
31
32
  deferWrite?: boolean;
32
33
  signal?: AbortSignal;
34
+ freshIdentity?: FreshStartIntentV1;
33
35
  }): Promise<{
34
36
  config: CliConfig;
35
37
  created: boolean;
36
38
  accountId?: string;
37
39
  reconfigured: false;
38
40
  }>;
39
- export declare function ensureDaemonPaired(config: CliConfig, machineName: string, existing: DaemonConfig | undefined, transaction: AccountConfigTransaction, deferWrite?: boolean, signal?: AbortSignal, expectedAccountId?: string): Promise<{
41
+ export declare function ensureDaemonPaired(config: CliConfig, machineName: string, existing: DaemonConfig | undefined, transaction: AccountConfigTransaction, deferWrite?: boolean, signal?: AbortSignal, expectedAccountId?: string, freshIdentity?: FreshStartIntentV1): Promise<{
40
42
  config: DaemonConfig;
41
43
  created: boolean;
42
44
  reconfigured: false;
@@ -1,9 +1,9 @@
1
- import { ControllerClient } from "../../../../../packages/client/dist/index.js";
1
+ import { claimMachinePairing, ControllerClient, ControllerClientError } from "../../../../../packages/client/dist/index.js";
2
2
  import { generateAccountSecret, randomId } from "../../../../../packages/shared/dist/index.js";
3
3
  import { CliError } from "../../errors.js";
4
4
  import { defaultHostedControllerUrl, defaultHostedRelayUrl } from "./paths.js";
5
5
  import { canonicalRelayUrl, redactedRelayUrl, requireRelayUrl, withAccountConfigTransaction, } from "./config.js";
6
- import { cliErrorFromFetch, parsePairingClaimResponse, parsePairingStartResponse, readRelayJson, throwRelayHttpError } from "./relay-http.js";
6
+ import { cliErrorFromFetch, parsePairingStartResponse, readRelayJson, throwRelayHttpError } from "./relay-http.js";
7
7
  import { assertNoPendingEnrollmentAuthority } from "./pending-enrollment.js";
8
8
  /**
9
9
  * Read and classify both local identities before `start` performs any network
@@ -17,9 +17,6 @@ export async function readStartConfigPreflight(flags, transaction) {
17
17
  return await withAccountConfigTransaction(async (activeTransaction) => await readStartConfigPreflight(flags, activeTransaction));
18
18
  }
19
19
  const state = await transaction.readAccountState();
20
- if (state.binding && state.daemon && !state.daemonMatchesBinding) {
21
- throw new CliError("The local Daemon credential does not match account.json. No configuration or Daemon process was changed.", "CONFIG_SPLIT_BRAIN");
22
- }
23
20
  if (state.binding && state.controller && !state.controllerMatchesBinding && !state.daemon) {
24
21
  throw new CliError("The local Device credential does not match account.json, and there is no matching Daemon to authorize recovery. No configuration was changed.", "CONFIG_SPLIT_BRAIN");
25
22
  }
@@ -27,7 +24,9 @@ export async function readStartConfigPreflight(flags, transaction) {
27
24
  controller: state.controller,
28
25
  daemon: state.daemon,
29
26
  explicitRelayUrl: typeof flags.relay === "string" ? requireRelayUrl(flags) : undefined,
30
- forceDeviceRecovery: Boolean(state.binding && state.controller && !state.controllerMatchesBinding),
27
+ forceDeviceRecovery: Boolean(state.binding
28
+ && ((state.controller && !state.controllerMatchesBinding)
29
+ || (state.daemon && !state.daemonMatchesBinding))),
31
30
  });
32
31
  if (result.kind === "fresh" || result.kind === "controller-only") {
33
32
  await assertNoPendingEnrollmentAuthority();
@@ -62,9 +61,13 @@ export function classifyStartConfig(params) {
62
61
  explicitRelay: Boolean(explicitRelayUrl),
63
62
  controller,
64
63
  daemon,
65
- recoveryReason: controllerRelayUrl === daemonRelayUrl
66
- ? "device-account-mismatch"
67
- : "device-relay-mismatch",
64
+ recoveryReason: params.forceDeviceRecovery === true
65
+ && controllerRelayUrl === daemonRelayUrl
66
+ && controller.accountSecret === daemon.accountSecret
67
+ ? "account-binding-mismatch"
68
+ : controllerRelayUrl === daemonRelayUrl
69
+ ? "device-account-mismatch"
70
+ : "device-relay-mismatch",
68
71
  };
69
72
  }
70
73
  return {
@@ -83,13 +86,21 @@ function splitBrainError(controller, daemon) {
83
86
  "No configuration or Daemon process was changed.", "CONFIG_SPLIT_BRAIN");
84
87
  }
85
88
  async function createControllerConfig(params, transaction, deferWrite = false) {
86
- const accountSecret = generateAccountSecret();
89
+ const accountSecret = params.freshIdentity?.accountSecret ?? generateAccountSecret();
87
90
  let response;
88
91
  try {
89
92
  response = await fetch(`${params.relayUrl}/api/pairing/start`, {
90
93
  method: "POST",
91
94
  headers: { "content-type": "application/json" },
92
- body: JSON.stringify({ deviceId: randomId("dev"), deviceName: params.deviceName, surface: "cli" }),
95
+ body: JSON.stringify({
96
+ deviceId: params.freshIdentity?.deviceId ?? randomId("dev"),
97
+ deviceName: params.deviceName,
98
+ surface: "cli",
99
+ ...(params.freshIdentity ? {
100
+ requestId: params.freshIdentity.accountRequestId,
101
+ recoveryKey: params.freshIdentity.accountRecoveryKey,
102
+ } : {}),
103
+ }),
93
104
  signal: params.signal,
94
105
  });
95
106
  }
@@ -117,35 +128,40 @@ export async function ensureControllerConfig(params) {
117
128
  }
118
129
  return { config: params.existing, created: false, reconfigured: false };
119
130
  }
120
- const created = await createControllerConfig({ relayUrl: params.relayUrl, deviceName: params.deviceName, signal: params.signal }, params.transaction, params.deferWrite);
131
+ const created = await createControllerConfig({
132
+ relayUrl: params.relayUrl,
133
+ deviceName: params.deviceName,
134
+ signal: params.signal,
135
+ freshIdentity: params.freshIdentity,
136
+ }, params.transaction, params.deferWrite);
121
137
  return { config: created.config, created: true, accountId: created.accountId, reconfigured: false };
122
138
  }
123
- async function claimMachinePairing(params) {
124
- let response;
139
+ async function createDaemonConfig(config, machineName, transaction, deferWrite = false, signal, expectedAccountId, freshIdentity) {
140
+ const pairing = await new ControllerClient(config).createPairingCode({
141
+ signal,
142
+ ...(freshIdentity ? {
143
+ requestId: freshIdentity.pairingRequestId,
144
+ pairingCode: freshIdentity.pairingCode,
145
+ } : {}),
146
+ });
147
+ let claimed;
125
148
  try {
126
- response = await fetch(`${params.relayUrl}/api/pairing/claim`, {
127
- method: "POST",
128
- headers: { "content-type": "application/json" },
129
- body: JSON.stringify({ code: params.pairingCode, machineId: params.machineId }),
130
- signal: params.signal,
149
+ claimed = await claimMachinePairing({ relayUrl: config.relayUrl }, {
150
+ code: pairing.pairingCode,
151
+ machineId: freshIdentity?.machineId ?? randomId("mach"),
152
+ machineName,
153
+ signal,
154
+ ...(freshIdentity ? {
155
+ requestId: freshIdentity.claimRequestId,
156
+ recoveryKey: freshIdentity.claimRecoveryKey,
157
+ } : {}),
131
158
  });
132
159
  }
133
160
  catch (error) {
134
- throw cliErrorFromFetch(error, "RELAY_UNREACHABLE", `Could not reach ${redactedRelayUrl(params.relayUrl)}`);
135
- }
136
- if (!response.ok) {
137
- await throwRelayHttpError(response, "Machine enrollment failed");
161
+ if (error instanceof ControllerClientError)
162
+ throw new CliError(error.message, error.code);
163
+ throw cliErrorFromFetch(error, "RELAY_UNREACHABLE", `Could not reach ${redactedRelayUrl(config.relayUrl)}`);
138
164
  }
139
- return parsePairingClaimResponse(await readRelayJson(response, "Machine enrollment response is not valid JSON"));
140
- }
141
- async function createDaemonConfig(config, machineName, transaction, deferWrite = false, signal, expectedAccountId) {
142
- const pairing = await new ControllerClient(config).createPairingCode({ signal });
143
- const claimed = await claimMachinePairing({
144
- relayUrl: config.relayUrl,
145
- pairingCode: pairing.pairingCode,
146
- machineId: randomId("mach"),
147
- signal,
148
- });
149
165
  if (pairing.accountId !== claimed.accountId ||
150
166
  (expectedAccountId !== undefined && pairing.accountId !== expectedAccountId)) {
151
167
  throw new CliError("The relay returned inconsistent account identities while pairing this machine. No configuration or daemon process was changed.", "PAIRING_ACCOUNT_MISMATCH");
@@ -163,7 +179,7 @@ async function createDaemonConfig(config, machineName, transaction, deferWrite =
163
179
  }
164
180
  return daemonConfig;
165
181
  }
166
- export async function ensureDaemonPaired(config, machineName, existing, transaction, deferWrite = false, signal, expectedAccountId) {
182
+ export async function ensureDaemonPaired(config, machineName, existing, transaction, deferWrite = false, signal, expectedAccountId, freshIdentity) {
167
183
  if (existing) {
168
184
  if (canonicalRelayUrl(existing.relayUrl, "Daemon config") !== canonicalRelayUrl(config.relayUrl, "Controller config") ||
169
185
  existing.accountSecret !== config.accountSecret) {
@@ -172,7 +188,7 @@ export async function ensureDaemonPaired(config, machineName, existing, transact
172
188
  return { config: existing, created: false, reconfigured: false };
173
189
  }
174
190
  return {
175
- config: await createDaemonConfig(config, machineName, transaction, deferWrite, signal, expectedAccountId),
191
+ config: await createDaemonConfig(config, machineName, transaction, deferWrite, signal, expectedAccountId, freshIdentity),
176
192
  created: true,
177
193
  reconfigured: false,
178
194
  };
@@ -10,6 +10,7 @@ export declare function ensureDaemonRunning(expectedConfig: DaemonConfig, cwd?:
10
10
  startedPid?: number;
11
11
  startedHandle?: SpawnedDaemonHandle;
12
12
  committedConfigMayBeInUse?: boolean;
13
+ autostartNeedsAttention?: string;
13
14
  }>;
14
15
  export type DaemonReadinessResult = {
15
16
  phase: "relay-authenticated" | "process-exited" | "owner-changed" | "relay-pending";
@@ -3,7 +3,7 @@ import { daemonRuntimeConfigFingerprint } from "../../../../../packages/shared/d
3
3
  import { CliError } from "../../errors.js";
4
4
  import { startDaemonTimeoutMs } from "./paths.js";
5
5
  import { readDaemonConfig } from "./config.js";
6
- import { assertLocalDaemonRuntimeMatchesConfig, assertLocalDaemonVerified, inspectLocalDaemonRuntime, localDaemonStatus, readLocalDaemonPidIdentity, spawnDaemonStartTracked, stopSpawnedDaemon, } from "./local-daemon.js";
6
+ import { assertLocalDaemonRuntimeMatchesConfig, assertLocalDaemonVerified, inspectLocalDaemonRuntime, ensureRunningDaemonAutostart, localDaemonStatus, readLocalDaemonPidIdentity, spawnDaemonStartTracked, stopSpawnedDaemon, } from "./local-daemon.js";
7
7
  const committedConfigMayBeInUseErrors = new WeakSet();
8
8
  export function startFailureMayUseCommittedConfig(error) {
9
9
  return Boolean(error && typeof error === "object" && committedConfigMayBeInUseErrors.has(error));
@@ -34,16 +34,26 @@ export async function ensureDaemonRunning(expectedConfig, cwd = process.cwd()) {
34
34
  const owner = await readLocalDaemonPidIdentity();
35
35
  if (!owner.record)
36
36
  throw new CliError("Verified daemon owner identity is missing.", "DAEMON_PID_INVALID");
37
+ const autostart = await ensureRunningDaemonAutostart();
38
+ const confirmedOwner = await readLocalDaemonPidIdentity();
39
+ if (!confirmedOwner.record ||
40
+ confirmedOwner.record.pid !== owner.record.pid ||
41
+ confirmedOwner.record.instanceNonce !== owner.record.instanceNonce) {
42
+ throw committedConfigInUseError("The verified daemon owner changed while automatic startup was being inspected. No replacement process was started.");
43
+ }
37
44
  return {
38
45
  local: current,
39
46
  instanceGeneration: owner.record.instanceNonce,
40
47
  started: false,
41
48
  committedConfigMayBeInUse: true,
49
+ ...(autostart.state === "needs-attention"
50
+ ? { autostartNeedsAttention: autostart.message }
51
+ : {}),
42
52
  };
43
53
  }
44
- const handle = spawnDaemonStartTracked([], cwd);
45
- const startedPid = handle.pid;
46
- if (!startedPid)
54
+ const handle = await spawnDaemonStartTracked([], cwd, daemonRuntimeConfigFingerprint(expectedConfig));
55
+ let startedPid = handle.pid || undefined;
56
+ if (handle.kind === "child" && !startedPid)
47
57
  throw new CliError("Daemon failed to start.", "DAEMON_START_FAILED");
48
58
  try {
49
59
  const spawnResult = await handle.spawnResult;
@@ -66,17 +76,28 @@ export async function ensureDaemonRunning(expectedConfig, cwd = process.cwd()) {
66
76
  }
67
77
  if (local.running) {
68
78
  const owner = await readLocalDaemonPidIdentity();
69
- const ownOwner = owner.record?.pid === handle.pid &&
70
- owner.record.instanceNonce === handle.instanceNonce;
79
+ const ownOwner = owner.record?.instanceNonce === handle.instanceNonce &&
80
+ (handle.kind === "service" || owner.record.pid === handle.pid);
81
+ if (ownOwner && owner.record && handle.kind === "service") {
82
+ handle.pid = owner.record.pid;
83
+ handle.observedOwner = {
84
+ pid: owner.record.pid,
85
+ instanceNonce: owner.record.instanceNonce,
86
+ startedAt: owner.record.startedAt,
87
+ };
88
+ startedPid = owner.record.pid;
89
+ }
71
90
  const runtime = await inspectLocalDaemonRuntime(expectedConfig);
72
91
  if (!ownOwner) {
73
92
  competingOwnerObserved = true;
74
93
  if (runtime.state === "match") {
75
- try {
76
- await stopSpawnedDaemon(handle);
77
- }
78
- catch (cleanupError) {
79
- throw markCommittedConfigMayBeInUse(new CliError(`A matching daemon became owner, but this start attempt could not settle its own child: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, "DAEMON_START_CLEANUP_FAILED"));
94
+ if (handle.kind === "child") {
95
+ try {
96
+ await stopSpawnedDaemon(handle);
97
+ }
98
+ catch (cleanupError) {
99
+ throw markCommittedConfigMayBeInUse(new CliError(`A matching daemon became owner, but this start attempt could not settle its own child: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, "DAEMON_START_CLEANUP_FAILED"));
100
+ }
80
101
  }
81
102
  const confirmedLocal = await localDaemonStatus();
82
103
  try {
@@ -137,7 +158,7 @@ export async function ensureDaemonRunning(expectedConfig, cwd = process.cwd()) {
137
158
  // A missing or stale marker is a bounded startup window. The daemon
138
159
  // publishes its owner-bound marker atomically immediately after PID v2.
139
160
  }
140
- if (!competingOwnerPending &&
161
+ if (handle.kind === "child" && !competingOwnerPending &&
141
162
  (handle.child.exitCode !== null || handle.child.signalCode !== null)) {
142
163
  throw new CliError("Daemon exited during startup.", "DAEMON_START_FAILED");
143
164
  }
@@ -1,13 +1,33 @@
1
1
  import { ControllerClient } from "../../../../../packages/client/dist/index.js";
2
2
  import { type DeviceRecoveryIntentV1 } from "../../../../../packages/shared/dist/index.js";
3
+ import { stopSpawnedDaemon } from "./local-daemon.js";
3
4
  import { type StartConfigPreflight } from "./bootstrap-config.js";
5
+ import { ensureDaemonRunning } from "./bootstrap-daemon.js";
4
6
  export declare function startDeviceRecoveryBootstrap(params: {
5
7
  flags: Record<string, string | boolean>;
6
8
  preflight: StartConfigPreflight;
7
9
  cwd: string;
8
10
  deviceName: string;
11
+ controllerCredentialKnownInvalid?: boolean;
9
12
  }): Promise<void>;
10
13
  export declare function deviceRecoveryIntentNeedsReplacement(intent: Pick<DeviceRecoveryIntentV1, "status">): boolean;
14
+ export declare function isTerminalRecoveryIntentCreationFailure(error: unknown): boolean;
15
+ export declare function projectRecoveredDaemonReadinessFailure(started: Awaited<ReturnType<typeof ensureDaemonRunning>>, machineName: string, readinessError: unknown, options?: {
16
+ stopStarted?: typeof stopSpawnedDaemon;
17
+ }): Promise<{
18
+ online: false;
19
+ pid?: number;
20
+ started: boolean;
21
+ name: string;
22
+ needsAttention: string;
23
+ }>;
24
+ /**
25
+ * Daemon credential rotation and Device recovery both change Account
26
+ * authority. Call this while holding the Account config transaction so a
27
+ * target Re-enroll cannot invalidate the machine generation needed to settle
28
+ * an exact pending Device recovery.
29
+ */
30
+ export declare function assertNoPendingDeviceRecoveryForDaemonEnrollment(): Promise<void>;
11
31
  /**
12
32
  * A previous recovery may have reached Relay after its CLI process lost the
13
33
  * response. Never discard the fragment secret solely because ordinary start