@vellumai/cli 0.8.4 → 0.8.6

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 (43) hide show
  1. package/AGENTS.md +17 -1
  2. package/knip.json +2 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/api-key-check.test.ts +78 -0
  5. package/src/__tests__/backup.test.ts +38 -0
  6. package/src/__tests__/recover.test.ts +307 -0
  7. package/src/__tests__/retire.test.ts +241 -0
  8. package/src/__tests__/wake.test.ts +215 -0
  9. package/src/commands/backup.ts +2 -0
  10. package/src/commands/client.ts +62 -32
  11. package/src/commands/flags.ts +197 -0
  12. package/src/commands/gateway/token.ts +73 -0
  13. package/src/commands/gateway.ts +29 -0
  14. package/src/commands/logs.ts +6 -18
  15. package/src/commands/ps.ts +41 -41
  16. package/src/commands/recover.ts +47 -9
  17. package/src/commands/restore.ts +8 -1
  18. package/src/commands/retire.ts +145 -55
  19. package/src/commands/roadmap.ts +449 -0
  20. package/src/commands/rollback.ts +2 -14
  21. package/src/commands/ssh.ts +5 -24
  22. package/src/commands/teleport.ts +34 -26
  23. package/src/commands/upgrade.ts +8 -16
  24. package/src/commands/wake.ts +68 -45
  25. package/src/index.ts +9 -0
  26. package/src/lib/__tests__/port-allocator.test.ts +117 -0
  27. package/src/lib/__tests__/step-runner.test.ts +133 -0
  28. package/src/lib/api-key-check.ts +40 -0
  29. package/src/lib/assistant-config.ts +13 -0
  30. package/src/lib/config-utils.ts +24 -3
  31. package/src/lib/docker.ts +72 -8
  32. package/src/lib/hatch-local.ts +15 -2
  33. package/src/lib/http-client.ts +1 -3
  34. package/src/lib/local.ts +173 -292
  35. package/src/lib/orphan-detection.ts +9 -5
  36. package/src/lib/pgrep.ts +5 -1
  37. package/src/lib/platform-client.ts +97 -49
  38. package/src/lib/port-allocator.ts +93 -0
  39. package/src/lib/process.ts +109 -39
  40. package/src/lib/statefulset.ts +0 -10
  41. package/src/lib/step-runner.ts +102 -9
  42. package/src/lib/sync-cloud-assistants.ts +17 -0
  43. package/src/shared/provider-env-vars.ts +1 -0
