@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.
- package/apps/cli/dist/commands/daemon.js +3 -0
- package/apps/cli/dist/commands/lib/account-snapshot.d.ts +1 -1
- package/apps/cli/dist/commands/lib/account-snapshot.js +36 -5
- package/apps/cli/dist/commands/lib/bootstrap-config.d.ts +4 -2
- package/apps/cli/dist/commands/lib/bootstrap-config.js +51 -35
- package/apps/cli/dist/commands/lib/bootstrap-daemon.d.ts +1 -0
- package/apps/cli/dist/commands/lib/bootstrap-daemon.js +33 -12
- package/apps/cli/dist/commands/lib/bootstrap-device-recovery.d.ts +20 -0
- package/apps/cli/dist/commands/lib/bootstrap-device-recovery.js +261 -134
- package/apps/cli/dist/commands/lib/bootstrap-join.js +3 -0
- package/apps/cli/dist/commands/lib/bootstrap-output.d.ts +11 -1
- package/apps/cli/dist/commands/lib/bootstrap-output.js +31 -16
- package/apps/cli/dist/commands/lib/bootstrap.d.ts +2 -0
- package/apps/cli/dist/commands/lib/bootstrap.js +200 -34
- package/apps/cli/dist/commands/lib/config.d.ts +8 -0
- package/apps/cli/dist/commands/lib/config.js +105 -2
- package/apps/cli/dist/commands/lib/daemon-enrollment-intent.d.ts +6 -0
- package/apps/cli/dist/commands/lib/daemon-enrollment-intent.js +10 -0
- package/apps/cli/dist/commands/lib/doctor.js +53 -14
- package/apps/cli/dist/commands/lib/exit.js +2 -5
- package/apps/cli/dist/commands/lib/fresh-start-intent.d.ts +26 -0
- package/apps/cli/dist/commands/lib/fresh-start-intent.js +169 -0
- package/apps/cli/dist/commands/lib/local-daemon.d.ts +50 -4
- package/apps/cli/dist/commands/lib/local-daemon.js +201 -5
- package/apps/cli/dist/commands/lib/paths.d.ts +1 -0
- package/apps/cli/dist/commands/lib/paths.js +1 -0
- package/apps/daemon/dist/account-config-lock.d.ts +10 -0
- package/apps/daemon/dist/account-config-lock.js +35 -0
- package/apps/daemon/dist/args.js +11 -1
- package/apps/daemon/dist/cli.js +50 -2
- package/apps/daemon/dist/lifecycle/detached-helper-bundle.mjs +4764 -1043
- package/apps/daemon/dist/lifecycle/helper.d.ts +1 -0
- package/apps/daemon/dist/lifecycle/helper.js +39 -5
- package/apps/daemon/dist/lifecycle/operation.d.ts +2 -0
- package/apps/daemon/dist/lifecycle/operation.js +46 -1
- package/apps/daemon/dist/lifecycle/restart.d.ts +1 -0
- package/apps/daemon/dist/lifecycle/restart.js +7 -3
- package/apps/daemon/dist/lifecycle/runtime.js +19 -0
- package/apps/daemon/dist/lifecycle/service.d.ts +30 -0
- package/apps/daemon/dist/lifecycle/service.js +86 -0
- package/apps/daemon/dist/lifecycle/types.d.ts +1 -1
- package/apps/daemon/dist/relay/connection.js +1 -1
- package/apps/daemon/dist/relay/register.d.ts +4 -2
- package/apps/daemon/dist/relay/register.js +24 -2
- package/apps/daemon/dist/runtime/external-provider.js +21 -4
- package/apps/daemon/dist/session/primitives.js +7 -1
- package/apps/daemon/dist/session/prompt.js +1 -0
- package/apps/daemon/dist/start.d.ts +19 -0
- package/apps/daemon/dist/start.js +87 -6
- package/apps/daemon/package.json +1 -1
- package/apps/relay/dist/account-access-revocation.d.ts +1 -0
- package/apps/relay/dist/account-access-revocation.js +18 -2
- package/apps/relay/dist/account-http-routes.js +324 -54
- package/apps/relay/dist/account-projections.d.ts +2 -0
- package/apps/relay/dist/account-projections.js +6 -1
- package/apps/relay/dist/db.js +48 -4
- package/apps/relay/dist/device-recovery-repository.d.ts +1 -0
- package/apps/relay/dist/device-recovery-repository.js +11 -4
- package/apps/relay/dist/enrollment-repository.d.ts +11 -0
- package/apps/relay/dist/enrollment-repository.js +18 -0
- package/apps/relay/dist/http-routes.js +185 -51
- package/apps/relay/dist/http-schemas.d.ts +6 -0
- package/apps/relay/dist/http-schemas.js +14 -0
- package/apps/relay/dist/machine-command-result-handlers.js +12 -7
- package/apps/relay/dist/projections.js +4 -1
- package/apps/relay/dist/relay-context.d.ts +5 -1
- package/apps/relay/dist/relay-context.js +10 -3
- package/apps/relay/dist/types.d.ts +4 -0
- package/apps/relay/dist/websocket.js +3 -0
- package/build-identity.json +2 -2
- package/npm-shrinkwrap.json +5 -5
- package/package.json +2 -1
- package/packages/client/dist/account-management.d.ts +39 -2
- package/packages/client/dist/account-management.js +214 -6
- package/packages/client/dist/account.d.ts +21 -1
- package/packages/client/dist/account.js +38 -3
- package/packages/client/dist/client.d.ts +5 -1
- package/packages/client/dist/client.js +2 -2
- package/packages/client/dist/index.d.ts +5 -4
- package/packages/client/dist/index.js +2 -2
- package/packages/client/dist/parsers.d.ts +2 -1
- package/packages/client/dist/parsers.js +8 -0
- package/packages/client/dist/transport.js +2 -0
- package/packages/daemon-service/dist/index.d.ts +172 -0
- package/packages/daemon-service/dist/index.js +2329 -0
- package/packages/shared/dist/account.d.ts +51 -2
- package/packages/shared/dist/account.js +16 -1
- package/packages/shared/dist/daemon-lifecycle.d.ts +35 -1
- package/packages/shared/dist/daemon-lifecycle.js +20 -2
- package/packages/shared/dist/protocol-schemas.d.ts +41 -3
- package/packages/shared/dist/protocol-schemas.js +2 -1
- package/packages/shared/dist/protocol-types.d.ts +8 -1
- package/packages/shared/dist/protocol.d.ts +6 -1
- 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
|
-
|
|
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"
|
|
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:
|
|
335
|
-
removed:
|
|
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,
|
|
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
|
|
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:
|
|
66
|
-
|
|
67
|
-
|
|
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({
|
|
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({
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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?.
|
|
70
|
-
owner.record.
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|