@cydm/happy-elves 0.1.0-beta.70 → 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 (191) hide show
  1. package/README.md +1 -1
  2. package/apps/cli/dist/commands/account.js +145 -85
  3. package/apps/cli/dist/commands/config.js +6 -2
  4. package/apps/cli/dist/commands/daemon.js +193 -26
  5. package/apps/cli/dist/commands/gateway.js +230 -7
  6. package/apps/cli/dist/commands/lib/account-snapshot.d.ts +13 -0
  7. package/apps/cli/dist/commands/lib/account-snapshot.js +337 -0
  8. package/apps/cli/dist/commands/lib/args.js +6 -0
  9. package/apps/cli/dist/commands/lib/bootstrap-config.d.ts +7 -4
  10. package/apps/cli/dist/commands/lib/bootstrap-config.js +50 -31
  11. package/apps/cli/dist/commands/lib/bootstrap-daemon.d.ts +13 -2
  12. package/apps/cli/dist/commands/lib/bootstrap-daemon.js +99 -4
  13. package/apps/cli/dist/commands/lib/bootstrap-device-recovery.d.ts +18 -0
  14. package/apps/cli/dist/commands/lib/bootstrap-device-recovery.js +663 -0
  15. package/apps/cli/dist/commands/lib/bootstrap-join.d.ts +6 -0
  16. package/apps/cli/dist/commands/lib/bootstrap-join.js +301 -0
  17. package/apps/cli/dist/commands/lib/bootstrap-output.d.ts +2 -5
  18. package/apps/cli/dist/commands/lib/bootstrap-output.js +6 -9
  19. package/apps/cli/dist/commands/lib/bootstrap.js +47 -15
  20. package/apps/cli/dist/commands/lib/config.d.ts +24 -1
  21. package/apps/cli/dist/commands/lib/config.js +297 -16
  22. package/apps/cli/dist/commands/lib/controller-enrollment-intent.d.ts +53 -0
  23. package/apps/cli/dist/commands/lib/controller-enrollment-intent.js +308 -0
  24. package/apps/cli/dist/commands/lib/daemon-enrollment-intent.d.ts +49 -0
  25. package/apps/cli/dist/commands/lib/daemon-enrollment-intent.js +265 -0
  26. package/apps/cli/dist/commands/lib/doctor.js +59 -17
  27. package/apps/cli/dist/commands/lib/enrollment-claim-settlement.d.ts +1 -0
  28. package/apps/cli/dist/commands/lib/enrollment-claim-settlement.js +20 -0
  29. package/apps/cli/dist/commands/lib/exit.js +8 -8
  30. package/apps/cli/dist/commands/lib/index.d.ts +7 -2
  31. package/apps/cli/dist/commands/lib/index.js +7 -2
  32. package/apps/cli/dist/commands/lib/local-daemon.d.ts +0 -1
  33. package/apps/cli/dist/commands/lib/local-daemon.js +0 -3
  34. package/apps/cli/dist/commands/lib/orchestrator.d.ts +3 -1
  35. package/apps/cli/dist/commands/lib/orchestrator.js +43 -5
  36. package/apps/cli/dist/commands/lib/paths.d.ts +2 -0
  37. package/apps/cli/dist/commands/lib/paths.js +2 -0
  38. package/apps/cli/dist/commands/lib/pending-enrollment.d.ts +1 -0
  39. package/apps/cli/dist/commands/lib/pending-enrollment.js +23 -0
  40. package/apps/cli/dist/commands/lib/scope.d.ts +11 -2
  41. package/apps/cli/dist/commands/lib/scope.js +27 -1
  42. package/apps/cli/dist/commands/lib/status.js +2 -2
  43. package/apps/cli/dist/commands/lib/usage.js +63 -35
  44. package/apps/cli/dist/commands/lib/workspace.js +2 -2
  45. package/apps/cli/dist/commands/loop.js +79 -18
  46. package/apps/cli/dist/commands/machine.js +10 -3
  47. package/apps/cli/dist/commands/memory.js +90 -1
  48. package/apps/cli/dist/commands/orchestrator.d.ts +1 -0
  49. package/apps/cli/dist/commands/orchestrator.js +45 -10
  50. package/apps/cli/dist/commands/remote.js +152 -30
  51. package/apps/cli/dist/commands/session.d.ts +1 -0
  52. package/apps/cli/dist/commands/session.js +60 -22
  53. package/apps/cli/dist/commands/skill.d.ts +8 -0
  54. package/apps/cli/dist/commands/skill.js +54 -9
  55. package/apps/cli/dist/skills/registry.d.ts +1 -1
  56. package/apps/cli/dist/skills/registry.js +12 -1
  57. package/apps/daemon/dist/account-config-lock.js +2 -1
  58. package/apps/daemon/dist/config.d.ts +5 -0
  59. package/apps/daemon/dist/config.js +143 -9
  60. package/apps/daemon/dist/gateway/commands.js +2 -0
  61. package/apps/daemon/dist/gateway/runtime.js +13 -3
  62. package/apps/daemon/dist/lifecycle/artifacts.d.ts +44 -0
  63. package/apps/daemon/dist/lifecycle/artifacts.js +588 -0
  64. package/apps/daemon/dist/lifecycle/capability.d.ts +25 -0
  65. package/apps/daemon/dist/lifecycle/capability.js +290 -0
  66. package/apps/daemon/dist/lifecycle/detached-helper-bundle.mjs +18574 -0
  67. package/apps/daemon/dist/lifecycle/detached-helper-cli.d.ts +1 -0
  68. package/apps/daemon/dist/lifecycle/detached-helper-cli.js +11 -0
  69. package/apps/daemon/dist/lifecycle/errors.d.ts +3 -0
  70. package/apps/daemon/dist/lifecycle/errors.js +10 -0
  71. package/apps/daemon/dist/lifecycle/helper.d.ts +39 -0
  72. package/apps/daemon/dist/lifecycle/helper.js +403 -0
  73. package/apps/daemon/dist/lifecycle/index.d.ts +7 -0
  74. package/apps/daemon/dist/lifecycle/index.js +6 -0
  75. package/apps/daemon/dist/lifecycle/journal.d.ts +21 -0
  76. package/apps/daemon/dist/lifecycle/journal.js +389 -0
  77. package/apps/daemon/dist/lifecycle/npm.d.ts +37 -0
  78. package/apps/daemon/dist/lifecycle/npm.js +164 -0
  79. package/apps/daemon/dist/lifecycle/operation.d.ts +19 -0
  80. package/apps/daemon/dist/lifecycle/operation.js +202 -0
  81. package/apps/daemon/dist/lifecycle/relay.d.ts +5 -0
  82. package/apps/daemon/dist/lifecycle/relay.js +72 -0
  83. package/apps/daemon/dist/lifecycle/reporter.d.ts +29 -0
  84. package/apps/daemon/dist/lifecycle/reporter.js +402 -0
  85. package/apps/daemon/dist/lifecycle/restart.d.ts +127 -0
  86. package/apps/daemon/dist/lifecycle/restart.js +650 -0
  87. package/apps/daemon/dist/lifecycle/runtime.d.ts +36 -0
  88. package/apps/daemon/dist/lifecycle/runtime.js +210 -0
  89. package/apps/daemon/dist/lifecycle/types.d.ts +110 -0
  90. package/apps/daemon/dist/lifecycle/types.js +3 -0
  91. package/apps/daemon/dist/pair.js +2 -1
  92. package/apps/daemon/dist/paths.d.ts +6 -0
  93. package/apps/daemon/dist/paths.js +22 -0
  94. package/apps/daemon/dist/relay/connection.js +37 -0
  95. package/apps/daemon/dist/relay/register.d.ts +3 -0
  96. package/apps/daemon/dist/relay/register.js +49 -1
  97. package/apps/daemon/dist/relay/send.d.ts +3 -1
  98. package/apps/daemon/dist/relay/send.js +5 -2
  99. package/apps/daemon/dist/runtime.js +7 -3
  100. package/apps/daemon/dist/session/lifecycle.d.ts +1 -1
  101. package/apps/daemon/dist/session/lifecycle.js +107 -46
  102. package/apps/daemon/dist/session/primitives.js +37 -3
  103. package/apps/daemon/dist/session/prompt.js +39 -3
  104. package/apps/daemon/dist/start.js +14 -1
  105. package/apps/daemon/dist/turn-coordinator.d.ts +8 -0
  106. package/apps/daemon/dist/turn-coordinator.js +28 -1
  107. package/apps/daemon/package.json +1 -1
  108. package/apps/relay/dist/account-access-revocation.d.ts +39 -0
  109. package/apps/relay/dist/account-access-revocation.js +69 -0
  110. package/apps/relay/dist/account-http-routes.d.ts +3 -0
  111. package/apps/relay/dist/account-http-routes.js +884 -0
  112. package/apps/relay/dist/account-projections.d.ts +40 -0
  113. package/apps/relay/dist/account-projections.js +96 -0
  114. package/apps/relay/dist/controller-handlers.d.ts +3 -1
  115. package/apps/relay/dist/controller-handlers.js +33 -6
  116. package/apps/relay/dist/db.d.ts +6 -0
  117. package/apps/relay/dist/db.js +176 -25
  118. package/apps/relay/dist/device-recovery-repository.d.ts +37 -0
  119. package/apps/relay/dist/device-recovery-repository.js +78 -0
  120. package/apps/relay/dist/enrollment-repository.d.ts +61 -0
  121. package/apps/relay/dist/enrollment-repository.js +113 -0
  122. package/apps/relay/dist/http-routes.d.ts +1 -1
  123. package/apps/relay/dist/http-routes.js +388 -78
  124. package/apps/relay/dist/http-schemas.d.ts +35 -10
  125. package/apps/relay/dist/http-schemas.js +15 -10
  126. package/apps/relay/dist/index.js +28 -8
  127. package/apps/relay/dist/machine-command-result-handlers.d.ts +21 -1
  128. package/apps/relay/dist/machine-command-result-handlers.js +363 -17
  129. package/apps/relay/dist/projections.d.ts +1 -1
  130. package/apps/relay/dist/projections.js +10 -0
  131. package/apps/relay/dist/relay-context.d.ts +5 -3
  132. package/apps/relay/dist/relay-context.js +56 -36
  133. package/apps/relay/dist/retention.d.ts +6 -0
  134. package/apps/relay/dist/retention.js +28 -2
  135. package/apps/relay/dist/scope.js +7 -0
  136. package/apps/relay/dist/session-projection-reducer.js +19 -15
  137. package/apps/relay/dist/types.d.ts +20 -12
  138. package/apps/relay/dist/websocket.js +9 -0
  139. package/build-identity.json +3 -3
  140. package/npm-shrinkwrap.json +2 -2
  141. package/package.json +1 -1
  142. package/packages/client/dist/account-management.d.ts +121 -0
  143. package/packages/client/dist/account-management.js +684 -0
  144. package/packages/client/dist/account.d.ts +16 -3
  145. package/packages/client/dist/account.js +97 -32
  146. package/packages/client/dist/client.d.ts +55 -4
  147. package/packages/client/dist/client.js +390 -13
  148. package/packages/client/dist/http.d.ts +1 -1
  149. package/packages/client/dist/index.d.ts +5 -2
  150. package/packages/client/dist/index.js +2 -0
  151. package/packages/client/dist/live-types.d.ts +16 -0
  152. package/packages/client/dist/live.d.ts +5 -0
  153. package/packages/client/dist/live.js +110 -8
  154. package/packages/client/dist/parsers.d.ts +1 -3
  155. package/packages/client/dist/parsers.js +1 -36
  156. package/packages/client/dist/transport.js +16 -0
  157. package/packages/client/dist/types.d.ts +78 -2
  158. package/packages/runtime-cli/dist/backend-session-lease.d.ts +40 -0
  159. package/packages/runtime-cli/dist/backend-session-lease.js +561 -0
  160. package/packages/runtime-cli/dist/claude-history.d.ts +1 -1
  161. package/packages/runtime-cli/dist/claude-history.js +23 -22
  162. package/packages/runtime-cli/dist/claude-session-store.d.ts +0 -1
  163. package/packages/runtime-cli/dist/claude-session-store.js +1 -7
  164. package/packages/runtime-cli/dist/claude-turn.d.ts +4 -0
  165. package/packages/runtime-cli/dist/claude-turn.js +98 -6
  166. package/packages/runtime-cli/dist/codex-app-server.d.ts +8 -1
  167. package/packages/runtime-cli/dist/codex-app-server.js +10 -1
  168. package/packages/runtime-cli/dist/codex-json-rpc.d.ts +5 -0
  169. package/packages/runtime-cli/dist/codex-json-rpc.js +84 -5
  170. package/packages/runtime-cli/dist/codex-lifecycle.js +16 -3
  171. package/packages/runtime-cli/dist/index.d.ts +8 -0
  172. package/packages/runtime-cli/dist/index.js +103 -51
  173. package/packages/runtime-cli/dist/process-identity.d.ts +15 -0
  174. package/packages/runtime-cli/dist/process-identity.js +102 -0
  175. package/packages/runtime-cli/dist/session-store.d.ts +28 -0
  176. package/packages/runtime-cli/dist/session-store.js +213 -17
  177. package/packages/shared/dist/account.d.ts +271 -0
  178. package/packages/shared/dist/account.js +334 -0
  179. package/packages/shared/dist/crypto.d.ts +27 -1
  180. package/packages/shared/dist/crypto.js +112 -4
  181. package/packages/shared/dist/daemon-lifecycle.d.ts +85 -0
  182. package/packages/shared/dist/daemon-lifecycle.js +66 -0
  183. package/packages/shared/dist/index.d.ts +2 -0
  184. package/packages/shared/dist/index.js +2 -0
  185. package/packages/shared/dist/node/gateway-store.d.ts +3 -1
  186. package/packages/shared/dist/node/gateway-store.js +7 -0
  187. package/packages/shared/dist/protocol-schemas.d.ts +80 -26
  188. package/packages/shared/dist/protocol-schemas.js +4 -0
  189. package/packages/shared/dist/protocol-types.d.ts +39 -0
  190. package/packages/shared/dist/protocol.d.ts +33 -2
  191. package/packages/shared/dist/protocol.js +36 -0