@@ -0,0 +1,197 @@
1
+ import { AssistantClient } from "../lib/assistant-client.js";
2
+
3
+ type FeatureFlagEntry = {
4
+ key: string;
5
+ label: string;
6
+ enabled: boolean;
7
+ defaultEnabled: boolean;
8
+ description: string;
9
+ };
10
+
11
+ type FlagsResponse = {
12
+ flags: FeatureFlagEntry[];
13
+ };
14
+
15
+ function pad(s: string, w: number): string {
16
+ return s + " ".repeat(Math.max(0, w - s.length));
17
+ }
18
+
19
+ function printFlagTable(flags: FeatureFlagEntry[]): void {
20
+ const headers = { key: "KEY", enabled: "ENABLED", default: "DEFAULT", label: "LABEL" };
21
+
22
+ const rows = flags
23
+ .slice()
24
+ .sort((a, b) => a.key.localeCompare(b.key))
25
+ .map((f) => ({
26
+ key: f.enabled !== f.defaultEnabled ? `* ${f.key}` : ` ${f.key}`,
27
+ enabled: String(f.enabled),
28
+ default: String(f.defaultEnabled),
29
+ label: f.label,
30
+ }));
31
+
32
+ const all = [headers, ...rows];
33
+ const colWidths = {
34
+ key: Math.max(...all.map((r) => r.key.length)),
35
+ enabled: Math.max(...all.map((r) => r.enabled.length)),
36
+ default: Math.max(...all.map((r) => r.default.length)),
37
+ label: Math.max(...all.map((r) => r.label.length)),
38
+ };
39
+
40
+ const formatRow = (r: typeof headers) =>
41
+ `${pad(r.key, colWidths.key)} ${pad(r.enabled, colWidths.enabled)} ${pad(r.default, colWidths.default)} ${r.label}`;
42
+
43
+ console.log(formatRow(headers));
44
+ console.log(
45
+ `${"-".repeat(colWidths.key)} ${"-".repeat(colWidths.enabled)} ${"-".repeat(colWidths.default)} ${"-".repeat(colWidths.label)}`,
46
+ );
47
+ for (const row of rows) {
48
+ console.log(formatRow(row));
49
+ }
50
+ console.log("");
51
+ console.log("* = overridden (differs from default)");
52
+ }
53
+
54
+ function printHelp(): void {
55
+ console.log("Usage: vellum flags [subcommand] [options]");
56
+ console.log("");
57
+ console.log("Show and toggle feature flags for the active assistant.");
58
+ console.log("Reads from the gateway's merged flag state (persisted overrides > remote > defaults).");
59
+ console.log("");
60
+ console.log("Subcommands:");
61
+ console.log(" (none) List all feature flags in a table");
62
+ console.log(" get <key> Show details for a single flag");
63
+ console.log(" set <key> <bool> Set a flag override to true or false");
64
+ console.log("");
65
+ console.log("Options:");
66
+ console.log(" --help, -h Show this help");
67
+ console.log("");
68
+ console.log("Examples:");
69
+ console.log(" $ vellum flags # list all flags");
70
+ console.log(" $ vellum flags get query-complexity-routing # inspect one flag");
71
+ console.log(" $ vellum flags set voice-mode true # enable a flag");
72
+ }
73
+
74
+ function createClient(): AssistantClient {
75
+ try {
76
+ return new AssistantClient();
77
+ } catch {
78
+ throw new Error(
79
+ "No assistant found. Hatch one with 'vellum hatch' first.",
80
+ );
81
+ }
82
+ }
83
+
84
+ function rethrowFetchError(err: unknown): never {
85
+ if (
86
+ err instanceof TypeError &&
87
+ (err.message.includes("fetch") || err.message.includes("connect"))
88
+ ) {
89
+ throw new Error(
90
+ "Could not reach the assistant gateway. Is it running? Try 'vellum wake'.",
91
+ );
92
+ }
93
+ throw err;
94
+ }
95
+
96
+ async function listFlags(): Promise<void> {
97
+ const client = createClient();
98
+ let res: Response;
99
+ try {
100
+ res = await client.get("/feature-flags");
101
+ } catch (err) {
102
+ rethrowFetchError(err);
103
+ }
104
+ if (!res.ok) {
105
+ const body = await res.text().catch(() => "");
106
+ throw new Error(`Failed to fetch flags: HTTP ${res.status} ${body}`.trim());
107
+ }
108
+ const data = (await res.json()) as FlagsResponse;
109
+ if (data.flags.length === 0) {
110
+ console.log("No feature flags found.");
111
+ return;
112
+ }
113
+ printFlagTable(data.flags);
114
+ }
115
+
116
+ async function getFlag(key: string): Promise<void> {
117
+ const client = createClient();
118
+ let res: Response;
119
+ try {
120
+ res = await client.get("/feature-flags");
121
+ } catch (err) {
122
+ rethrowFetchError(err);
123
+ }
124
+ if (!res.ok) {
125
+ const body = await res.text().catch(() => "");
126
+ throw new Error(`Failed to fetch flags: HTTP ${res.status} ${body}`.trim());
127
+ }
128
+ const data = (await res.json()) as FlagsResponse;
129
+ const flag = data.flags.find((f) => f.key === key);
130
+ if (!flag) {
131
+ throw new Error(`Flag "${key}" not found.`);
132
+ }
133
+ console.log(`Key: ${flag.key}`);
134
+ console.log(`Enabled: ${flag.enabled}`);
135
+ console.log(`Default: ${flag.defaultEnabled}`);
136
+ console.log(`Description: ${flag.description || "(none)"}`);
137
+ }
138
+
139
+ async function setFlag(key: string, value: boolean): Promise<void> {
140
+ const client = createClient();
141
+ let res: Response;
142
+ try {
143
+ res = await client.patch(`/feature-flags/${key}`, { enabled: value });
144
+ } catch (err) {
145
+ rethrowFetchError(err);
146
+ }
147
+ if (!res.ok) {
148
+ const body = await res.text().catch(() => "");
149
+ throw new Error(`Failed to set flag: HTTP ${res.status} ${body}`.trim());
150
+ }
151
+ console.log(`Flag "${key}" set to ${value}`);
152
+ }
153
+
154
+ export async function flags(): Promise<void> {
155
+ const args = process.argv.slice(3);
156
+
157
+ if (args.includes("--help") || args.includes("-h")) {
158
+ printHelp();
159
+ return;
160
+ }
161
+
162
+ const subcommand = args[0];
163
+
164
+ if (!subcommand) {
165
+ await listFlags();
166
+ return;
167
+ }
168
+
169
+ if (subcommand === "get") {
170
+ const key = args[1];
171
+ if (!key) {
172
+ console.error("Usage: vellum flags get <key>");
173
+ process.exit(1);
174
+ }
175
+ await getFlag(key);
176
+ return;
177
+ }
178
+
179
+ if (subcommand === "set") {
180
+ const key = args[1];
181
+ const rawValue = args[2];
182
+ if (!key || rawValue === undefined) {
183
+ console.error("Usage: vellum flags set <key> <true|false>");
184
+ process.exit(1);
185
+ }
186
+ if (rawValue !== "true" && rawValue !== "false") {
187
+ console.error(`Invalid value "${rawValue}". Must be "true" or "false".`);
188
+ process.exit(1);
189
+ }
190
+ await setFlag(key, rawValue === "true");
191
+ return;
192
+ }
193
+
194
+ console.error(`Unknown subcommand: ${subcommand}`);
195
+ printHelp();
196
+ process.exit(1);
197
+ }
@@ -0,0 +1,73 @@
1
+ import {
2
+ lookupAssistantByIdentifier,
3
+ formatAssistantLookupError,
4
+ } from "../../lib/assistant-config.js";
5
+ import {
6
+ loadGuardianToken,
7
+ refreshGuardianToken,
8
+ } from "../../lib/guardian-token.js";
9
+
10
+ function printUsage(): void {
11
+ console.log("Usage: vellum gateway token <subcommand> <assistantId>");
12
+ console.log("");
13
+ console.log("Manage gateway authentication tokens.");
14
+ console.log("");
15
+ console.log("Subcommands:");
16
+ console.log(" get Print the current guardian access token");
17
+ console.log(" refresh Refresh an expired access token and print it");
18
+ }
19
+
20
+ export async function gatewayToken(): Promise<void> {
21
+ const args = process.argv.slice(4);
22
+ const subcommand = args[0];
23
+
24
+ if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
25
+ printUsage();
26
+ process.exit(0);
27
+ }
28
+
29
+ if (subcommand !== "get" && subcommand !== "refresh") {
30
+ console.error(`Unknown subcommand: ${subcommand}`);
31
+ printUsage();
32
+ process.exit(1);
33
+ }
34
+
35
+ const assistantId = args[1];
36
+ if (!assistantId) {
37
+ console.error("Missing required argument: <assistantId>");
38
+ printUsage();
39
+ process.exit(1);
40
+ }
41
+
42
+ const result = lookupAssistantByIdentifier(assistantId);
43
+ if (result.status !== "found") {
44
+ console.error(formatAssistantLookupError(assistantId, result));
45
+ process.exit(1);
46
+ }
47
+ const entry = result.entry;
48
+
49
+ const tokenData = loadGuardianToken(entry.assistantId);
50
+ if (!tokenData) {
51
+ console.error("No guardian token found for this assistant.");
52
+ process.exit(1);
53
+ }
54
+
55
+ if (subcommand === "get") {
56
+ console.log(tokenData.accessToken);
57
+ return;
58
+ }
59
+
60
+ const gatewayUrl = entry.localUrl || entry.runtimeUrl;
61
+ if (!gatewayUrl) {
62
+ console.error("No gateway URL found for this assistant.");
63
+ process.exit(1);
64
+ }
65
+
66
+ const refreshed = await refreshGuardianToken(gatewayUrl, entry.assistantId);
67
+ if (!refreshed) {
68
+ console.error("Failed to refresh guardian token.");
69
+ process.exit(1);
70
+ }
71
+
72
+ console.log(refreshed.accessToken);
73
+ }
@@ -0,0 +1,29 @@
1
+ import { gatewayToken } from "./gateway/token.js";
2
+
3
+ function printUsage(): void {
4
+ console.log("Usage: vellum gateway <subcommand>");
5
+ console.log("");
6
+ console.log("Gateway management commands.");
7
+ console.log("");
8
+ console.log("Subcommands:");
9
+ console.log(" token Manage gateway authentication tokens");
10
+ }
11
+
12
+ export async function gateway(): Promise<void> {
13
+ const args = process.argv.slice(3);
14
+ const subcommand = args[0];
15
+
16
+ if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
17
+ printUsage();
18
+ process.exit(0);
19
+ }
20
+
21
+ if (subcommand === "token") {
22
+ await gatewayToken();
23
+ return;
24
+ }
25
+
26
+ console.error(`Unknown subcommand: ${subcommand}`);
27
+ printUsage();
28
+ process.exit(1);
29
+ }
@@ -4,8 +4,12 @@ import { createInterface } from "readline";
4
4
  import { watch } from "fs";
