@vellumai/cli 0.8.6 → 0.8.7-dev.202606052135.3e62c5a

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 (79) hide show
  1. package/bun.lock +8 -0
  2. package/knip.json +5 -1
  3. package/node_modules/@vellumai/environments/bun.lock +24 -0
  4. package/node_modules/@vellumai/environments/package.json +18 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  6. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  7. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  8. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  9. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  10. package/node_modules/@vellumai/local-mode/package.json +22 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  16. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  18. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  19. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  20. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  21. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  22. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  26. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  27. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  28. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  29. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  30. package/package.json +12 -1
  31. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  32. package/src/__tests__/clean.test.ts +179 -0
  33. package/src/__tests__/client-token.test.ts +87 -0
  34. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  35. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  36. package/src/__tests__/connect-import.test.ts +317 -0
  37. package/src/__tests__/devices.test.ts +272 -0
  38. package/src/__tests__/env-drift.test.ts +32 -44
  39. package/src/__tests__/flags.test.ts +248 -0
  40. package/src/__tests__/guardian-token.test.ts +126 -2
  41. package/src/__tests__/multi-local.test.ts +1 -1
  42. package/src/__tests__/orphan-detection.test.ts +8 -6
  43. package/src/__tests__/pair.test.ts +271 -0
  44. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  45. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  46. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  47. package/src/__tests__/unpair.test.ts +163 -0
  48. package/src/commands/client.ts +511 -11
  49. package/src/commands/connect/import.ts +217 -0
  50. package/src/commands/connect.ts +31 -0
  51. package/src/commands/devices.ts +247 -0
  52. package/src/commands/env.ts +1 -1
  53. package/src/commands/flags.ts +89 -17
  54. package/src/commands/pair.ts +222 -0
  55. package/src/commands/ps.ts +16 -0
  56. package/src/commands/retire.ts +20 -47
  57. package/src/commands/sleep.ts +7 -0
  58. package/src/commands/tunnel.ts +46 -2
  59. package/src/commands/unpair.ts +118 -0
  60. package/src/commands/wake.ts +7 -0
  61. package/src/components/DefaultMainScreen.tsx +100 -14
  62. package/src/index.ts +16 -0
  63. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  64. package/src/lib/assistant-client.ts +58 -37
  65. package/src/lib/assistant-config.ts +15 -3
  66. package/src/lib/cloudflare-tunnel.ts +276 -0
  67. package/src/lib/confirm-action.ts +57 -0
  68. package/src/lib/docker.ts +25 -1
  69. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  70. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  71. package/src/lib/environments/paths.ts +1 -1
  72. package/src/lib/environments/resolve.ts +11 -35
  73. package/src/lib/guardian-token.ts +132 -9
  74. package/src/lib/hatch-local.ts +73 -33
  75. package/src/lib/lifecycle-reporter.ts +31 -0
  76. package/src/lib/local.ts +20 -6
  77. package/src/lib/retire-local.ts +28 -14
  78. package/src/lib/segments-to-plain-text.ts +35 -0
  79. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -0,0 +1,58 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import type { CliInvocation } from "./util";
