@vellumai/cli 0.3.5 → 0.3.7
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/package.json +1 -1
- package/src/commands/config.ts +244 -0
- package/src/commands/email.ts +28 -8
- package/src/commands/hatch.ts +7 -0
- package/src/commands/ps.ts +65 -0
- package/src/commands/recover.ts +54 -0
- package/src/commands/retire.ts +17 -4
- package/src/email/vellum.ts +11 -17
- package/src/index.ts +6 -0
- package/src/lib/local.ts +1 -0
- package/src/lib/retire-archive.ts +43 -0
package/package.json
CHANGED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
interface AllowlistConfig {
|
|
6
|
+
values?: string[];
|
|
7
|
+
prefixes?: string[];
|
|
8
|
+
patterns?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AllowlistValidationError {
|
|
12
|
+
index: number;
|
|
13
|
+
pattern: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getRootDir(): string {
|
|
18
|
+
return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getConfigPath(): string {
|
|
22
|
+
return join(getRootDir(), "workspace", "config.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getAllowlistPath(): string {
|
|
26
|
+
return join(getRootDir(), "protected", "secret-allowlist.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loadRawConfig(): Record<string, unknown> {
|
|
30
|
+
const configPath = getConfigPath();
|
|
31
|
+
if (!existsSync(configPath)) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
35
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function saveRawConfig(config: Record<string, unknown>): void {
|
|
39
|
+
const configPath = getConfigPath();
|
|
40
|
+
const dir = dirname(configPath);
|
|
41
|
+
if (!existsSync(dir)) {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getNestedValue(
|
|
48
|
+
obj: Record<string, unknown>,
|
|
49
|
+
path: string,
|
|
50
|
+
): unknown {
|
|
51
|
+
const keys = path.split(".");
|
|
52
|
+
let current: unknown = obj;
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
if (
|
|
55
|
+
current === null ||
|
|
56
|
+
current === undefined ||
|
|
57
|
+
typeof current !== "object"
|
|
58
|
+
) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
current = (current as Record<string, unknown>)[key];
|
|
62
|
+
}
|
|
63
|
+
return current;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function setNestedValue(
|
|
67
|
+
obj: Record<string, unknown>,
|
|
68
|
+
path: string,
|
|
69
|
+
value: unknown,
|
|
70
|
+
): void {
|
|
71
|
+
const keys = path.split(".");
|
|
72
|
+
let current = obj;
|
|
73
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
74
|
+
const key = keys[i];
|
|
75
|
+
if (
|
|
76
|
+
current[key] === undefined ||
|
|
77
|
+
current[key] === null ||
|
|
78
|
+
typeof current[key] !== "object"
|
|
79
|
+
) {
|
|
80
|
+
current[key] = {};
|
|
81
|
+
}
|
|
82
|
+
current = current[key] as Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
current[keys[keys.length - 1]] = value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateAllowlist(
|
|
88
|
+
allowlistConfig: AllowlistConfig,
|
|
89
|
+
): AllowlistValidationError[] {
|
|
90
|
+
const errors: AllowlistValidationError[] = [];
|
|
91
|
+
if (!allowlistConfig.patterns) return errors;
|
|
92
|
+
if (!Array.isArray(allowlistConfig.patterns)) {
|
|
93
|
+
errors.push({
|
|
94
|
+
index: -1,
|
|
95
|
+
pattern: String(allowlistConfig.patterns),
|
|
96
|
+
message: '"patterns" must be an array',
|
|
97
|
+
});
|
|
98
|
+
return errors;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < allowlistConfig.patterns.length; i++) {
|
|
102
|
+
const p = allowlistConfig.patterns[i];
|
|
103
|
+
if (typeof p !== "string") {
|
|
104
|
+
errors.push({
|
|
105
|
+
index: i,
|
|
106
|
+
pattern: String(p),
|
|
107
|
+
message: "Pattern is not a string",
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
new RegExp(p);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
errors.push({
|
|
115
|
+
index: i,
|
|
116
|
+
pattern: p,
|
|
117
|
+
message: (err as Error).message,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return errors;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateAllowlistFile(): AllowlistValidationError[] | null {
|
|
125
|
+
const filePath = getAllowlistPath();
|
|
126
|
+
if (!existsSync(filePath)) return null;
|
|
127
|
+
|
|
128
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
129
|
+
const allowlistConfig: AllowlistConfig = JSON.parse(
|
|
130
|
+
raw,
|
|
131
|
+
) as AllowlistConfig;
|
|
132
|
+
return validateAllowlist(allowlistConfig);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function printUsage(): void {
|
|
136
|
+
console.log("Usage: vellum config <subcommand> [options]");
|
|
137
|
+
console.log("");
|
|
138
|
+
console.log("Subcommands:");
|
|
139
|
+
console.log(
|
|
140
|
+
" get <key> Get a config value (supports dotted paths)",
|
|
141
|
+
);
|
|
142
|
+
console.log(
|
|
143
|
+
" set <key> <value> Set a config value (supports dotted paths like apiKeys.anthropic)",
|
|
144
|
+
);
|
|
145
|
+
console.log(
|
|
146
|
+
" list List all config values",
|
|
147
|
+
);
|
|
148
|
+
console.log(
|
|
149
|
+
" validate-allowlist Validate regex patterns in secret-allowlist.json",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function config(): void {
|
|
154
|
+
const args = process.argv.slice(3);
|
|
155
|
+
const subcommand = args[0];
|
|
156
|
+
|
|
157
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
158
|
+
printUsage();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
switch (subcommand) {
|
|
163
|
+
case "set": {
|
|
164
|
+
const key = args[1];
|
|
165
|
+
const value = args[2];
|
|
166
|
+
if (!key || value === undefined) {
|
|
167
|
+
console.error("Usage: vellum config set <key> <value>");
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
const raw = loadRawConfig();
|
|
171
|
+
let parsed: unknown = value;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(value);
|
|
174
|
+
} catch {
|
|
175
|
+
// keep as string
|
|
176
|
+
}
|
|
177
|
+
setNestedValue(raw, key, parsed);
|
|
178
|
+
saveRawConfig(raw);
|
|
179
|
+
console.log(`Set ${key} = ${JSON.stringify(parsed)}`);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case "get": {
|
|
184
|
+
const key = args[1];
|
|
185
|
+
if (!key) {
|
|
186
|
+
console.error("Usage: vellum config get <key>");
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
const raw = loadRawConfig();
|
|
190
|
+
const val = getNestedValue(raw, key);
|
|
191
|
+
if (val === undefined) {
|
|
192
|
+
console.log("(not set)");
|
|
193
|
+
} else {
|
|
194
|
+
console.log(
|
|
195
|
+
typeof val === "object" ? JSON.stringify(val, null, 2) : String(val),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case "list": {
|
|
202
|
+
const raw = loadRawConfig();
|
|
203
|
+
if (Object.keys(raw).length === 0) {
|
|
204
|
+
console.log("No configuration set");
|
|
205
|
+
} else {
|
|
206
|
+
console.log(JSON.stringify(raw, null, 2));
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case "validate-allowlist": {
|
|
212
|
+
try {
|
|
213
|
+
const errors = validateAllowlistFile();
|
|
214
|
+
if (errors === null) {
|
|
215
|
+
console.log("No secret-allowlist.json file found");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (errors.length === 0) {
|
|
219
|
+
console.log("All patterns in secret-allowlist.json are valid");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
console.error(
|
|
223
|
+
`Found ${errors.length} invalid pattern(s) in secret-allowlist.json:`,
|
|
224
|
+
);
|
|
225
|
+
for (const e of errors) {
|
|
226
|
+
console.error(` [${e.index}] "${e.pattern}": ${e.message}`);
|
|
227
|
+
}
|
|
228
|
+
process.exit(1);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.error(
|
|
231
|
+
`Failed to read secret-allowlist.json: ${(err as Error).message}`,
|
|
232
|
+
);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
default: {
|
|
239
|
+
console.error(`Unknown config subcommand: ${subcommand}`);
|
|
240
|
+
printUsage();
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
package/src/commands/email.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { VellumEmailClient } from "../email/vellum.js";
|
|
10
|
+
import { loadLatestAssistant } from "../lib/assistant-config.js";
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
13
|
// Helpers
|
|
@@ -33,6 +34,7 @@ Subcommands:
|
|
|
33
34
|
create <username> Create a new email inbox for the given username
|
|
34
35
|
|
|
35
36
|
Options:
|
|
37
|
+
--assistant <id> Assistant ID (defaults to the most recently hatched)
|
|
36
38
|
--help, -h Show this help message
|
|
37
39
|
`);
|
|
38
40
|
}
|
|
@@ -49,18 +51,36 @@ export async function email(): Promise<void> {
|
|
|
49
51
|
return;
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
// Resolve assistant ID from --assistant flag or latest assistant
|
|
55
|
+
const assistantFlagIdx = args.indexOf("--assistant");
|
|
56
|
+
let assistantId: string | undefined;
|
|
57
|
+
if (assistantFlagIdx !== -1) {
|
|
58
|
+
const value = args[assistantFlagIdx + 1];
|
|
59
|
+
if (!value || value.startsWith("-")) {
|
|
60
|
+
exitError("--assistant requires a value.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
assistantId = value;
|
|
64
|
+
args.splice(assistantFlagIdx, 2);
|
|
65
|
+
}
|
|
66
|
+
if (!assistantId) {
|
|
67
|
+
assistantId = loadLatestAssistant()?.assistantId;
|
|
68
|
+
}
|
|
69
|
+
if (!assistantId) {
|
|
70
|
+
exitError(
|
|
71
|
+
"No assistant ID available. Pass --assistant <id> or hatch an assistant first.",
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
52
76
|
const subcommand = args[0];
|
|
53
77
|
|
|
54
78
|
switch (subcommand) {
|
|
55
79
|
case "status": {
|
|
56
80
|
try {
|
|
57
|
-
const client = new VellumEmailClient();
|
|
58
|
-
const
|
|
59
|
-
output({
|
|
60
|
-
ok: true,
|
|
61
|
-
provider: status.provider,
|
|
62
|
-
inboxes: status.inboxes,
|
|
63
|
-
});
|
|
81
|
+
const client = new VellumEmailClient(assistantId);
|
|
82
|
+
const addresses = await client.status();
|
|
83
|
+
output({ ok: true, addresses });
|
|
64
84
|
} catch (err) {
|
|
65
85
|
exitError(err instanceof Error ? err.message : String(err));
|
|
66
86
|
}
|
|
@@ -73,7 +93,7 @@ export async function email(): Promise<void> {
|
|
|
73
93
|
return;
|
|
74
94
|
}
|
|
75
95
|
try {
|
|
76
|
-
const client = new VellumEmailClient();
|
|
96
|
+
const client = new VellumEmailClient(assistantId);
|
|
77
97
|
const inbox = await client.createInbox(username);
|
|
78
98
|
output({ ok: true, inbox });
|
|
79
99
|
} catch (err) {
|
package/src/commands/hatch.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
|
22
22
|
import { startLocalDaemon, startGateway, stopLocalProcesses } from "../lib/local";
|
|
23
23
|
import { isProcessAlive } from "../lib/process";
|
|
24
24
|
import { generateRandomSuffix } from "../lib/random-name";
|
|
25
|
+
import { validateAssistantName } from "../lib/retire-archive";
|
|
25
26
|
import { exec } from "../lib/step-runner";
|
|
26
27
|
|
|
27
28
|
export type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
@@ -169,6 +170,12 @@ function parseArgs(): HatchArgs {
|
|
|
169
170
|
console.error("Error: --name requires a value");
|
|
170
171
|
process.exit(1);
|
|
171
172
|
}
|
|
173
|
+
try {
|
|
174
|
+
validateAssistantName(next);
|
|
175
|
+
} catch {
|
|
176
|
+
console.error(`Error: --name contains invalid characters (path separators or traversal segments are not allowed)`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
172
179
|
name = next;
|
|
173
180
|
i++;
|
|
174
181
|
} else if (arg === "--remote") {
|
package/src/commands/ps.ts
CHANGED
|
@@ -245,6 +245,57 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
|
|
|
245
245
|
printTable(rows);
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// ── Orphaned process detection ──────────────────────────────────
|
|
249
|
+
|
|
250
|
+
interface OrphanedProcess {
|
|
251
|
+
name: string;
|
|
252
|
+
pid: string;
|
|
253
|
+
source: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
|
|
257
|
+
const results: OrphanedProcess[] = [];
|
|
258
|
+
const seenPids = new Set<string>();
|
|
259
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
260
|
+
|
|
261
|
+
// Strategy 1: PID file scan
|
|
262
|
+
const pidFiles: Array<{ file: string; name: string }> = [
|
|
263
|
+
{ file: join(vellumDir, "vellum.pid"), name: "daemon" },
|
|
264
|
+
{ file: join(vellumDir, "gateway.pid"), name: "gateway" },
|
|
265
|
+
{ file: join(vellumDir, "qdrant.pid"), name: "qdrant" },
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
for (const { file, name } of pidFiles) {
|
|
269
|
+
const result = checkPidFile(file);
|
|
270
|
+
if (result.status === "running" && result.pid) {
|
|
271
|
+
results.push({ name, pid: result.pid, source: "pid file" });
|
|
272
|
+
seenPids.add(result.pid);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Strategy 2: Process table scan
|
|
277
|
+
try {
|
|
278
|
+
const output = await execOutput("sh", [
|
|
279
|
+
"-c",
|
|
280
|
+
"ps ax -o pid=,ppid=,args= | grep -E 'vellum|gateway|qdrant|openclaw' | grep -v grep",
|
|
281
|
+
]);
|
|
282
|
+
const procs = parseRemotePs(output);
|
|
283
|
+
const ownPid = String(process.pid);
|
|
284
|
+
|
|
285
|
+
for (const p of procs) {
|
|
286
|
+
if (p.pid === ownPid || seenPids.has(p.pid)) continue;
|
|
287
|
+
const type = classifyProcess(p.command);
|
|
288
|
+
if (type === "unknown") continue;
|
|
289
|
+
results.push({ name: type, pid: p.pid, source: "process table" });
|
|
290
|
+
seenPids.add(p.pid);
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
// grep exits 1 when no matches found — ignore
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return results;
|
|
297
|
+
}
|
|
298
|
+
|
|
248
299
|
// ── List all assistants (no arg) ────────────────────────────────
|
|
249
300
|
|
|
250
301
|
async function listAllAssistants(): Promise<void> {
|
|
@@ -252,6 +303,20 @@ async function listAllAssistants(): Promise<void> {
|
|
|
252
303
|
|
|
253
304
|
if (assistants.length === 0) {
|
|
254
305
|
console.log("No assistants found.");
|
|
306
|
+
|
|
307
|
+
const orphans = await detectOrphanedProcesses();
|
|
308
|
+
if (orphans.length > 0) {
|
|
309
|
+
console.log("\nOrphaned processes detected:\n");
|
|
310
|
+
const rows: TableRow[] = orphans.map((o) => ({
|
|
311
|
+
name: o.name,
|
|
312
|
+
status: withStatusEmoji("running"),
|
|
313
|
+
info: `PID ${o.pid} (from ${o.source})`,
|
|
314
|
+
}));
|
|
315
|
+
printTable(rows);
|
|
316
|
+
const pids = orphans.map((o) => o.pid).join(" ");
|
|
317
|
+
console.log(`\nHint: Run \`kill ${pids}\` to clean up orphaned processes.`);
|
|
318
|
+
}
|
|
319
|
+
|
|
255
320
|
return;
|
|
256
321
|
}
|
|
257
322
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
import { saveAssistantEntry } from "../lib/assistant-config";
|
|
6
|
+
import type { AssistantEntry } from "../lib/assistant-config";
|
|
7
|
+
import { startLocalDaemon, startGateway } from "../lib/local";
|
|
8
|
+
import { getArchivePath, getMetadataPath } from "../lib/retire-archive";
|
|
9
|
+
import { exec } from "../lib/step-runner";
|
|
10
|
+
|
|
11
|
+
export async function recover(): Promise<void> {
|
|
12
|
+
const name = process.argv[3];
|
|
13
|
+
if (!name) {
|
|
14
|
+
console.error("Usage: vellum-cli recover <name>");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const archivePath = getArchivePath(name);
|
|
19
|
+
const metadataPath = getMetadataPath(name);
|
|
20
|
+
|
|
21
|
+
// 1. Verify archive exists
|
|
22
|
+
if (!existsSync(archivePath) || !existsSync(metadataPath)) {
|
|
23
|
+
console.error(`No retired archive found for '${name}'.`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 2. Check ~/.vellum doesn't already exist
|
|
28
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
29
|
+
if (existsSync(vellumDir)) {
|
|
30
|
+
console.error(
|
|
31
|
+
"Error: ~/.vellum already exists. Retire the current assistant first."
|
|
32
|
+
);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 3. Extract archive
|
|
37
|
+
await exec("tar", ["xzf", archivePath, "-C", homedir()]);
|
|
38
|
+
|
|
39
|
+
// 4. Restore lockfile entry
|
|
40
|
+
const entry: AssistantEntry = JSON.parse(readFileSync(metadataPath, "utf-8"));
|
|
41
|
+
saveAssistantEntry(entry);
|
|
42
|
+
|
|
43
|
+
// 5. Clean up archive
|
|
44
|
+
unlinkSync(archivePath);
|
|
45
|
+
unlinkSync(metadataPath);
|
|
46
|
+
|
|
47
|
+
// 6. Start daemon + gateway (same as wake)
|
|
48
|
+
await startLocalDaemon();
|
|
49
|
+
if (!process.env.VELLUM_DESKTOP_APP) {
|
|
50
|
+
await startGateway();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(`✅ Recovered assistant '${name}'.`);
|
|
54
|
+
}
|
package/src/commands/retire.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import { rmSync } from "fs";
|
|
2
|
+
import { rmSync, writeFileSync } from "fs";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
-
import { join } from "path";
|
|
4
|
+
import { basename, dirname, join } from "path";
|
|
5
5
|
|
|
6
6
|
import { findAssistantByName, removeAssistantEntry } from "../lib/assistant-config";
|
|
7
7
|
import type { AssistantEntry } from "../lib/assistant-config";
|
|
8
8
|
import { retireInstance as retireAwsInstance } from "../lib/aws";
|
|
9
9
|
import { retireInstance as retireGcpInstance } from "../lib/gcp";
|
|
10
10
|
import { stopProcessByPidFile } from "../lib/process";
|
|
11
|
+
import { getArchivePath, getMetadataPath } from "../lib/retire-archive";
|
|
11
12
|
import { exec } from "../lib/step-runner";
|
|
12
13
|
|
|
13
14
|
function resolveCloud(entry: AssistantEntry): string {
|
|
@@ -32,7 +33,7 @@ function extractHostFromUrl(url: string): string {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
async function retireLocal(): Promise<void> {
|
|
36
|
+
async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
|
|
36
37
|
console.log("\u{1F5D1}\ufe0f Stopping local daemon...\n");
|
|
37
38
|
|
|
38
39
|
const vellumDir = join(homedir(), ".vellum");
|
|
@@ -61,6 +62,18 @@ async function retireLocal(): Promise<void> {
|
|
|
61
62
|
} catch {}
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// Archive ~/.vellum before deleting
|
|
66
|
+
try {
|
|
67
|
+
const archivePath = getArchivePath(name);
|
|
68
|
+
const metadataPath = getMetadataPath(name);
|
|
69
|
+
await exec("tar", ["czf", archivePath, "-C", dirname(vellumDir), basename(vellumDir)]);
|
|
70
|
+
writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
|
|
71
|
+
console.log(`📦 Archived to ${archivePath}`);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.warn(`⚠️ Failed to archive: ${err instanceof Error ? err.message : err}`);
|
|
74
|
+
console.warn("Proceeding with permanent deletion.");
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
rmSync(vellumDir, { recursive: true, force: true });
|
|
65
78
|
|
|
66
79
|
console.log("\u2705 Local instance retired.");
|
|
@@ -142,7 +155,7 @@ export async function retire(): Promise<void> {
|
|
|
142
155
|
}
|
|
143
156
|
await retireAwsInstance(name, region, source);
|
|
144
157
|
} else if (cloud === "local") {
|
|
145
|
-
await retireLocal();
|
|
158
|
+
await retireLocal(name, entry);
|
|
146
159
|
} else if (cloud === "custom") {
|
|
147
160
|
await retireCustom(entry);
|
|
148
161
|
} else {
|
package/src/email/vellum.ts
CHANGED
|
@@ -9,17 +9,10 @@ const DEFAULT_VELLUM_API_URL = "https://api.vellum.ai";
|
|
|
9
9
|
// Types
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
|
-
export interface
|
|
12
|
+
export interface AssistantEmailAddress {
|
|
13
13
|
id: string;
|
|
14
14
|
address: string;
|
|
15
|
-
|
|
16
|
-
createdAt: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface EmailStatus {
|
|
20
|
-
provider: string;
|
|
21
|
-
ok: boolean;
|
|
22
|
-
inboxes: EmailInbox[];
|
|
15
|
+
created_at: string;
|
|
23
16
|
}
|
|
24
17
|
|
|
25
18
|
// ---------------------------------------------------------------------------
|
|
@@ -63,8 +56,10 @@ async function vellumFetch(
|
|
|
63
56
|
export class VellumEmailClient {
|
|
64
57
|
private apiKey: string;
|
|
65
58
|
private baseUrl: string;
|
|
59
|
+
private assistantId: string;
|
|
66
60
|
|
|
67
|
-
constructor(apiKey?: string, baseUrl?: string) {
|
|
61
|
+
constructor(assistantId: string, apiKey?: string, baseUrl?: string) {
|
|
62
|
+
this.assistantId = assistantId;
|
|
68
63
|
const resolvedKey = apiKey ?? process.env.VELLUM_API_KEY;
|
|
69
64
|
if (!resolvedKey) {
|
|
70
65
|
throw new Error(
|
|
@@ -77,27 +72,26 @@ export class VellumEmailClient {
|
|
|
77
72
|
}
|
|
78
73
|
|
|
79
74
|
/** List existing email addresses and check connectivity. */
|
|
80
|
-
async status(): Promise<
|
|
75
|
+
async status(): Promise<AssistantEmailAddress[]> {
|
|
81
76
|
const result = await vellumFetch(
|
|
82
77
|
this.apiKey,
|
|
83
78
|
this.baseUrl,
|
|
84
|
-
|
|
79
|
+
`/v1/assistants/${this.assistantId}/email-addresses/`,
|
|
85
80
|
);
|
|
86
|
-
|
|
87
|
-
return { provider: "vellum", ok: true, inboxes };
|
|
81
|
+
return result as AssistantEmailAddress[];
|
|
88
82
|
}
|
|
89
83
|
|
|
90
84
|
/** Provision a new email address for the given username. */
|
|
91
|
-
async createInbox(username: string): Promise<
|
|
85
|
+
async createInbox(username: string): Promise<AssistantEmailAddress> {
|
|
92
86
|
const result = await vellumFetch(
|
|
93
87
|
this.apiKey,
|
|
94
88
|
this.baseUrl,
|
|
95
|
-
|
|
89
|
+
`/v1/assistants/${this.assistantId}/email-addresses/`,
|
|
96
90
|
{
|
|
97
91
|
method: "POST",
|
|
98
92
|
body: { username },
|
|
99
93
|
},
|
|
100
94
|
);
|
|
101
|
-
return result as
|
|
95
|
+
return result as AssistantEmailAddress;
|
|
102
96
|
}
|
|
103
97
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,9 +6,11 @@ import { dirname, join } from "node:path";
|
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { client } from "./commands/client";
|
|
9
|
+
import { config } from "./commands/config";
|
|
9
10
|
import { email } from "./commands/email";
|
|
10
11
|
import { hatch } from "./commands/hatch";
|
|
11
12
|
import { ps } from "./commands/ps";
|
|
13
|
+
import { recover } from "./commands/recover";
|
|
12
14
|
import { retire } from "./commands/retire";
|
|
13
15
|
import { sleep } from "./commands/sleep";
|
|
14
16
|
import { ssh } from "./commands/ssh";
|
|
@@ -16,9 +18,11 @@ import { wake } from "./commands/wake";
|
|
|
16
18
|
|
|
17
19
|
const commands = {
|
|
18
20
|
client,
|
|
21
|
+
config,
|
|
19
22
|
email,
|
|
20
23
|
hatch,
|
|
21
24
|
ps,
|
|
25
|
+
recover,
|
|
22
26
|
retire,
|
|
23
27
|
sleep,
|
|
24
28
|
ssh,
|
|
@@ -62,9 +66,11 @@ async function main() {
|
|
|
62
66
|
console.log("");
|
|
63
67
|
console.log("Commands:");
|
|
64
68
|
console.log(" client Connect to a hatched assistant");
|
|
69
|
+
console.log(" config Manage configuration");
|
|
65
70
|
console.log(" email Email operations (status, create inbox)");
|
|
66
71
|
console.log(" hatch Create a new assistant instance");
|
|
67
72
|
console.log(" ps List assistants (or processes for a specific assistant)");
|
|
73
|
+
console.log(" recover Restore a previously retired local assistant");
|
|
68
74
|
console.log(" retire Delete an assistant instance");
|
|
69
75
|
console.log(" sleep Stop the daemon process");
|
|
70
76
|
console.log(" ssh SSH into a remote assistant instance");
|
package/src/lib/local.ts
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mkdirSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { basename, join, resolve } from "path";
|
|
4
|
+
|
|
5
|
+
export function getRetiredDir(): string {
|
|
6
|
+
const xdgData =
|
|
7
|
+
process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
|
|
8
|
+
const dir = join(xdgData, "vellum", "retired");
|
|
9
|
+
mkdirSync(dir, { recursive: true });
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Throws if the name contains path separators or traversal segments. */
|
|
14
|
+
export function validateAssistantName(name: string): void {
|
|
15
|
+
if (
|
|
16
|
+
!name ||
|
|
17
|
+
name.includes("/") ||
|
|
18
|
+
name.includes("\\") ||
|
|
19
|
+
name === ".." ||
|
|
20
|
+
name === "."
|
|
21
|
+
) {
|
|
22
|
+
throw new Error(`Invalid assistant name: '${name}'`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function safeName(assistantId: string): string {
|
|
27
|
+
validateAssistantName(assistantId);
|
|
28
|
+
// Canonicalize and verify the result stays inside the retired directory
|
|
29
|
+
const retiredDir = getRetiredDir();
|
|
30
|
+
const candidate = resolve(retiredDir, basename(assistantId));
|
|
31
|
+
if (!candidate.startsWith(retiredDir + "/")) {
|
|
32
|
+
throw new Error(`Invalid assistant name: '${assistantId}'`);
|
|
33
|
+
}
|
|
34
|
+
return basename(assistantId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getArchivePath(assistantId: string): string {
|
|
38
|
+
return join(getRetiredDir(), `${safeName(assistantId)}.tar.gz`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getMetadataPath(assistantId: string): string {
|
|
42
|
+
return join(getRetiredDir(), `${safeName(assistantId)}.json`);
|
|
43
|
+
}
|