Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cli/src/commands/hatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { PollResult, WatchHatchingResult } from "../lib/gcp";
import { startLocalDaemon, startGateway, stopLocalProcesses } from "../lib/local";
import { isProcessAlive } from "../lib/process";
import { generateRandomSuffix } from "../lib/random-name";
import { validateAssistantName } from "../lib/retire-archive";
import { exec } from "../lib/step-runner";

export type { PollResult, WatchHatchingResult } from "../lib/gcp";
Expand Down Expand Up @@ -169,6 +170,12 @@ function parseArgs(): HatchArgs {
console.error("Error: --name requires a value");
process.exit(1);
}
try {
validateAssistantName(next);
} catch {
console.error(`Error: --name contains invalid characters (path separators or traversal segments are not allowed)`);
process.exit(1);
}
name = next;
i++;
} else if (arg === "--remote") {
Expand Down
54 changes: 54 additions & 0 deletions cli/src/commands/recover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { existsSync, readFileSync, unlinkSync } from "fs";
import { homedir } from "os";
import { join } from "path";

import { saveAssistantEntry } from "../lib/assistant-config";
import type { AssistantEntry } from "../lib/assistant-config";
import { startLocalDaemon, startGateway } from "../lib/local";
import { getArchivePath, getMetadataPath } from "../lib/retire-archive";
import { exec } from "../lib/step-runner";

export async function recover(): Promise<void> {
const name = process.argv[3];
if (!name) {
console.error("Usage: vellum-cli recover <name>");
process.exit(1);
}

const archivePath = getArchivePath(name);
const metadataPath = getMetadataPath(name);

// 1. Verify archive exists
if (!existsSync(archivePath) || !existsSync(metadataPath)) {
console.error(`No retired archive found for '${name}'.`);
process.exit(1);
}

// 2. Check ~/.vellum doesn't already exist
const vellumDir = join(homedir(), ".vellum");
if (existsSync(vellumDir)) {
console.error(
"Error: ~/.vellum already exists. Retire the current assistant first."
);
process.exit(1);
}

// 3. Extract archive
await exec("tar", ["xzf", archivePath, "-C", homedir()]);

// 4. Restore lockfile entry
const entry: AssistantEntry = JSON.parse(readFileSync(metadataPath, "utf-8"));
saveAssistantEntry(entry);

// 5. Clean up archive
unlinkSync(archivePath);
unlinkSync(metadataPath);

// 6. Start daemon + gateway (same as wake)
await startLocalDaemon();
if (!process.env.VELLUM_DESKTOP_APP) {
await startGateway();
}

console.log(`✅ Recovered assistant '${name}'.`);
}
21 changes: 17 additions & 4 deletions cli/src/commands/retire.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { spawn } from "child_process";
import { rmSync } from "fs";
import { rmSync, writeFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import { basename, dirname, join } from "path";

import { findAssistantByName, removeAssistantEntry } from "../lib/assistant-config";
import type { AssistantEntry } from "../lib/assistant-config";
import { retireInstance as retireAwsInstance } from "../lib/aws";
import { retireInstance as retireGcpInstance } from "../lib/gcp";
import { stopProcessByPidFile } from "../lib/process";
import { getArchivePath, getMetadataPath } from "../lib/retire-archive";
import { exec } from "../lib/step-runner";

function resolveCloud(entry: AssistantEntry): string {
Expand All @@ -32,7 +33,7 @@ function extractHostFromUrl(url: string): string {
}
}

async function retireLocal(): Promise<void> {
async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
console.log("\u{1F5D1}\ufe0f Stopping local daemon...\n");

const vellumDir = join(homedir(), ".vellum");
Expand Down Expand Up @@ -61,6 +62,18 @@ async function retireLocal(): Promise<void> {
} catch {}
}

// Archive ~/.vellum before deleting
try {
const archivePath = getArchivePath(name);
const metadataPath = getMetadataPath(name);
await exec("tar", ["czf", archivePath, "-C", dirname(vellumDir), basename(vellumDir)]);
writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
console.log(`📦 Archived to ${archivePath}`);
} catch (err) {
console.warn(`⚠️ Failed to archive: ${err instanceof Error ? err.message : err}`);
console.warn("Proceeding with permanent deletion.");
}

rmSync(vellumDir, { recursive: true, force: true });

console.log("\u2705 Local instance retired.");
Expand Down Expand Up @@ -142,7 +155,7 @@ export async function retire(): Promise<void> {
}
await retireAwsInstance(name, region, source);
} else if (cloud === "local") {
await retireLocal();
await retireLocal(name, entry);
} else if (cloud === "custom") {
await retireCustom(entry);
} else {
Expand Down
3 changes: 3 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { client } from "./commands/client";
import { email } from "./commands/email";
import { hatch } from "./commands/hatch";
import { ps } from "./commands/ps";
import { recover } from "./commands/recover";
import { retire } from "./commands/retire";
import { sleep } from "./commands/sleep";
import { ssh } from "./commands/ssh";
Expand All @@ -19,6 +20,7 @@ const commands = {
email,
hatch,
ps,
recover,
retire,
sleep,
ssh,
Expand Down Expand Up @@ -65,6 +67,7 @@ async function main() {
console.log(" email Email operations (status, create inbox)");
console.log(" hatch Create a new assistant instance");
console.log(" ps List assistants (or processes for a specific assistant)");
console.log(" recover Restore a previously retired local assistant");
console.log(" retire Delete an assistant instance");
console.log(" sleep Stop the daemon process");
console.log(" ssh SSH into a remote assistant instance");
Expand Down
43 changes: 43 additions & 0 deletions cli/src/lib/retire-archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { mkdirSync } from "fs";
import { homedir } from "os";
import { basename, join, resolve } from "path";

export function getRetiredDir(): string {
const xdgData =
process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
const dir = join(xdgData, "vellum", "retired");
mkdirSync(dir, { recursive: true });
return dir;
}

/** Throws if the name contains path separators or traversal segments. */
export function validateAssistantName(name: string): void {
if (
!name ||
name.includes("/") ||
name.includes("\\") ||
name === ".." ||
name === "."
) {
throw new Error(`Invalid assistant name: '${name}'`);
}
}

function safeName(assistantId: string): string {
validateAssistantName(assistantId);
// Canonicalize and verify the result stays inside the retired directory
const retiredDir = getRetiredDir();
const candidate = resolve(retiredDir, basename(assistantId));
if (!candidate.startsWith(retiredDir + "/")) {
throw new Error(`Invalid assistant name: '${assistantId}'`);
}
return basename(assistantId);
}

export function getArchivePath(assistantId: string): string {
return join(getRetiredDir(), `${safeName(assistantId)}.tar.gz`);
}

export function getMetadataPath(assistantId: string): string {
return join(getRetiredDir(), `${safeName(assistantId)}.json`);
}
Loading