4
+
5
+ const RETIRE_TIMEOUT_MS = 60_000;
6
+
7
+ export type RetireResult =
8
+ | { ok: true }
9
+ | { ok: false; status: number; error: string };
10
+
11
+ export function runRetire(
12
+ invocation: CliInvocation,
13
+ assistantId: string,
14
+ ): Promise<RetireResult> {
15
+ return new Promise((resolve) => {
16
+ const child = spawn(
17
+ invocation.command,
18
+ [...invocation.baseArgs, "retire", assistantId, "--yes"],
19
+ { stdio: ["ignore", "pipe", "pipe"] },
20
+ );
21
+
22
+ let stdout = "";
23
+ let stderr = "";
24
+ let done = false;
25
+
26
+ const finish = (result: RetireResult) => {
27
+ if (done) return;
28
+ done = true;
29
+ clearTimeout(timeout);
30
+ resolve(result);
31
+ };
32
+
33
+ const timeout = setTimeout(() => {
34
+ child.kill("SIGTERM");
35
+ finish({ ok: false, status: 500, error: "Retire timed out after 60 seconds" });
36
+ }, RETIRE_TIMEOUT_MS);
37
+
38
+ child.stdout.on("data", (data: Buffer) => {
39
+ stdout += data.toString();
40
+ });
41
+
42
+ child.stderr.on("data", (data: Buffer) => {
43
+ stderr += data.toString();
44
+ });
45
+
46
+ child.on("close", (code) => {
47
+ if (code === 0) {
48
+ finish({ ok: true });
49
+ } else {
50
+ finish({ ok: false, status: 500, error: stderr || stdout });
51
+ }
52
+ });
53
+
54
+ child.on("error", (err) => {
55
+ finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
56
+ });
57
+ });
58
+ }
@@ -0,0 +1,102 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+
5
+ const CLI_PACKAGE_NAME = "@vellumai/cli";
6
+
7
+ /**
8
+ * How to invoke the Vellum CLI as a child process: a base command plus the
9
+ * leading arguments that precede the subcommand. Each host resolves its own
10
+ * invocation — the dev hosts run the CLI from source via `bun run <entry>`,
11
+ * a packaged host would point at its bundled runtime — and the shared
12
+ * lifecycle ops (`runHatch`, `runRetire`, guardian-token refresh) append
13
+ * their subcommand args to `baseArgs`.
14
+ */
15
+ export interface CliInvocation {
16
+ command: string;
17
+ baseArgs: string[];
18
+ }
19
+
20
+ const SENSITIVE_FIELDS = [
21
+ "signingKey",
22
+ "bearerToken",
23
+ "guardianBootstrapSecret",
24
+ ] as const;
25
+
26
+ export function stripSensitiveFields(data: Record<string, unknown>): void {
27
+ const assistants = data.assistants;
28
+ if (!Array.isArray(assistants)) return;
29
+ for (const assistant of assistants) {
30
+ if (assistant && typeof assistant === "object") {
31
+ const entry = assistant as Record<string, unknown>;
32
+ for (const field of SENSITIVE_FIELDS) {
33
+ delete entry[field];
34
+ }
35
+ const resources = entry.resources;
36
+ if (resources && typeof resources === "object") {
37
+ for (const field of SENSITIVE_FIELDS) {
38
+ delete (resources as Record<string, unknown>)[field];
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ export function isLoopbackAddr(addr: string): boolean {
46
+ const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
47
+ const normalized = v4Mapped ? v4Mapped[1]! : addr;
48
+ if (normalized.includes(".")) {
49
+ return normalized.startsWith("127.");
50
+ }
51
+ return normalized === "::1";
52
+ }
53
+
54
+ let _resolvedCliPath: string | undefined;
55
+
56
+ /**
57
+ * Resolve the CLI entry point.
58
+ *
59
+ * 1. Source tree — `<baseDir>/cli/src/index.ts` (dev mode in monorepo).
60
+ * 2. Installed package — `require.resolve("@vellumai/cli/package.json")`.
61
+ */
62
+ function resolveCliPath(baseDir: string, importMetaUrl?: string): string {
63
+ if (_resolvedCliPath) return _resolvedCliPath;
64
+
65
+ const sourceTreePath = path.join(baseDir, "cli", "src", "index.ts");
66
+ if (fs.existsSync(sourceTreePath)) {
67
+ _resolvedCliPath = sourceTreePath;
68
+ return _resolvedCliPath;
69
+ }
70
+
71
+ const _require = createRequire(importMetaUrl ?? `file://${baseDir}/`);
72
+ try {
73
+ const pkgPath = _require.resolve(`${CLI_PACKAGE_NAME}/package.json`);
74
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { bin?: Record<string, string> };
75
+ const binEntry = pkg.bin?.["vellum"];
76
+ if (binEntry) {
77
+ const entryPoint = path.resolve(path.dirname(pkgPath), binEntry);
78
+ if (fs.existsSync(entryPoint)) {
79
+ _resolvedCliPath = entryPoint;
80
+ return _resolvedCliPath;
81
+ }
82
+ }
83
+ } catch {
84
+ // Not found in node_modules
85
+ }
86
+
87
+ throw new Error(
88
+ `Vellum CLI not found. Looked for source tree at ${sourceTreePath} and npm package ${CLI_PACKAGE_NAME}.`,
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Build the CLI invocation used by the dev hosts (CLI client server and the
94
+ * web dev-server middleware), which run the CLI from source via Bun:
95
+ * `bun run <cli-entry> <subcommand> …`.
96
+ */
97
+ export function resolveDevCliInvocation(
98
+ baseDir: string,
99
+ importMetaUrl?: string,
100
+ ): CliInvocation {
101
+ return { command: "bun", baseArgs: ["run", resolveCliPath(baseDir, importMetaUrl)] };
102
+ }
@@ -0,0 +1,78 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import type { CliInvocation } from "./util";
4
+
5
+ // `wake` cold-starts a stopped assistant, so it can legitimately run far
6
+ // longer than a teardown like `retire`: the CLI waits up to 60s for the daemon
7
+ // to answer (plus another 60s if it falls back to a source daemon) and up to
8
+ // 30s for the gateway. The wrapper timeout is a safety net for a truly hung
9
+ // process, so it must sit above those documented readiness windows — otherwise
10
+ // a slow-but-succeeding wake gets killed and misreported as a timeout.
11
+ const WAKE_TIMEOUT_MS = 180_000;
12
+
13
+ export type WakeResult =
14
+ | { ok: true }
15
+ | { ok: false; status: number; error: string };
16
+
17
+ /**
18
+ * Start (or restart) a local assistant's daemon and gateway via the CLI's
19
+ * `wake`, which also re-seeds the guardian token from a sibling environment.
20
+ *
21
+ * This is the non-destructive repair primitive: it revives a stopped or
22
+ * mis-seeded local assistant in place without touching its data or identity,
23
+ * the same way the native client re-pairs on a failed connection. Mirrors
24
+ * {@link runRetire}'s never-reject contract so each host wires transport once
25
+ * and surfaces a structured failure rather than a thrown error.
26
+ */
27
+ export function runWake(
28
+ invocation: CliInvocation,
29
+ assistantId: string,
30
+ ): Promise<WakeResult> {
31
+ return new Promise((resolve) => {
32
+ const child = spawn(
33
+ invocation.command,
34
+ [...invocation.baseArgs, "wake", assistantId],
35
+ { stdio: ["ignore", "pipe", "pipe"] },
36
+ );
37
+
38
+ let stdout = "";
39
+ let stderr = "";
40
+ let done = false;
41
+
42
+ const finish = (result: WakeResult) => {
43
+ if (done) return;
44
+ done = true;
45
+ clearTimeout(timeout);
46
+ resolve(result);
47
+ };
48
+
49
+ const timeout = setTimeout(() => {
50
+ child.kill("SIGTERM");
51
+ finish({
52
+ ok: false,
53
+ status: 500,
54
+ error: `Wake timed out after ${WAKE_TIMEOUT_MS / 1000} seconds`,
55
+ });
56
+ }, WAKE_TIMEOUT_MS);
57
+
58
+ child.stdout.on("data", (data: Buffer) => {
59
+ stdout += data.toString();
60
+ });
61
+
62
+ child.stderr.on("data", (data: Buffer) => {
63
+ stderr += data.toString();
64
+ });
65
+
66
+ child.on("close", (code) => {
67
+ if (code === 0) {
68
+ finish({ ok: true });
69
+ } else {
70
+ finish({ ok: false, status: 500, error: stderr || stdout });
71
+ }
72
+ });
73
+
74
+ child.on("error", (err) => {
75
+ finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
76
+ });
77
+ });
78
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "resolveJsonModule": true,
11
+ "noEmit": true,
12
+ "types": ["bun-types"]
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules"]
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.6",
3
+ "version": "0.8.7-dev.202606052135.3e62c5a",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,6 +8,10 @@
8
8
  "./package.json": "./package.json",
9
9
  "./src/components/DefaultMainScreen": "./src/components/DefaultMainScreen.tsx",
10
10
  "./src/lib/constants": "./src/lib/constants.ts",
11
+ "./src/lib/hatch-local": "./src/lib/hatch-local.ts",
12
+ "./src/lib/retire-local": "./src/lib/retire-local.ts",
13
+ "./src/lib/guardian-token": "./src/lib/guardian-token.ts",
14
+ "./src/lib/lifecycle-reporter": "./src/lib/lifecycle-reporter.ts",
11
15
  "./src/commands/*": "./src/commands/*.ts"
12
16
  },
13
17
  "bin": {
@@ -18,18 +22,25 @@
18
22
  "format:check": "prettier --check .",
19
23
  "lint": "eslint",
20
24
  "lint:unused": "knip --include files,dependencies,unlisted",
25
+ "prepack": "node ../scripts/prepack-bundled-deps.mjs",
21
26
  "test": "bun test",
22
27
  "typecheck": "bunx tsc --noEmit"
23
28
  },
24
29
  "author": "Vellum AI",
25
30
  "license": "MIT",
26
31
  "dependencies": {
32
+ "@vellumai/environments": "file:../packages/environments",
33
+ "@vellumai/local-mode": "file:../packages/local-mode",
27
34
  "chalk": "5.6.2",
28
35
  "ink": "6.8.0",
29
36
  "nanoid": "5.1.7",
30
37
  "react": "19.2.4",
31
38
  "react-devtools-core": "6.1.5"
32
39
  },
40
+ "bundledDependencies": [
41
+ "@vellumai/environments",
42
+ "@vellumai/local-mode"
43
+ ],
33
44
  "devDependencies": {
34
45
  "@types/bun": "1.3.11",
35
46
  "@types/react": "19.2.14",
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Tests for AssistantClient's reactive 401 -> refresh -> retry: a paired/local
3
+ * guardian access token that 401s is refreshed once via the stored refresh
4
+ * credential and the request retried. Self-gating (no refresh token => no
5
+ * retry) and never applied to the platform session-auth path.
6
+ */
7
+ import {
8
+ afterAll,
9
+ afterEach,
10
+ beforeEach,
11
+ describe,
12
+ expect,
13
+ test,
14
+ } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ const testDir = mkdtempSync(join(tmpdir(), "client-refresh-test-"));
20
+ const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
21
+ const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
22
+ const ORIGINAL_FETCH = globalThis.fetch;
23
+
24
+ import { AssistantClient } from "../lib/assistant-client.js";
25
+ import { saveAssistantEntry } from "../lib/assistant-config.js";
26
+ import { loadGuardianToken, saveGuardianToken } from "../lib/guardian-token.js";
27
+
28
+ const RUNTIME = "http://10.0.0.9:7830";
29
+ const FUTURE = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
30
+
31
+ function seedPaired(refreshToken: string): void {
32
+ saveAssistantEntry({
33
+ assistantId: "px",
34
+ name: "Paired",
35
+ runtimeUrl: RUNTIME,
36
+ cloud: "paired",
37
+ paired: true,
38
+ species: "vellum",
39
+ });
40
+ saveGuardianToken("px", {
41
+ guardianPrincipalId: "imported",
42
+ accessToken: "old-acc",
43
+ accessTokenExpiresAt: FUTURE,
44
+ refreshToken,
45
+ refreshTokenExpiresAt: refreshToken ? FUTURE : 0,
46
+ refreshAfter: "",
47
+ isNew: false,
48
+ deviceId: "dev",
49
+ leasedAt: new Date().toISOString(),
50
+ });
51
+ }
52
+
53
+ interface Call {
54
+ url: string;
55
+ headers: Record<string, string>;
56
+ }
57
+
58
+ /** Replace global fetch with a URL-routed stub; returns the call log. */
59
+ function stubFetch(handler: (url: string, calls: Call[]) => Response): Call[] {
60
+ const calls: Call[] = [];
61
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
62
+ const url = typeof input === "string" ? input : String(input);
63
+ calls.push({
64
+ url,
65
+ headers: (init?.headers ?? {}) as Record<string, string>,
66
+ });
67
+ return handler(url, calls);
68
+ }) as typeof fetch;
69
+ return calls;
70
+ }
71
+
72
+ const isRefresh = (url: string) => url.includes("/v1/guardian/refresh");
73
+
74
+ function refreshResponse(): Response {
75
+ return new Response(
76
+ JSON.stringify({
77
+ accessToken: "new-acc",
78
+ refreshToken: "new-ref",
79
+ accessTokenExpiresAt: FUTURE,
80
+ refreshTokenExpiresAt: FUTURE,
81
+ refreshAfter: "",
82
+ }),
83
+ { status: 200, headers: { "content-type": "application/json" } },
84
+ );
85
+ }
86
+
87
+ describe("AssistantClient 401 -> refresh -> retry", () => {
88
+ beforeEach(() => {
89
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
90
+ process.env.XDG_CONFIG_HOME = testDir;
91
+ });
92
+
93
+ afterEach(() => {
94
+ globalThis.fetch = ORIGINAL_FETCH;
95
+ if (ORIGINAL_LOCKFILE_DIR === undefined)
96
+ delete process.env.VELLUM_LOCKFILE_DIR;
97
+ else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
98
+ if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
99
+ else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
100
+ });
101
+
102
+ afterAll(() => {
103
+ rmSync(testDir, { recursive: true, force: true });
104
+ });
105
+
106
+ test("refreshes and retries once on 401, persisting the new token", async () => {
107
+ seedPaired("refresh-tok");
108
+ let assistantAttempts = 0;
109
+ const calls = stubFetch((url) => {
110
+ if (isRefresh(url)) return refreshResponse();
111
+ assistantAttempts++;
112
+ return new Response("", { status: assistantAttempts === 1 ? 401 : 200 });
113
+ });
114
+
115
+ const client = new AssistantClient({ assistantId: "px" });
116
+ const res = await client.get("/messages/");
117
+
118
+ expect(res.status).toBe(200);
119
+ expect(assistantAttempts).toBe(2); // original + one retry
120
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(1);
121
+ // The retry carried the refreshed bearer token.
122
+ const assistantCalls = calls.filter((c) => !isRefresh(c.url));
123
+ expect(assistantCalls[1].headers["Authorization"]).toBe("Bearer new-acc");
124
+ // refreshGuardianToken persisted the rotated token.
125
+ expect(loadGuardianToken("px")?.accessToken).toBe("new-acc");
126
+ });
127
+
128
+ test("does not retry when there is no stored refresh token", async () => {
129
+ seedPaired(""); // access-only
130
+ let assistantAttempts = 0;
131
+ const calls = stubFetch((url) => {
132
+ if (isRefresh(url)) return refreshResponse();
133
+ assistantAttempts++;
134
+ return new Response("", { status: 401 });
135
+ });
136
+
137
+ const client = new AssistantClient({ assistantId: "px" });
138
+ const res = await client.get("/messages/");
139
+
140
+ expect(res.status).toBe(401);
141
+ expect(assistantAttempts).toBe(1); // no retry
142
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(0);
143
+ });
144
+
145
+ test("never refreshes on the platform session-auth path", async () => {
146
+ seedPaired("refresh-tok"); // entry must exist; session auth ignores it
147
+ let assistantAttempts = 0;
148
+ const calls = stubFetch((url) => {
149
+ if (isRefresh(url)) return refreshResponse();
150
+ assistantAttempts++;
151
+ return new Response("", { status: 401 });
152
+ });
153
+
154
+ const client = new AssistantClient({
155
+ assistantId: "px",
156
+ sessionToken: "sess-tok",
157
+ orgId: "org-1",
158
+ });
159
+ const res = await client.get("/messages/");
160
+
161
+ expect(res.status).toBe(401);
162
+ expect(assistantAttempts).toBe(1);
163
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(0);
164
+ });
165
+
166
+ test("retries at most once (second 401 is not refreshed again)", async () => {
167
+ seedPaired("refresh-tok");
168
+ let assistantAttempts = 0;
169
+ const calls = stubFetch((url) => {
170
+ if (isRefresh(url)) return refreshResponse();
171
+ assistantAttempts++;
172
+ return new Response("", { status: 401 }); // always 401
173
+ });
174
+
175
+ const client = new AssistantClient({ assistantId: "px" });
176
+ const res = await client.get("/messages/");
177
+
178
+ expect(res.status).toBe(401);
179
+ expect(assistantAttempts).toBe(2); // original + one retry, no more
180
+ expect(calls.filter((c) => isRefresh(c.url))).toHaveLength(1);
181
+ });
182
+ });
@@ -0,0 +1,179 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ mock,
9
+ spyOn,
10
+ test,
11
+ } from "bun:test";
12
+
13
+ import type { OrphanedProcess } from "../lib/orphan-detection.js";
14
+
15
+ // ── Module mocks (must be set up before importing the command) ───────────────
16
+
17
+ const detectOrphansMock = mock(async (): Promise<OrphanedProcess[]> => []);
18
+ const stopProcessMock = mock(
19
+ async (_pid: number, _label: string): Promise<boolean> => true,
20
+ );
21
+
22
+ beforeAll(() => {
23
+ mock.module("../lib/orphan-detection.js", () => ({
24
+ detectOrphanedProcesses: detectOrphansMock,
25
+ }));
26
+ mock.module("../lib/process.js", () => ({
27
+ stopProcess: stopProcessMock,
28
+ }));
29
+ });
30
+
31
+ import { clean } from "../commands/clean.js";
32
+
33
+ // ── Helpers ───────────────────────────────────────────────────────────────────
34
+
35
+ function makeOrphan(
36
+ name: string,
37
+ pid: string,
38
+ source = "process table",
39
+ ): OrphanedProcess {
40
+ return { name, pid, source };
41
+ }
42
+
43
+ const originalArgv = [...process.argv];
44
+
45
+ let consoleLogSpy: ReturnType<typeof spyOn>;
46
+ let consoleErrorSpy: ReturnType<typeof spyOn>;
47
+ let exitSpy: ReturnType<typeof spyOn>;
48
+
49
+ beforeEach(() => {
50
+ consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
51
+ consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
52
+ exitSpy = spyOn(process, "exit").mockImplementation((_code?: number) => {
53
+ throw new Error(`process.exit(${_code})`);
54
+ });
55
+ detectOrphansMock.mockClear();
56
+ stopProcessMock.mockClear();
57
+ });
58
+
59
+ afterEach(() => {
60
+ process.argv = [...originalArgv];
61
+ consoleLogSpy.mockRestore();
62
+ consoleErrorSpy.mockRestore();
63
+ exitSpy.mockRestore();
64
+ });
65
+
66
+ afterAll(() => {
67
+ process.argv = [...originalArgv];
68
+ });
69
+
70
+ // ── Tests ─────────────────────────────────────────────────────────────────────
71
+
72
+ describe("vellum clean --help", () => {
73
+ test("prints usage and exits 0", async () => {
74
+ process.argv = ["bun", "vellum", "clean", "--help"];
75
+ await expect(clean()).rejects.toThrow("process.exit(0)");
76
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
77
+ expect(output).toContain("Usage: vellum clean");
78
+ expect(output).toContain("orphaned");
79
+ });
80
+
81
+ test("-h is accepted as an alias for --help", async () => {
82
+ process.argv = ["bun", "vellum", "clean", "-h"];
83
+ await expect(clean()).rejects.toThrow("process.exit(0)");
84
+ });
85
+ });
86
+
87
+ describe("vellum clean — no orphans", () => {
88
+ test("prints nothing-to-do message when no orphans are found", async () => {
89
+ detectOrphansMock.mockResolvedValueOnce([]);
90
+ process.argv = ["bun", "vellum", "clean"];
91
+ await clean();
92
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
93
+ expect(output).toContain("No orphaned processes found.");
94
+ expect(stopProcessMock).not.toHaveBeenCalled();
95
+ });
96
+ });
97
+
98
+ describe("vellum clean — single orphan", () => {
99
+ test("kills the orphan and prints singular 'process'", async () => {
100
+ detectOrphansMock.mockResolvedValueOnce([makeOrphan("assistant", "12345")]);
101
+ stopProcessMock.mockResolvedValueOnce(true);
102
+ process.argv = ["bun", "vellum", "clean"];
103
+ await clean();
104
+
105
+ expect(stopProcessMock).toHaveBeenCalledTimes(1);
106
+ expect(stopProcessMock).toHaveBeenCalledWith(
107
+ 12345,
108
+ "assistant (PID 12345)",
109
+ );
110
+
111
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
112
+ expect(output).toContain("Found 1 orphaned process");
113
+ expect(output).not.toContain("processes");
114
+ expect(output).toContain("Cleaned up 1 process.");
115
+ expect(output).not.toContain("processes.");
116
+ });
117
+
118
+ test("reports 0 cleaned when stopProcess returns false", async () => {
119
+ detectOrphansMock.mockResolvedValueOnce([makeOrphan("gateway", "99999")]);
120
+ stopProcessMock.mockResolvedValueOnce(false);
121
+ process.argv = ["bun", "vellum", "clean"];
122
+ await clean();
123
+
124
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
125
+ expect(output).toContain("Cleaned up 0 process");
126
+ });
127
+ });
128
+
129
+ describe("vellum clean — multiple orphans", () => {
130
+ test("uses plural 'processes' with multiple orphans", async () => {
131
+ detectOrphansMock.mockResolvedValueOnce([
132
+ makeOrphan("assistant", "1001"),
133
+ makeOrphan("gateway", "1002"),
134
+ makeOrphan("qdrant", "1003"),
135
+ ]);
136
+ stopProcessMock.mockResolvedValue(true);
137
+ process.argv = ["bun", "vellum", "clean"];
138
+ await clean();
139
+
140
+ expect(stopProcessMock).toHaveBeenCalledTimes(3);
141
+
142
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
143
+ expect(output).toContain("Found 3 orphaned processes");
144
+ expect(output).toContain("Cleaned up 3 processes.");
145
+ });
146
+
147
+ test("counts only successfully stopped processes in the total", async () => {
148
+ detectOrphansMock.mockResolvedValueOnce([
149
+ makeOrphan("assistant", "2001"),
150
+ makeOrphan("qdrant", "2002"),
151
+ makeOrphan("gateway", "2003"),
152
+ ]);
153
+ // Only the first and third succeed
154
+ stopProcessMock
155
+ .mockResolvedValueOnce(true)
156
+ .mockResolvedValueOnce(false)
157
+ .mockResolvedValueOnce(true);
158
+ process.argv = ["bun", "vellum", "clean"];
159
+ await clean();
160
+
161
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
162
+ expect(output).toContain("Found 3 orphaned processes");
163
+ expect(output).toContain("Cleaned up 2 processes.");
164
+ });
165
+
166
+ test("passes the correct PID and label to stopProcess for each orphan", async () => {
167
+ detectOrphansMock.mockResolvedValueOnce([
168
+ makeOrphan("assistant", "3001"),
169
+ makeOrphan("gateway", "3002"),
170
+ ]);
171
+ stopProcessMock.mockResolvedValue(true);
172
+ process.argv = ["bun", "vellum", "clean"];
173
+ await clean();
174
+
175
+ const calls = stopProcessMock.mock.calls as [number, string][];
176
+ expect(calls[0]).toEqual([3001, "assistant (PID 3001)"]);
177
+ expect(calls[1]).toEqual([3002, "gateway (PID 3002)"]);
178
+ });
179
+ });