package/README.md CHANGED
@@ -8,7 +8,7 @@ Public beta CLI for Happy Elves.
8
8
  happy-elves start
9
9
  ```
10
10
 
11
- Existing workspace recovery uses a one-time invite from Settings → Browsers → Add:
11
+ Reconnect this Device to an existing Account with a one-time invite from Settings → Devices → Add Device (the CLI command name is retained for compatibility):
12
12
 
13
13
  ```bash
14
14
  happy-elves remote join-controller --url "<invite-url>" --recover-controller --json
@@ -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);
@@ -261,7 +299,7 @@ export async function handleAccount({ domain, action, flags }) {
261
299
  response = await fetch(`${relayUrl}/api/pairing/start`, {
262
300
  method: "POST",
263
301
  headers: { "content-type": "application/json" },
264
- body: JSON.stringify({ deviceId, deviceName }),
302
+ body: JSON.stringify({ deviceId, deviceName, surface: "cli" }),
265
303
  signal: AbortSignal.timeout(accountCreateRelayTimeoutMs),
266
304
  });
267
305
  }
@@ -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,
@@ -292,20 +330,34 @@ export async function handleAccount({ domain, action, flags }) {
292
330
  expiresAt: paired.expiresAt,
293
331
  };
294
332
  });
295
- ok("account.create", created);
333
+ if (flags["reveal-secrets"] === true) {
334
+ ok("account.create", created);
335
+ }
336
+ else {
337
+ const { controllerToken: _controllerToken, accountSecret: _accountSecret, ...safeCreated } = created;
338
+ ok("account.create", safeCreated);
339
+ }
296
340
  return true;