5
5
  import { join } from "path";
6
6
 
7
- import { resolveAssistant } from "../lib/assistant-config";
8
- import type { AssistantEntry } from "../lib/assistant-config";
7
+ import {
8
+ extractHostFromUrl,
9
+ resolveAssistant,
10
+ resolveCloud,
11
+ type AssistantEntry,
12
+ } from "../lib/assistant-config";
9
13
  import { dockerResourceNames } from "../lib/docker";
10
14
  import { getLogDir } from "../lib/xdg-log";
11
15
  import { execOutput } from "../lib/step-runner";
@@ -112,13 +116,6 @@ function parseArgs(): LogsArgs {
112
116
 
113
117
  // ── Helpers ─────────────────────────────────────────────────────
114
118
 
115
- function resolveCloud(entry: AssistantEntry): string {
116
- if (entry.cloud) return entry.cloud;
117
- if (entry.project) return "gcp";
118
- if (entry.sshUser) return "custom";
119
- return "local";
120
- }
121
-
122
119
  /**
123
120
  * Parse a relative time string like "10m", "2h", "30s" into a Date.
124
121
  * Returns null if the string doesn't look like a relative time.
@@ -494,15 +491,6 @@ async function showGcpLogs(
494
491
  }
495
492
  }
496
493
 
497
- function extractHostFromUrl(url: string): string {
498
- try {
499
- const parsed = new URL(url);
500
- return parsed.hostname;
501
- } catch {
502
- return url.replace(/^https?:\/\//, "").split(":")[0];
503
- }
504
- }
505
-
506
494
  async function showCustomLogs(
507
495
  entry: AssistantEntry,
508
496
  opts: LogsArgs,
@@ -1,6 +1,7 @@
1
1
  import { join } from "path";
2
2
 
3
3
  import {
4
+ extractHostFromUrl,
4
5
  findAssistantByName,
5
6
  formatAssistantLookupError,
6
7
  formatAssistantReference,
@@ -9,6 +10,7 @@ import {
9
10
  getDaemonPidPath,
10
11
  loadAllAssistants,
11
12
  lookupAssistantByIdentifier,
13
+ resolveCloud,
12
14
  type AssistantEntry,
13
15
  } from "../lib/assistant-config";
14
16
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
@@ -26,7 +28,7 @@ import { existsSync } from "fs";
26
28
  import {
27
29
  classifyProcess,
28
30
  detectOrphanedProcesses,
29
- isProcessAlive,
31
+ isPidAlive,
30
32
  parseRemotePs,
31
33
  readPidFile,
32
34
  } from "../lib/orphan-detection";
@@ -149,35 +151,25 @@ const REMOTE_PS_CMD = [
149
151
  "| grep -v grep",
150
152
  ].join(" ");
151
153
 
152
- function extractHostFromUrl(url: string): string {
153
- try {
154
- const parsed = new URL(url);
155
- return parsed.hostname;
156
- } catch {
157
- return url.replace(/^https?:\/\//, "").split(":")[0];
158
- }
159
- }
160
-
161
- function resolveCloud(entry: AssistantEntry): string {
162
- if (entry.cloud) return entry.cloud;
163
- if (entry.project) return "gcp";
164
- if (entry.sshUser) return "custom";
165
- return "local";
166
- }
154
+ const REMOTE_SSH_TIMEOUT_MS = 30_000;
167
155
 
168
156
  async function getRemoteProcessesGcp(entry: AssistantEntry): Promise<string> {
169
- return execOutput("gcloud", [
170
- "compute",
171
- "ssh",
172
- `${entry.sshUser ?? entry.assistantId}@${entry.assistantId}`,
173
- `--zone=${entry.zone}`,
174
- `--project=${entry.project}`,
175
- `--command=${REMOTE_PS_CMD}`,
176
- "--ssh-flag=-o StrictHostKeyChecking=no",
177
- "--ssh-flag=-o UserKnownHostsFile=/dev/null",
178
- "--ssh-flag=-o ConnectTimeout=10",
179
- "--ssh-flag=-o LogLevel=ERROR",
180
- ]);
157
+ return execOutput(
158
+ "gcloud",
159
+ [
160
+ "compute",
161
+ "ssh",
162
+ `${entry.sshUser ?? entry.assistantId}@${entry.assistantId}`,
163
+ `--zone=${entry.zone}`,
164
+ `--project=${entry.project}`,
165
+ `--command=${REMOTE_PS_CMD}`,
166
+ "--ssh-flag=-o StrictHostKeyChecking=no",
167
+ "--ssh-flag=-o UserKnownHostsFile=/dev/null",
168
+ "--ssh-flag=-o ConnectTimeout=10",
169
+ "--ssh-flag=-o LogLevel=ERROR",
170
+ ],
171
+ { timeoutMs: REMOTE_SSH_TIMEOUT_MS },
172
+ );
181
173
  }
182
174
 
183
175
  async function getRemoteProcessesCustom(
@@ -185,7 +177,9 @@ async function getRemoteProcessesCustom(
185
177
  ): Promise<string> {
186
178
  const host = extractHostFromUrl(entry.runtimeUrl);
187
179
  const sshUser = entry.sshUser ?? "root";
188
- return execOutput("ssh", [...SSH_OPTS, `${sshUser}@${host}`, REMOTE_PS_CMD]);
180
+ return execOutput("ssh", [...SSH_OPTS, `${sshUser}@${host}`, REMOTE_PS_CMD], {
181
+ timeoutMs: REMOTE_SSH_TIMEOUT_MS,
182
+ });
189
183
  }
190
184
 
191
185
  interface ProcessSpec {
@@ -203,9 +197,13 @@ interface DetectedProcess {
203
197
  watch: boolean;
204
198
  }
205
199
 
200
+ const LOCAL_CMD_TIMEOUT_MS = 5_000;
201
+
206
202
  async function isWatchMode(pid: string): Promise<boolean> {
207
203
  try {
208
- const args = await execOutput("ps", ["-p", pid, "-o", "args="]);
204
+ const args = await execOutput("ps", ["-p", pid, "-o", "args="], {
205
+ timeoutMs: LOCAL_CMD_TIMEOUT_MS,
206
+ });
209
207
  return args.includes("--watch");
210
208
  } catch {
211
209
  return false;
@@ -242,7 +240,7 @@ async function detectProcess(spec: ProcessSpec): Promise<DetectedProcess> {
242
240
 
243
241
  // Tier 3: PID file fallback
244
242
  const filePid = readPidFile(spec.pidFile);
245
- if (filePid && isProcessAlive(filePid)) {
243
+ if (filePid && isPidAlive(filePid)) {
246
244
  const watch = await isWatchMode(filePid);
247
245
  return {
248
246
  name: spec.name,
@@ -320,12 +318,11 @@ async function getDockerContainerState(
320
318
  containerName: string,
321
319
  ): Promise<string | null> {
322
320
  try {
323
- const output = await execOutput("docker", [
324
- "inspect",
325
- "--format",
326
- "{{.State.Status}}",
327
- containerName,
328
- ]);
321
+ const output = await execOutput(
322
+ "docker",
323
+ ["inspect", "--format", "{{.State.Status}}", containerName],
324
+ { timeoutMs: LOCAL_CMD_TIMEOUT_MS },
325
+ );
329
326
  return output.trim() || "unknown";
330
327
  } catch {
331
328
  return null;
@@ -458,9 +455,12 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
458
455
  process.exit(1);
459
456
  }
460
457
  } catch (error) {
461
- console.error(
462
- `Failed to list processes: ${error instanceof Error ? error.message : error}`,
463
- );
458
+ const msg = error instanceof Error ? error.message : String(error);
459
+ if (msg.includes("timed out")) {
460
+ console.warn(`Warning: remote process listing timed out — ${msg}`);
461
+ return;
462
+ }
463
+ console.error(`Failed to list processes: ${msg}`);
464
464
  process.exit(1);
465
465
  }
466
466
 
@@ -496,7 +496,7 @@ async function getAssistantListHealth(
496
496
  // TODO(ATL-306): Remove readPidFile/getDaemonPidPath in favor of
497
497
  // fetching daemon PIDs via the health API (Gateway Security Migration).
498
498
  const pid = readPidFile(getDaemonPidPath(resources));
499
- const alive = pid !== null && isProcessAlive(pid);
499
+ const alive = pid !== null && isPidAlive(pid);
500
500
  if (!alive) {
501
501
  return { status: "sleeping", detail: null };
502
502
  }
@@ -1,6 +1,12 @@
1
- import { existsSync, readFileSync, unlinkSync } from "fs";
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ } from "fs";
2
8
  import { homedir } from "os";
3
- import { join } from "path";
9
+ import { basename, dirname, join } from "path";
4
10
 
5
11
  import { saveAssistantEntry } from "../lib/assistant-config";
6
12
  import type { AssistantEntry } from "../lib/assistant-config";
@@ -21,8 +27,22 @@ export async function recover(): Promise<void> {
21
27
  "Restore a previously retired local assistant from its archive.",
22
28
  );
23
29
  console.log("");
30
+ console.log(
31
+ "Extracts the archived workspace data back to its original location,",
32
+ );
33
+ console.log(
34
+ "restores the lockfile entry, and starts the assistant and gateway.",
35
+ );
36
+ console.log(
37
+ "Archives are stored in $XDG_DATA_HOME/vellum/retired/ (default: ~/.local/share/vellum/retired/).",
38
+ );
39
+ console.log("");
24
40
  console.log("Arguments:");
25
41
  console.log(" <name> Name of the retired assistant to recover");
42
+ console.log("");
43
+ console.log("Examples:");
44
+ console.log(" $ vellum recover my-assistant");
45
+ console.log(" $ vellum recover aria-7f3a");
26
46
  process.exit(0);
27
47
  }
28
48
 
@@ -61,11 +81,27 @@ export async function recover(): Promise<void> {
61
81
  process.exit(1);
62
82
  }
63
83
 
64
- // 4. Extract archive
65
- // TODO: extraction target is hardcoded to homedir(); multi-instance entries
66
- // whose instanceDir differs from homedir will extract to the wrong
67
- // location. Tracked separately from the collision-check regression.
68
- await exec("tar", ["xzf", archivePath, "-C", homedir()]);
84
+ // 4. Determine the original target directory, then extract and rename.
85
+ //
86
+ // retireLocal archives either the full instanceDir (named instances) or just
87
+ // the .vellum/ subdirectory (default instance whose instanceDir === homedir()).
88
+ // The directory is staged under `<archive>.staging` inside the retired dir
89
+ // before being packed with `tar -C <retiredDir> <stagingBasename>`, so the
90
+ // top-level entry inside the tarball is always `<name>.tar.gz.staging`.
91
+ //
92
+ // Correct restoration: extract to retiredDir, then rename the staging entry
93
+ // back to the original target path. Using homedir() as the -C target was
94
+ // wrong for any instance stored outside the home directory.
95
+ const isNamedInstance = entry.resources.instanceDir !== homedir();
96
+ const targetDir = isNamedInstance
97
+ ? entry.resources.instanceDir
98
+ : join(entry.resources.instanceDir, ".vellum");
99
+ const retiredDir = dirname(archivePath);
100
+ const extractedPath = join(retiredDir, basename(archivePath) + ".staging");
101
+
102
+ await exec("tar", ["xzf", archivePath, "-C", retiredDir]);
103
+ mkdirSync(dirname(targetDir), { recursive: true });
104
+ renameSync(extractedPath, targetDir);
69
105
 
70
106
  // 5. Restore lockfile entry
71
107
  saveAssistantEntry(entry);
@@ -74,14 +110,16 @@ export async function recover(): Promise<void> {
74
110
  unlinkSync(archivePath);
75
111
  unlinkSync(metadataPath);
76
112
 
77
- // 7. Persist signing key so it survives daemon/gateway restarts (same as wake)
113
+ // 7. Persist signing key and bootstrap secret so they survive daemon/gateway restarts
78
114
  const signingKey = generateLocalSigningKey();
115
+ const bootstrapSecret = generateLocalSigningKey();
79
116
  entry.resources = { ...entry.resources, signingKey };
117
+ entry.guardianBootstrapSecret = bootstrapSecret;
80
118
  saveAssistantEntry(entry);
81
119
 
82
120
  // 8. Start daemon + gateway
83
121
  await startLocalDaemon(false, entry.resources, { signingKey });
84
- await startGateway(false, entry.resources, { signingKey });
122
+ await startGateway(false, entry.resources, { signingKey, bootstrapSecret });
85
123
 
86
124
  console.log(`✅ Recovered assistant '${name}'.`);
87
125
  }
@@ -97,6 +97,7 @@ async function getAccessToken(
97
97
  runtimeUrl: string,
98
98
  assistantId: string,
99
99
  displayName: string,
100
+ bootstrapSecret?: string,
100
101
  ): Promise<string> {
101
102
  const tokenData = loadGuardianToken(assistantId);
102
103
 
@@ -105,7 +106,11 @@ async function getAccessToken(
105
106
  }
106
107
 
107
108
  try {
108
- const freshToken = await leaseGuardianToken(runtimeUrl, assistantId);
109
+ const freshToken = await leaseGuardianToken(
110
+ runtimeUrl,
111
+ assistantId,
112
+ bootstrapSecret,
113
+ );
109
114
  return freshToken.accessToken;
110
115
  } catch (err) {
111
116
  const msg = err instanceof Error ? err.message : String(err);
@@ -574,6 +579,7 @@ export async function restore(): Promise<void> {
574
579
  entry.runtimeUrl,
575
580
  entry.assistantId,
576
581
  name,
582
+ entry.guardianBootstrapSecret,
577
583
  );
578
584
 
579
585
  if (dryRun) {
@@ -679,6 +685,7 @@ export async function restore(): Promise<void> {
679
685
  entry.runtimeUrl,
680
686
  entry.assistantId,
681
687
  name,
688
+ entry.guardianBootstrapSecret,
682
689
  );
683
690
  }
684
691