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

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 (69) hide show
  1. package/apps/cli/dist/commands/account.js +137 -83
  2. package/apps/cli/dist/commands/config.js +5 -1
  3. package/apps/cli/dist/commands/daemon.js +56 -44
  4. package/apps/cli/dist/commands/lib/account-snapshot.d.ts +13 -0
  5. package/apps/cli/dist/commands/lib/account-snapshot.js +337 -0
  6. package/apps/cli/dist/commands/lib/bootstrap-config.d.ts +7 -4
  7. package/apps/cli/dist/commands/lib/bootstrap-config.js +46 -27
  8. package/apps/cli/dist/commands/lib/bootstrap-device-recovery.d.ts +18 -0
  9. package/apps/cli/dist/commands/lib/bootstrap-device-recovery.js +663 -0
  10. package/apps/cli/dist/commands/lib/bootstrap-join.d.ts +5 -0
  11. package/apps/cli/dist/commands/lib/bootstrap-join.js +175 -73
  12. package/apps/cli/dist/commands/lib/bootstrap.js +27 -0
  13. package/apps/cli/dist/commands/lib/config.d.ts +24 -2
  14. package/apps/cli/dist/commands/lib/config.js +297 -19
  15. package/apps/cli/dist/commands/lib/controller-enrollment-intent.d.ts +53 -0
  16. package/apps/cli/dist/commands/lib/controller-enrollment-intent.js +308 -0
  17. package/apps/cli/dist/commands/lib/daemon-enrollment-intent.d.ts +2 -0
  18. package/apps/cli/dist/commands/lib/daemon-enrollment-intent.js +11 -3
  19. package/apps/cli/dist/commands/lib/doctor.js +51 -9
  20. package/apps/cli/dist/commands/lib/enrollment-claim-settlement.d.ts +1 -0
  21. package/apps/cli/dist/commands/lib/enrollment-claim-settlement.js +20 -0
  22. package/apps/cli/dist/commands/lib/index.d.ts +3 -0
  23. package/apps/cli/dist/commands/lib/index.js +3 -0
  24. package/apps/cli/dist/commands/lib/paths.d.ts +2 -0
  25. package/apps/cli/dist/commands/lib/paths.js +2 -0
  26. package/apps/cli/dist/commands/lib/pending-enrollment.d.ts +1 -0
  27. package/apps/cli/dist/commands/lib/pending-enrollment.js +23 -0
  28. package/apps/cli/dist/commands/remote.js +108 -25
  29. package/apps/daemon/dist/account-config-lock.js +2 -1
  30. package/apps/daemon/dist/config.d.ts +5 -0
  31. package/apps/daemon/dist/config.js +143 -9
  32. package/apps/daemon/dist/lifecycle/detached-helper-bundle.mjs +69 -0
  33. package/apps/daemon/dist/lifecycle/operation.js +27 -0
  34. package/apps/daemon/dist/pair.js +2 -1
  35. package/apps/daemon/dist/paths.d.ts +2 -0
  36. package/apps/daemon/dist/paths.js +18 -0
  37. package/apps/daemon/dist/runtime.js +2 -1
  38. package/apps/daemon/dist/start.js +4 -1
  39. package/apps/daemon/package.json +1 -1
  40. package/apps/relay/dist/account-access-revocation.js +2 -0
  41. package/apps/relay/dist/account-http-routes.js +235 -1
  42. package/apps/relay/dist/db.js +39 -0
  43. package/apps/relay/dist/device-recovery-repository.d.ts +37 -0
  44. package/apps/relay/dist/device-recovery-repository.js +78 -0
  45. package/apps/relay/dist/http-routes.js +110 -8
  46. package/apps/relay/dist/http-schemas.d.ts +2 -0
  47. package/apps/relay/dist/http-schemas.js +6 -0
  48. package/apps/relay/dist/projections.d.ts +1 -1
  49. package/apps/relay/dist/projections.js +10 -0
  50. package/apps/relay/dist/retention.js +2 -0
  51. package/build-identity.json +2 -2
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/packages/client/dist/account-management.d.ts +59 -2
  55. package/packages/client/dist/account-management.js +201 -3
  56. package/packages/client/dist/account.d.ts +10 -2
  57. package/packages/client/dist/account.js +45 -5
  58. package/packages/client/dist/client.d.ts +21 -3
  59. package/packages/client/dist/client.js +14 -5
  60. package/packages/client/dist/index.d.ts +5 -5
  61. package/packages/client/dist/index.js +2 -2
  62. package/packages/client/dist/types.d.ts +12 -0
  63. package/packages/runtime-cli/dist/index.d.ts +8 -0
  64. package/packages/runtime-cli/dist/index.js +6 -1
  65. package/packages/runtime-cli/dist/session-store.d.ts +1 -0
  66. package/packages/runtime-cli/dist/session-store.js +1 -1
  67. package/packages/shared/dist/account.d.ts +87 -0
  68. package/packages/shared/dist/account.js +129 -0
  69. package/packages/shared/dist/protocol-types.d.ts +4 -0