297
341
  }
298
342
  if (domain === "account" && action === "reset") {
299
343
  await confirmReset(flags);
300
- const stop = await requireDaemonStopped();
301
- 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();
302
355
  const result = await backupAccountConfig();
303
- await fs.rm(configPath, { force: true });
304
- await fs.rm(daemonConfigPath, { force: true });
305
- return result;
356
+ await commitAccountSnapshotMutation({}, "reset");
357
+ return { backup: result, stop };
306
358
  });
307
359
  ok("account.reset", {
308
- removed: [configPath, daemonConfigPath],
360
+ removed: [configPath, daemonConfigPath, accountBindingPath],
309
361
  backup,
310
362
  restoreCommand: `happy-elves account restore --from ${JSON.stringify(backup.path)} --yes --json`,
311
363
  stop,
@@ -336,11 +388,19 @@ export async function handleAccount({ domain, action, flags }) {
336
388
  try {
337
389
  const existing = await currentAccountConfigFiles();
338
390
  await confirmRestore(flags, files.length === 0 || existing.length > 0);
339
- const stop = await requireDaemonStopped();
340
- 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();
341
397
  const preRestoreBackup = await backupAccountConfig("account-pre-restore");
342
- const restored = await applyAccountRestore({ path: staged.path, files });
343
- 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 };
344
404
  });
345
405
  ok("account.restore", {
346
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
  }
@@ -24,7 +28,7 @@ export async function handleConfig({ domain, action, flags }) {
24
28
  const redacted = redactedControllerConfig(config);
25
29
  console.log(`Config: ${configPath}`);
26
30
  console.log(`Relay: ${redacted.relayUrl}`);
27
- console.log(`Controller token: ${redacted.hasControllerToken ? "configured" : "missing"}`);
31
+ console.log(`Device credential: ${redacted.hasControllerToken ? "configured" : "missing"}`);
28
32
  console.log(`Account secret: ${redacted.hasAccountSecret ? "configured" : "missing"}`);
29
33
  return true;
30
34
  }