@cydm/happy-elves 0.1.0-beta.71 → 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/account.js +137 -83
- package/apps/cli/dist/commands/config.js +5 -1
- package/apps/cli/dist/commands/daemon.js +59 -44
- package/apps/cli/dist/commands/lib/account-snapshot.d.ts +13 -0
- package/apps/cli/dist/commands/lib/account-snapshot.js +368 -0
- package/apps/cli/dist/commands/lib/bootstrap-config.d.ts +10 -5
- package/apps/cli/dist/commands/lib/bootstrap-config.js +90 -55
- 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 +38 -0
- package/apps/cli/dist/commands/lib/bootstrap-device-recovery.js +790 -0
- package/apps/cli/dist/commands/lib/bootstrap-join.d.ts +5 -0
- package/apps/cli/dist/commands/lib/bootstrap-join.js +178 -73
- 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 +223 -30
- package/apps/cli/dist/commands/lib/config.d.ts +32 -2
- package/apps/cli/dist/commands/lib/config.js +401 -20
- package/apps/cli/dist/commands/lib/controller-enrollment-intent.d.ts +53 -0
- package/apps/cli/dist/commands/lib/controller-enrollment-intent.js +308 -0
- package/apps/cli/dist/commands/lib/daemon-enrollment-intent.d.ts +8 -0
- package/apps/cli/dist/commands/lib/daemon-enrollment-intent.js +21 -3
- package/apps/cli/dist/commands/lib/doctor.js +93 -12
- package/apps/cli/dist/commands/lib/enrollment-claim-settlement.d.ts +1 -0
- package/apps/cli/dist/commands/lib/enrollment-claim-settlement.js +20 -0
- 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/index.d.ts +3 -0
- package/apps/cli/dist/commands/lib/index.js +3 -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 +3 -0
- package/apps/cli/dist/commands/lib/paths.js +3 -0
- package/apps/cli/dist/commands/lib/pending-enrollment.d.ts +1 -0
- package/apps/cli/dist/commands/lib/pending-enrollment.js +23 -0
- package/apps/cli/dist/commands/remote.js +108 -25
- package/apps/daemon/dist/account-config-lock.d.ts +10 -0
- package/apps/daemon/dist/account-config-lock.js +37 -1
- package/apps/daemon/dist/args.js +11 -1
- package/apps/daemon/dist/cli.js +50 -2
- package/apps/daemon/dist/config.d.ts +5 -0
- package/apps/daemon/dist/config.js +143 -9
- package/apps/daemon/dist/lifecycle/detached-helper-bundle.mjs +4833 -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 +73 -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/pair.js +2 -1
- package/apps/daemon/dist/paths.d.ts +2 -0
- package/apps/daemon/dist/paths.js +18 -0
- 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/runtime.js +2 -1
- 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 +91 -7
- 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 +20 -2
- package/apps/relay/dist/account-http-routes.js +517 -13
- 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 +87 -4
- package/apps/relay/dist/device-recovery-repository.d.ts +38 -0
- package/apps/relay/dist/device-recovery-repository.js +85 -0
- 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 +295 -59
- package/apps/relay/dist/http-schemas.d.ts +8 -0
- package/apps/relay/dist/http-schemas.js +20 -0
- package/apps/relay/dist/machine-command-result-handlers.js +12 -7
- package/apps/relay/dist/projections.d.ts +1 -1
- package/apps/relay/dist/projections.js +13 -0
- package/apps/relay/dist/relay-context.d.ts +5 -1
- package/apps/relay/dist/relay-context.js +10 -3
- package/apps/relay/dist/retention.js +2 -0
- 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 +97 -3
- package/packages/client/dist/account-management.js +414 -8
- package/packages/client/dist/account.d.ts +31 -3
- package/packages/client/dist/account.js +82 -7
- package/packages/client/dist/client.d.ts +26 -4
- package/packages/client/dist/client.js +16 -7
- package/packages/client/dist/index.d.ts +6 -5
- 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/client/dist/types.d.ts +12 -0
- package/packages/daemon-service/dist/index.d.ts +172 -0
- package/packages/daemon-service/dist/index.js +2329 -0
- package/packages/runtime-cli/dist/index.d.ts +8 -0
- package/packages/runtime-cli/dist/index.js +6 -1
- package/packages/runtime-cli/dist/session-store.d.ts +1 -0
- package/packages/runtime-cli/dist/session-store.js +1 -1
- package/packages/shared/dist/account.d.ts +138 -2
- package/packages/shared/dist/account.js +145 -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 +12 -1
- package/packages/shared/dist/protocol.d.ts +6 -1
- package/packages/shared/dist/protocol.js +5 -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
|
-
|
|
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.
|
|
76
|
-
|
|
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
|
-
|
|
102
|
-
await fs.
|
|
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(
|
|
151
|
+
if (await copyConfigIfPresent(configPath, path.join(stagingPath, "config.json")))
|
|
105
152
|
files.push("config.json");
|
|
106
|
-
if (await copyConfigIfPresent(daemonConfigPath, path.join(
|
|
153
|
+
if (await copyConfigIfPresent(daemonConfigPath, path.join(stagingPath, "daemon.json")))
|
|
107
154
|
files.push("daemon.json");
|
|
108
|
-
await
|
|
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,
|
|
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.
|
|
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
|
-
|
|
307
|
-
|
|
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
|
|
310
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
|
349
|
-
|
|
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,
|
|
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,31 @@ export async function handleDaemon({ domain, action, flags }) {
|
|
|
309
310
|
return true;
|
|
310
311
|
}
|
|
311
312
|
if (domain === "daemon" && action === "start") {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
...(daemon.autostartNeedsAttention ? { needsAttention: daemon.autostartNeedsAttention } : {}),
|
|
333
|
+
machineId: readiness.machine?.id ?? config.machineId,
|
|
334
|
+
relayAuthenticated: readiness.relayAuthenticated,
|
|
335
|
+
...(readiness.relayAuthenticated === "verified" ? { online: true } : {}),
|
|
336
|
+
};
|
|
337
|
+
});
|
|
331
338
|
if (wantsJson(flags))
|
|
332
339
|
ok("daemon.start", data);
|
|
333
340
|
else
|
|
@@ -350,31 +357,37 @@ export async function handleDaemon({ domain, action, flags }) {
|
|
|
350
357
|
return true;
|
|
351
358
|
}
|
|
352
359
|
if (domain === "daemon" && action === "restart") {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
360
|
+
const result = await withAccountConfigTransaction(async (transaction) => {
|
|
361
|
+
await assertNoPendingEnrollmentAuthority();
|
|
362
|
+
assertLocalDaemonVerified(await localDaemonStatus());
|
|
363
|
+
const config = await transaction.readDaemon();
|
|
364
|
+
if (!config)
|
|
365
|
+
throw new CliError("Daemon config not found.", "CONFIG_MISSING");
|
|
366
|
+
assertDaemonRelayOverrideMatches(flags, config);
|
|
367
|
+
const controllerConfig = await transaction.readController();
|
|
368
|
+
if (controllerConfig)
|
|
369
|
+
assertControllerCanObserveDaemon(controllerConfig, config);
|
|
370
|
+
const client = controllerConfig ? new ControllerClient(controllerConfig) : undefined;
|
|
371
|
+
const baseline = client
|
|
372
|
+
? await readDaemonProjectionBaseline(client, config.machineId, { timeoutMs: startDaemonTimeoutMs })
|
|
373
|
+
: undefined;
|
|
374
|
+
const stop = await stopLocalDaemon();
|
|
375
|
+
if (stop.running || stop.alive) {
|
|
376
|
+
throw new CliError("Daemon did not stop before the restart deadline; no replacement process was started.", "DAEMON_STOP_FAILED");
|
|
377
|
+
}
|
|
378
|
+
const daemon = await ensureDaemonRunning(config);
|
|
379
|
+
const readiness = await waitForLocalDaemonReadiness(client, config, baseline, daemon, true);
|
|
380
|
+
return {
|
|
381
|
+
stop,
|
|
382
|
+
startedPid: daemon.startedPid ?? readiness.local.pid,
|
|
383
|
+
pidPath: daemonPidPath,
|
|
384
|
+
local: readiness.local,
|
|
385
|
+
machineId: readiness.machine?.id ?? config.machineId,
|
|
386
|
+
relayAuthenticated: readiness.relayAuthenticated,
|
|
387
|
+
...(readiness.relayAuthenticated === "verified" ? { online: true } : {}),
|
|
388
|
+
};
|
|
377
389
|
});
|
|
390
|
+
ok("daemon.restart", result);
|
|
378
391
|
return true;
|
|
379
392
|
}
|
|
380
393
|
if (domain === "daemon" && action === "status" && flags.local === true) {
|
|
@@ -506,6 +519,8 @@ function writeHumanDaemonStartOutput(data) {
|
|
|
506
519
|
if (data.relayAuthenticated === "unverified") {
|
|
507
520
|
console.log("Local daemon identity is ready; relay authentication was not verified because this host has no matching Device credential.");
|
|
508
521
|
}
|
|
522
|
+
if (data.needsAttention)
|
|
523
|
+
console.log(`Automatic startup needs attention: ${data.needsAttention}`);
|
|
509
524
|
console.log("This command manages only the paired background daemon; it does not open a browser page.");
|
|
510
525
|
console.log("For setup or browser onboarding, run: happy-elves start");
|
|
511
526
|
}
|
|
@@ -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" | "adopt-device", hooks?: SnapshotMutationHooks): Promise<{
|
|
10
|
+
restored: string[];
|
|
11
|
+
removed: string[];
|
|
12
|
+
}>;
|
|
13
|
+
export {};
|