@@ -2,12 +2,16 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
- import { CliError, configDir, configPath, generateAccountSecret, ok, parsePairingStartResponse, randomId, readRelayJson, requireRelayUrl, stopLocalDaemon, throwRelayHttpError, withAccountConfigTransaction } from "./lib/index.js";
5
+ import { accountBindingPath, assertNoPendingEnrollmentAuthority, CliError, configDir, configPath, generateAccountSecret, ok, parseControllerConfig, parseDaemonConfig, parseLocalAccountBinding, parsePairingStartResponse, randomId, readLocalAccountBinding, readRelayJson, requireRelayUrl, stopLocalDaemon, throwRelayHttpError, withAccountConfigTransaction, } from "./lib/index.js";
6
+ import { createLocalAccountBindingV1, localAccountBindingMatchesV1, } from "../../../../packages/shared/dist/index.js";
7
+ import { commitAccountSnapshotMutation, } from "./lib/account-snapshot.js";
6
8
  const daemonConfigPath = path.join(configDir, "daemon.json");
7
- const accountConfigFiles = ["config.json", "daemon.json"];
9
+ const accountConfigFiles = ["config.json", "daemon.json", "account.json"];
8
10
  const accountCreateRelayTimeoutMs = 15_000;
9
11
  function accountConfigTarget(file) {
10
- return file === "config.json" ? configPath : daemonConfigPath;
12
+ if (file === "config.json")
13
+ return configPath;
14
+ return file === "daemon.json" ? daemonConfigPath : accountBindingPath;
11
15
  }
12
16
  export async function confirmReset(flags, testIo) {
13
17
  if (flags.json === true) {
@@ -72,8 +76,17 @@ async function copyConfigIfPresent(source, target) {
72
76
  const stat = await fs.lstat(source);
73
77
  if (!stat.isFile() || stat.isSymbolicLink())
74
78
  throw new CliError(`Refusing to copy non-regular config file: ${source}`, "CONFIG_BACKUP_UNSAFE");
75
- await fs.copyFile(source, target);
76
- await fs.chmod(target, 0o600);
79
+ const contents = await fs.readFile(source);
80
+ let handle;
81
+ try {
82
+ handle = await fs.open(target, "wx", 0o600);
83
+ await handle.writeFile(contents);
84
+ await handle.chmod(0o600);
85
+ await handle.sync();
86
+ }
87
+ finally {
88
+ await handle?.close().catch(() => undefined);
89
+ }
77
90
  return true;
78
91
  }
79
92
  catch (error) {
@@ -82,11 +95,41 @@ async function copyConfigIfPresent(source, target) {
82
95
  throw error;
83
96
  }
84
97
  }
98
+ const unsupportedWindowsDirectorySyncCodes = new Set(["EINVAL", "ENOTSUP", "EISDIR", "EPERM"]);
99
+ async function syncDirectory(directory) {
100
+ let handle;
101
+ try {
102
+ handle = await fs.open(directory, "r");
103
+ await handle.sync();
104
+ }
105
+ catch (error) {
106
+ if (process.platform !== "win32"
107
+ || !unsupportedWindowsDirectorySyncCodes.has(error.code ?? ""))
108
+ throw error;
109
+ }
110
+ finally {
111
+ await handle?.close().catch(() => undefined);
112
+ }
113
+ }
114
+ async function writeSyncedBackupManifest(filePath, contents) {
115
+ let handle;
116
+ try {
117
+ handle = await fs.open(filePath, "wx", 0o600);
118
+ await handle.writeFile(contents, "utf8");
119
+ await handle.chmod(0o600);
120
+ await handle.sync();
121
+ }
122
+ finally {
123
+ await handle?.close().catch(() => undefined);
124
+ }
125
+ }
85
126
  async function backupAccountConfig(prefix = "account-reset") {
86
127
  const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
87
128
  const backupsRoot = path.join(configDir, "backups");
129
+ let rootCreated = false;
88
130
  try {
89
131
  await fs.mkdir(backupsRoot, { mode: 0o700 });
132
+ rootCreated = true;
90
133
  }
91
134
  catch (error) {
92
135
  if (error.code !== "EEXIST")
@@ -97,15 +140,24 @@ async function backupAccountConfig(prefix = "account-reset") {
97
140
  throw new CliError("Refusing to write an account backup through an unsafe backups path.", "CONFIG_BACKUP_UNSAFE");
98
141
  }
99
142
  await fs.chmod(backupsRoot, 0o700);
143
+ if (rootCreated)
144
+ await syncDirectory(configDir);
100
145
  const backupPath = path.join(backupsRoot, `${prefix}-${stamp}-${randomId("backup")}`);
101
- await fs.mkdir(backupPath, { mode: 0o700 });
102
- await fs.chmod(backupPath, 0o700);
146
+ const stagingPath = path.join(backupsRoot, `.account-backup-staging-${randomId("backup")}`);
147
+ await fs.mkdir(stagingPath, { mode: 0o700 });
148
+ await fs.chmod(stagingPath, 0o700);
149
+ await syncDirectory(backupsRoot);
103
150
  const files = [];
104
- if (await copyConfigIfPresent(configPath, path.join(backupPath, "config.json")))
151
+ if (await copyConfigIfPresent(configPath, path.join(stagingPath, "config.json")))
105
152
  files.push("config.json");
106
- if (await copyConfigIfPresent(daemonConfigPath, path.join(backupPath, "daemon.json")))
153
+ if (await copyConfigIfPresent(daemonConfigPath, path.join(stagingPath, "daemon.json")))
107
154
  files.push("daemon.json");
108
- await fs.writeFile(path.join(backupPath, "manifest.json"), JSON.stringify({ version: 1, createdAt: new Date().toISOString(), files }, null, 2) + "\n", { mode: 0o600 });
155
+ if (await copyConfigIfPresent(accountBindingPath, path.join(stagingPath, "account.json")))
156
+ files.push("account.json");
157
+ await writeSyncedBackupManifest(path.join(stagingPath, "manifest.json"), JSON.stringify({ version: 1, createdAt: new Date().toISOString(), files }, null, 2) + "\n");
158
+ await syncDirectory(stagingPath);
159
+ await fs.rename(stagingPath, backupPath);
160
+ await syncDirectory(backupsRoot);
109
161
  return { path: backupPath, files };
110
162
  }
111
163
  async function requireDaemonStopped() {
@@ -138,18 +190,28 @@ async function requireRegularBackupFile(filePath) {
138
190
  throw new CliError(`Account backup contains an unsafe ${path.basename(filePath)}.`, "ACCOUNT_BACKUP_INVALID");
139
191
  }
140
192
  }
141
- async function stageAccountRestore(backupPath, files) {
193
+ async function stageAccountRestore(backupPath, requestedFiles) {
142
194
  await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
143
195
  await fs.chmod(configDir, 0o700);
144
196
  const stagingPath = await fs.mkdtemp(path.join(configDir, ".account-restore-"));
145
197
  await fs.chmod(stagingPath, 0o700);
198
+ let files = [...requestedFiles];
199
+ let controller;
200
+ let daemon;
201
+ let binding;
146
202
  try {
147
203
  for (const file of files) {
148
204
  const source = path.join(backupPath, file);
149
205
  await requireRegularBackupFile(source);
150
206
  const contents = await fs.readFile(source, "utf8");
151
207
  try {
152
- JSON.parse(contents);
208
+ const parsed = JSON.parse(contents);
209
+ if (file === "config.json")
210
+ controller = parseControllerConfig(parsed);
211
+ else if (file === "daemon.json")
212
+ daemon = parseDaemonConfig(parsed);
213
+ else if (file === "account.json")
214
+ binding = parseLocalAccountBinding(parsed);
153
215
  }
154
216
  catch {
155
217
  throw new CliError(`Account backup ${file} is not valid JSON.`, "ACCOUNT_BACKUP_INVALID");
@@ -158,6 +220,38 @@ async function stageAccountRestore(backupPath, files) {
158
220
  await fs.writeFile(staged, contents, { mode: 0o600, flag: "wx" });
159
221
  await fs.chmod(staged, 0o600);
160
222
  }
223
+ if (controller && daemon &&
224
+ (controller.relayUrl !== daemon.relayUrl ||
225
+ controller.accountSecret !== daemon.accountSecret)) {
226
+ throw new CliError("Account backup Device and Daemon credentials identify different Accounts.", "ACCOUNT_BACKUP_INVALID");
227
+ }
228
+ if (binding) {
229
+ if (controller && !localAccountBindingMatchesV1(binding, {
230
+ relayUrl: controller.relayUrl,
231
+ accountSecret: controller.accountSecret,
232
+ }) ||
233
+ daemon && !localAccountBindingMatchesV1(binding, {
234
+ relayUrl: daemon.relayUrl,
235
+ accountId: daemon.accountId,
236
+ accountSecret: daemon.accountSecret,
237
+ })) {
238
+ throw new CliError("Account backup binding does not match its Device or Daemon credential.", "ACCOUNT_BACKUP_INVALID");
239
+ }
240
+ }
241
+ else if (controller || daemon) {
242
+ const credential = controller ?? daemon;
243
+ if (!credential)
244
+ throw new CliError("Account backup credential is missing.", "ACCOUNT_BACKUP_INVALID");
245
+ binding = createLocalAccountBindingV1({
246
+ relayUrl: credential.relayUrl,
247
+ accountId: daemon?.accountId ?? null,
248
+ accountSecret: credential.accountSecret,
249
+ });
250
+ const stagedBinding = path.join(stagingPath, "account.json");
251
+ await fs.writeFile(stagedBinding, `${JSON.stringify(binding, null, 2)}\n`, { mode: 0o600, flag: "wx" });
252
+ await fs.chmod(stagedBinding, 0o600);
253
+ files = [...files, "account.json"];
254
+ }
161
255
  return { path: stagingPath, files };
162
256
  }
163
257
  catch (error) {
@@ -184,72 +278,16 @@ async function currentAccountConfigFiles() {
184
278
  }
185
279
  return existing;
186
280
  }
187
- async function applyAccountRestore(staged) {
188
- const existing = await currentAccountConfigFiles();
189
- const rollbackPath = await fs.mkdtemp(path.join(configDir, ".account-restore-rollback-"));
190
- await fs.chmod(rollbackPath, 0o700);
191
- const moved = [];
192
- const installed = [];
193
- let result;
194
- let preserveRollback = false;
195
- try {
196
- for (const file of existing) {
197
- await fs.rename(accountConfigTarget(file), path.join(rollbackPath, file));
198
- moved.push(file);
199
- }
200
- for (const file of staged.files) {
201
- const target = accountConfigTarget(file);
202
- await fs.rename(path.join(staged.path, file), target);
203
- installed.push(file);
204
- await fs.chmod(target, 0o600);
205
- }
206
- result = {
207
- restored: staged.files.map(accountConfigTarget),
208
- removed: existing.filter((file) => !staged.files.includes(file)).map(accountConfigTarget),
209
- };
210
- }
211
- catch (error) {
212
- const rollbackFailures = [];
213
- for (const file of [...installed].reverse()) {
214
- try {
215
- await fs.rm(accountConfigTarget(file), { force: true });
216
- }
217
- catch (rollbackError) {
218
- rollbackFailures.push(`${file} cleanup: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
219
- }
220
- }
221
- for (const file of [...moved].reverse()) {
222
- try {
223
- const target = accountConfigTarget(file);
224
- await fs.rename(path.join(rollbackPath, file), target);
225
- await fs.chmod(target, 0o600);
226
- }
227
- catch (rollbackError) {
228
- rollbackFailures.push(`${file} rollback: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
229
- }
230
- }
231
- const detail = error instanceof Error ? error.message : String(error);
232
- if (rollbackFailures.length > 0) {
233
- preserveRollback = true;
234
- throw new CliError(`Account restore failed and rollback was incomplete: ${detail}. ${rollbackFailures.join("; ")}. Recovery files remain in ${rollbackPath}`, "ACCOUNT_RESTORE_ROLLBACK_FAILED");
235
- }
236
- throw new CliError(`Account restore failed; the previous configuration was restored: ${detail}`, "ACCOUNT_RESTORE_FAILED");
237
- }
238
- finally {
239
- if (!preserveRollback)
240
- await fs.rm(rollbackPath, { recursive: true, force: true });
241
- }
242
- if (!result)
243
- throw new CliError("Account restore did not produce a result", "ACCOUNT_RESTORE_FAILED");
244
- return result;
245
- }
246
281
  export async function handleAccount({ domain, action, flags }) {
247
282
  if (domain === "account" && action === "create") {
248
283
  const created = await withAccountConfigTransaction(async (transaction) => {
284
+ await assertNoPendingEnrollmentAuthority();
249
285
  // This check intentionally precedes every relay request. Holding the
250
286
  // account-wide lease through pairing prevents concurrent creators from
251
287
  // producing an orphan remote account and racing the local credential.
252
- if (await transaction.controllerExists() || await transaction.daemonExists()) {
288
+ if (await transaction.accountExists() ||
289
+ await transaction.controllerExists() ||
290
+ await transaction.daemonExists()) {
253
291
  throw new CliError("Happy Elves account configuration already exists. Use an invite to recover the existing account; account create never replaces credentials.", "CONFIG_EXISTS");
254
292
  }
255
293
  const relayUrl = requireRelayUrl(flags);
@@ -280,7 +318,7 @@ export async function handleAccount({ domain, action, flags }) {
280
318
  controllerToken: paired.controllerToken,
281
319
  accountSecret,
282
320
  };
283
- await transaction.writeController(config, "create");
321
+ await transaction.writeController(config, "create", paired.accountId);
284
322
  return {
285
323
  accountId: paired.accountId,
286
324
  configPath,
@@ -303,15 +341,23 @@ export async function handleAccount({ domain, action, flags }) {
303
341
  }
304
342
  if (domain === "account" && action === "reset") {
305
343
  await confirmReset(flags);
306
- const stop = await requireDaemonStopped();
307
- const backup = await withAccountConfigTransaction(async () => {
344
+ // Reject an unknown or malformed ownership boundary before stopping a
345
+ // healthy Daemon. The locked read below repeats this check at commit.
346
+ await readLocalAccountBinding();
347
+ await assertNoPendingEnrollmentAuthority();
348
+ const { backup, stop } = await withAccountConfigTransaction(async (transaction) => {
349
+ await transaction.readAccountBinding();
350
+ await assertNoPendingEnrollmentAuthority();
351
+ // Lock order is Account config -> verified Daemon lifetime. Local
352
+ // start/restart use the same order, so no process can reload the old
353
+ // credential between this stop and the snapshot commit.
354
+ const stop = await requireDaemonStopped();
308
355
  const result = await backupAccountConfig();
309
- await fs.rm(configPath, { force: true });
310
- await fs.rm(daemonConfigPath, { force: true });
311
- return result;
356
+ await commitAccountSnapshotMutation({}, "reset");
357
+ return { backup: result, stop };
312
358
  });
313
359
  ok("account.reset", {
314
- removed: [configPath, daemonConfigPath],
360
+ removed: [configPath, daemonConfigPath, accountBindingPath],
315
361
  backup,
316
362
  restoreCommand: `happy-elves account restore --from ${JSON.stringify(backup.path)} --yes --json`,
317
363
  stop,
@@ -342,11 +388,19 @@ export async function handleAccount({ domain, action, flags }) {
342
388
  try {
343
389
  const existing = await currentAccountConfigFiles();
344
390
  await confirmRestore(flags, files.length === 0 || existing.length > 0);
345
- const stop = await requireDaemonStopped();
346
- const { preRestoreBackup, restored } = await withAccountConfigTransaction(async () => {
391
+ await readLocalAccountBinding();
392
+ await assertNoPendingEnrollmentAuthority();
393
+ const { preRestoreBackup, restored, stop } = await withAccountConfigTransaction(async (transaction) => {
394
+ await transaction.readAccountBinding();
395
+ await assertNoPendingEnrollmentAuthority();
396
+ const stop = await requireDaemonStopped();
347
397
  const preRestoreBackup = await backupAccountConfig("account-pre-restore");
348
- const restored = await applyAccountRestore({ path: staged.path, files });
349
- return { preRestoreBackup, restored };
398
+ const desired = {};
399
+ for (const file of staged.files) {
400
+ desired[file] = await fs.readFile(path.join(staged.path, file), "utf8");
401
+ }
402
+ const restored = await commitAccountSnapshotMutation(desired, "restore");
403
+ return { preRestoreBackup, restored, stop };
350
404
  });
351
405
  ok("account.restore", {
352
406
  backupPath,
@@ -1,4 +1,4 @@
1
- import { CliError, configPath, ok, readConfig, redactedControllerConfig, redactedRelayUrl, requireRelayUrl, requireString, wantsJson, withAccountConfigTransaction } from "./lib/index.js";
1
+ import { assertNoPendingEnrollmentAuthority, CliError, configPath, ok, readConfig, redactedControllerConfig, redactedRelayUrl, requireRelayUrl, requireString, wantsJson, withAccountConfigTransaction } from "./lib/index.js";
2
2
  export async function handleConfig({ domain, action, flags }) {
3
3
  if (domain === "config" && action === "init") {
4
4
  const config = {
@@ -7,6 +7,10 @@ export async function handleConfig({ domain, action, flags }) {
7
7
  accountSecret: requireString(flags, "secret"),
8
8
  };
9
9
  await withAccountConfigTransaction(async (transaction) => {
10
+ await assertNoPendingEnrollmentAuthority();
11
+ if (await transaction.accountExists()) {
12
+ throw new CliError("Account binding already exists. config init never replaces Account ownership.", "CONFIG_EXISTS");
13
+ }
10
14
  if (await transaction.controllerExists()) {
11
15
  throw new CliError("Controller configuration already exists. config init never replaces credentials.", "CONFIG_EXISTS");
12
16
  }
@@ -2,7 +2,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { daemonLifecycleRequestIdV1Schema, } from "../../../../packages/shared/dist/index.js";
5
- import { assertLocalDaemonRuntimeMatchesConfig, assertLocalDaemonVerified, canonicalRelayUrl, cliErrorFromFetch, CliError, compactText, ControllerClient, configDir, configPath, daemonPidPath, deriveOrchestration, diagnoseInstallation, emitDoctorResult, ensureDaemonRunning, idleSessionStatuses, localDaemonStatus, ok, parsePairingClaimResponse, randomId, readConfig, readDaemonConfig, readDaemonProjectionBaseline, readLocalAuditLog, readOptionalControllerConfig, readRelayJson, redactedDaemonConfig, requirePositional, requireRelayUrl, requireString, showMachine, startDaemonTimeoutMs, stopLocalDaemon, stopSpawnedDaemon, throwRelayHttpError, waitForDaemonReadiness, waitForDaemonSelfReadiness, wantsJson, withAccountConfigTransaction } from "./lib/index.js";
5
+ import { assertLocalDaemonRuntimeMatchesConfig, assertLocalDaemonVerified, assertNoPendingEnrollmentAuthority, canonicalRelayUrl, cliErrorFromFetch, CliError, compactText, ControllerClient, configDir, configPath, daemonPidPath, deriveOrchestration, diagnoseInstallation, emitDoctorResult, ensureDaemonRunning, idleSessionStatuses, localDaemonStatus, ok, parsePairingClaimResponse, randomId, readConfig, readDaemonConfig, readDaemonProjectionBaseline, readLocalAuditLog, readRelayJson, redactedDaemonConfig, requirePositional, requireRelayUrl, requireString, showMachine, startDaemonTimeoutMs, stopLocalDaemon, stopSpawnedDaemon, throwRelayHttpError, waitForDaemonReadiness, waitForDaemonSelfReadiness, wantsJson, withAccountConfigTransaction } from "./lib/index.js";
6
6
  function positiveLimit(flags, fallback) {
7
7
  const value = flags.limit;
8
8
  if (value === undefined)
@@ -208,6 +208,7 @@ export async function handleDaemon({ domain, action, flags }) {
208
208
  const machineId = typeof flags["machine-id"] === "string" ? flags["machine-id"] : randomId("mach");
209
209
  let daemonConfig;
210
210
  await withAccountConfigTransaction(async (transaction) => {
211
+ await assertNoPendingEnrollmentAuthority();
211
212
  if (await transaction.daemonExists()) {
212
213
  throw new CliError(`Daemon configuration already exists at ${transaction.daemonPath}. Pairing never replaces an existing machine identity; use an explicit account recovery or migration flow.`, "CONFIG_EXISTS");
213
214
  }
@@ -309,25 +310,30 @@ export async function handleDaemon({ domain, action, flags }) {
309
310
  return true;
310
311
  }
311
312
  if (domain === "daemon" && action === "start") {
312
- assertLocalDaemonVerified(await localDaemonStatus());
313
- const config = await readDaemonConfig();
314
- assertDaemonRelayOverrideMatches(flags, config);
315
- const controllerConfig = await readOptionalControllerConfig();
316
- if (controllerConfig)
317
- assertControllerCanObserveDaemon(controllerConfig, config);
318
- const client = controllerConfig ? new ControllerClient(controllerConfig) : undefined;
319
- const baseline = client
320
- ? await readDaemonProjectionBaseline(client, config.machineId, { timeoutMs: startDaemonTimeoutMs })
321
- : undefined;
322
- const daemon = await ensureDaemonRunning(config);
323
- const readiness = await waitForLocalDaemonReadiness(client, config, baseline, daemon, daemon.started);
324
- const data = {
325
- ...readiness.local,
326
- started: daemon.started,
327
- machineId: readiness.machine?.id ?? config.machineId,
328
- relayAuthenticated: readiness.relayAuthenticated,
329
- ...(readiness.relayAuthenticated === "verified" ? { online: true } : {}),
330
- };
313
+ const data = await withAccountConfigTransaction(async (transaction) => {
314
+ await assertNoPendingEnrollmentAuthority();
315
+ assertLocalDaemonVerified(await localDaemonStatus());
316
+ const config = await transaction.readDaemon();
317
+ if (!config)
318
+ throw new CliError("Daemon config not found.", "CONFIG_MISSING");
319
+ assertDaemonRelayOverrideMatches(flags, config);
320
+ const controllerConfig = await transaction.readController();
321
+ if (controllerConfig)
322
+ assertControllerCanObserveDaemon(controllerConfig, config);
323
+ const client = controllerConfig ? new ControllerClient(controllerConfig) : undefined;
324
+ const baseline = client
325
+ ? await readDaemonProjectionBaseline(client, config.machineId, { timeoutMs: startDaemonTimeoutMs })
326
+ : undefined;
327
+ const daemon = await ensureDaemonRunning(config);
328
+ const readiness = await waitForLocalDaemonReadiness(client, config, baseline, daemon, daemon.started);
329
+ return {
330
+ ...readiness.local,
331
+ started: daemon.started,
332
+ machineId: readiness.machine?.id ?? config.machineId,
333
+ relayAuthenticated: readiness.relayAuthenticated,
334
+ ...(readiness.relayAuthenticated === "verified" ? { online: true } : {}),
335
+ };
336
+ });
331
337
  if (wantsJson(flags))
332
338
  ok("daemon.start", data);
333
339
  else
@@ -350,31 +356,37 @@ export async function handleDaemon({ domain, action, flags }) {
350
356
  return true;
351
357
  }
352
358
  if (domain === "daemon" && action === "restart") {
353
- assertLocalDaemonVerified(await localDaemonStatus());
354
- const config = await readDaemonConfig();
355
- assertDaemonRelayOverrideMatches(flags, config);
356
- const controllerConfig = await readOptionalControllerConfig();
357
- if (controllerConfig)
358
- assertControllerCanObserveDaemon(controllerConfig, config);
359
- const client = controllerConfig ? new ControllerClient(controllerConfig) : undefined;
360
- const baseline = client
361
- ? await readDaemonProjectionBaseline(client, config.machineId, { timeoutMs: startDaemonTimeoutMs })
362
- : undefined;
363
- const stop = await stopLocalDaemon();
364
- if (stop.running || stop.alive) {
365
- throw new CliError("Daemon did not stop before the restart deadline; no replacement process was started.", "DAEMON_STOP_FAILED");
366
- }
367
- const daemon = await ensureDaemonRunning(config);
368
- const readiness = await waitForLocalDaemonReadiness(client, config, baseline, daemon, true);
369
- ok("daemon.restart", {
370
- stop,
371
- startedPid: daemon.startedPid ?? readiness.local.pid,
372
- pidPath: daemonPidPath,
373
- local: readiness.local,
374
- machineId: readiness.machine?.id ?? config.machineId,
375
- relayAuthenticated: readiness.relayAuthenticated,
376
- ...(readiness.relayAuthenticated === "verified" ? { online: true } : {}),
359
+ const result = await withAccountConfigTransaction(async (transaction) => {
360
+ await assertNoPendingEnrollmentAuthority();
361
+ assertLocalDaemonVerified(await localDaemonStatus());
362
+ const config = await transaction.readDaemon();
363
+ if (!config)
364
+ throw new CliError("Daemon config not found.", "CONFIG_MISSING");
365
+ assertDaemonRelayOverrideMatches(flags, config);
366
+ const controllerConfig = await transaction.readController();
367
+ if (controllerConfig)
368
+ assertControllerCanObserveDaemon(controllerConfig, config);
369
+ const client = controllerConfig ? new ControllerClient(controllerConfig) : undefined;
370
+ const baseline = client
371
+ ? await readDaemonProjectionBaseline(client, config.machineId, { timeoutMs: startDaemonTimeoutMs })
372
+ : undefined;
373
+ const stop = await stopLocalDaemon();
374
+ if (stop.running || stop.alive) {
375
+ throw new CliError("Daemon did not stop before the restart deadline; no replacement process was started.", "DAEMON_STOP_FAILED");
376
+ }
377
+ const daemon = await ensureDaemonRunning(config);
378
+ const readiness = await waitForLocalDaemonReadiness(client, config, baseline, daemon, true);
379
+ return {
380
+ stop,
381
+ startedPid: daemon.startedPid ?? readiness.local.pid,
382
+ pidPath: daemonPidPath,
383
+ local: readiness.local,
384
+ machineId: readiness.machine?.id ?? config.machineId,
385
+ relayAuthenticated: readiness.relayAuthenticated,
386
+ ...(readiness.relayAuthenticated === "verified" ? { online: true } : {}),
387
+ };
377
388
  });
389
+ ok("daemon.restart", result);
378
390
  return true;
379
391
  }
380
392
  if (domain === "daemon" && action === "status" && flags.local === true) {
@@ -0,0 +1,13 @@
1
+ declare const snapshotFiles: readonly ["config.json", "daemon.json", "account.json"];
2
+ export type AccountSnapshotFile = (typeof snapshotFiles)[number];
3
+ export type AccountSnapshotContents = Partial<Record<AccountSnapshotFile, string>>;
4
+ type SnapshotMutationHooks = {
5
+ beforeTarget?(file: AccountSnapshotFile): Promise<void>;
6
+ afterTarget?(file: AccountSnapshotFile): Promise<void>;
7
+ };
8
+ export declare function recoverAccountSnapshotMutation(): Promise<void>;
9
+ export declare function commitAccountSnapshotMutation(desired: AccountSnapshotContents, operation: "reset" | "restore", hooks?: SnapshotMutationHooks): Promise<{
10
+ restored: string[];
11
+ removed: string[];
12
+ }>;
13
+ export {};