@vellumai/cli 0.8.12 → 0.9.0-staging.1
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/bun.lock +49 -56
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
- package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
- package/package.json +3 -3
- package/src/__tests__/assistant-config.test.ts +1 -2
- package/src/__tests__/device-id.test.ts +6 -14
- package/src/__tests__/helpers/os-mock.ts +27 -0
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/multi-local.test.ts +2 -10
- package/src/__tests__/nginx-ingress-command.test.ts +69 -0
- package/src/__tests__/nginx-ingress.test.ts +401 -0
- package/src/__tests__/sleep.test.ts +4 -0
- package/src/__tests__/teleport.test.ts +6 -9
- package/src/__tests__/tunnel.test.ts +164 -0
- package/src/__tests__/wake.test.ts +15 -4
- package/src/__tests__/workos-pkce.test.ts +314 -0
- package/src/commands/flags.ts +1 -22
- package/src/commands/hatch.ts +90 -9
- package/src/commands/login.ts +123 -59
- package/src/commands/nginx-ingress.ts +291 -0
- package/src/commands/rollback.ts +0 -6
- package/src/commands/sleep.ts +17 -0
- package/src/commands/teleport.ts +23 -36
- package/src/commands/tunnel.ts +69 -11
- package/src/commands/upgrade.ts +0 -2
- package/src/commands/wake.ts +7 -5
- package/src/commands/workflows.ts +301 -0
- package/src/index.ts +8 -0
- package/src/lib/arg-utils.ts +48 -0
- package/src/lib/assistant-client.ts +2 -0
- package/src/lib/assistant-config.ts +0 -7
- package/src/lib/cloudflare-tunnel.ts +15 -2
- package/src/lib/docker.ts +103 -49
- package/src/lib/feature-flags.test.ts +157 -0
- package/src/lib/feature-flags.ts +38 -0
- package/src/lib/hatch-local.ts +0 -1
- package/src/lib/local.ts +5 -0
- package/src/lib/nginx-ingress.ts +574 -0
- package/src/lib/ngrok.ts +26 -4
- package/src/lib/platform-client.ts +0 -1
- package/src/lib/retire-local.ts +5 -0
- package/src/lib/statefulset.ts +73 -21
- package/src/lib/sync-cloud-assistants.ts +4 -17
- package/src/lib/upgrade-lifecycle.ts +1 -2
- package/src/lib/workos-pkce.ts +160 -0
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
export interface LocalAssistantResources {
|
|
34
|
+
instanceDir?: string;
|
|
34
35
|
gatewayPort: number;
|
|
35
36
|
daemonPort: number;
|
|
36
37
|
}
|
|
@@ -42,8 +43,6 @@ export interface LockfileAssistant {
|
|
|
42
43
|
runtimeUrl?: string;
|
|
43
44
|
species?: string;
|
|
44
45
|
hatchedAt?: string;
|
|
45
|
-
/** Installed release version (no `v` prefix), written by the CLI at hatch/upgrade. */
|
|
46
|
-
version?: string;
|
|
47
46
|
/** Owning org for platform assistants; absent for local ones. */
|
|
48
47
|
organizationId?: string;
|
|
49
48
|
resources?: LocalAssistantResources;
|
|
@@ -71,7 +70,13 @@ function parseResources(value: unknown): LocalAssistantResources | undefined {
|
|
|
71
70
|
if (!isRecord(value)) return undefined;
|
|
72
71
|
if (typeof value.gatewayPort !== "number") return undefined;
|
|
73
72
|
if (typeof value.daemonPort !== "number") return undefined;
|
|
74
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
...(typeof value.instanceDir === "string"
|
|
75
|
+
? { instanceDir: value.instanceDir }
|
|
76
|
+
: {}),
|
|
77
|
+
gatewayPort: value.gatewayPort,
|
|
78
|
+
daemonPort: value.daemonPort,
|
|
79
|
+
};
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
/**
|
|
@@ -91,7 +96,6 @@ function parseAssistant(value: unknown): LockfileAssistant | null {
|
|
|
91
96
|
if (typeof value.runtimeUrl === "string") assistant.runtimeUrl = value.runtimeUrl;
|
|
92
97
|
if (typeof value.species === "string") assistant.species = value.species;
|
|
93
98
|
if (typeof value.hatchedAt === "string") assistant.hatchedAt = value.hatchedAt;
|
|
94
|
-
if (typeof value.version === "string") assistant.version = value.version;
|
|
95
99
|
if (typeof value.organizationId === "string") assistant.organizationId = value.organizationId;
|
|
96
100
|
const resources = parseResources(value.resources);
|
|
97
101
|
if (resources) assistant.resources = resources;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import type { CliInvocation } from "./util";
|
|
4
|
+
|
|
5
|
+
// The CLI's `sleep` command uses a 120s SIGKILL ceiling for the assistant
|
|
6
|
+
// daemon (WAL checkpoint can be slow on large databases) plus 7s for the
|
|
7
|
+
// gateway drain window. The wrapper timeout must sit above that total so a
|
|
8
|
+
// slow-but-succeeding sleep isn't killed and misreported as a timeout.
|
|
9
|
+
const SLEEP_TIMEOUT_MS = 150_000;
|
|
10
|
+
|
|
11
|
+
export type SleepResult =
|
|
12
|
+
| { ok: true }
|
|
13
|
+
| { ok: false; status: number; error: string };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stop a local assistant's daemon and gateway via the CLI's `sleep --force`.
|
|
17
|
+
*
|
|
18
|
+
* Uses `--force` to bypass the active-call-lease guard — the restart flow
|
|
19
|
+
* immediately follows with a `wake`, so the brief interruption is expected
|
|
20
|
+
* and user-confirmed at the UI level.
|
|
21
|
+
*
|
|
22
|
+
* Mirrors {@link runRetire}'s never-reject contract so each host wires
|
|
23
|
+
* transport once and surfaces a structured failure rather than a thrown error.
|
|
24
|
+
*/
|
|
25
|
+
export function runSleep(
|
|
26
|
+
invocation: CliInvocation,
|
|
27
|
+
assistantId: string,
|
|
28
|
+
): Promise<SleepResult> {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const child = spawn(
|
|
31
|
+
invocation.command,
|
|
32
|
+
[...invocation.baseArgs, "sleep", assistantId, "--force"],
|
|
33
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
let stdout = "";
|
|
37
|
+
let stderr = "";
|
|
38
|
+
let done = false;
|
|
39
|
+
|
|
40
|
+
const finish = (result: SleepResult) => {
|
|
41
|
+
if (done) return;
|
|
42
|
+
done = true;
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
resolve(result);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const timeout = setTimeout(() => {
|
|
48
|
+
child.kill("SIGTERM");
|
|
49
|
+
finish({
|
|
50
|
+
ok: false,
|
|
51
|
+
status: 500,
|
|
52
|
+
error: `Sleep timed out after ${SLEEP_TIMEOUT_MS / 1000} seconds`,
|
|
53
|
+
});
|
|
54
|
+
}, SLEEP_TIMEOUT_MS);
|
|
55
|
+
|
|
56
|
+
child.stdout.on("data", (data: Buffer) => {
|
|
57
|
+
stdout += data.toString();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
child.stderr.on("data", (data: Buffer) => {
|
|
61
|
+
stderr += data.toString();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
child.on("close", (code) => {
|
|
65
|
+
if (code === 0) {
|
|
66
|
+
finish({ ok: true });
|
|
67
|
+
} else {
|
|
68
|
+
finish({ ok: false, status: 500, error: stderr || stdout });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
child.on("error", (err) => {
|
|
73
|
+
finish({
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 500,
|
|
76
|
+
error: `Failed to spawn CLI: ${err.message}`,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { SEEDS } from "@vellumai/environments";
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
LockfileAssistant,
|
|
10
|
+
} from "./lockfile-contract";
|
|
11
|
+
import { getLockfileData } from "./lockfile";
|
|
12
|
+
|
|
13
|
+
const HEALTH_TIMEOUT_MS = 1_500;
|
|
14
|
+
const STARTING_GRACE_MS = 60_000;
|
|
15
|
+
const PRODUCTION_ENVIRONMENT_NAME = "production";
|
|
16
|
+
const DEFAULT_PORTS = {
|
|
17
|
+
daemon: 7821,
|
|
18
|
+
gateway: 7830,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type LocalAssistantRuntimeState =
|
|
22
|
+
| "healthy"
|
|
23
|
+
| "sleeping"
|
|
24
|
+
| "starting"
|
|
25
|
+
| "crashed"
|
|
26
|
+
| "unknown";
|
|
27
|
+
|
|
28
|
+
export type LocalAssistantStatusResult =
|
|
29
|
+
| {
|
|
30
|
+
ok: true;
|
|
31
|
+
state: LocalAssistantRuntimeState;
|
|
32
|
+
detail?: string;
|
|
33
|
+
pid?: number;
|
|
34
|
+
}
|
|
35
|
+
| { ok: false; status: number; error: string };
|
|
36
|
+
|
|
37
|
+
type PidState =
|
|
38
|
+
| { state: "missing" }
|
|
39
|
+
| { state: "starting"; updatedAtMs: number }
|
|
40
|
+
| { state: "alive"; pid: number; updatedAtMs: number }
|
|
41
|
+
| { state: "dead"; pid: number; updatedAtMs: number }
|
|
42
|
+
| { state: "invalid"; value: string; updatedAtMs: number };
|
|
43
|
+
|
|
44
|
+
interface StatusResources {
|
|
45
|
+
instanceDir: string;
|
|
46
|
+
gatewayPort: number;
|
|
47
|
+
daemonPort: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getDaemonPidPath(instanceDir: string): string {
|
|
51
|
+
return path.join(instanceDir, ".vellum", "workspace", "vellum.pid");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getGatewayPidPath(instanceDir: string): string {
|
|
55
|
+
return path.join(instanceDir, ".vellum", "gateway.pid");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readPidState(pidFile: string): PidState {
|
|
59
|
+
if (!existsSync(pidFile)) return { state: "missing" };
|
|
60
|
+
|
|
61
|
+
const updatedAtMs = statSync(pidFile).mtimeMs;
|
|
62
|
+
const value = readFileSync(pidFile, "utf-8").trim();
|
|
63
|
+
if (!value) return { state: "missing" };
|
|
64
|
+
if (value === "starting") return { state: "starting", updatedAtMs };
|
|
65
|
+
|
|
66
|
+
const pid = Number(value);
|
|
67
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
68
|
+
return { state: "invalid", value, updatedAtMs };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
process.kill(pid, 0);
|
|
73
|
+
return { state: "alive", pid, updatedAtMs };
|
|
74
|
+
} catch {
|
|
75
|
+
return { state: "dead", pid, updatedAtMs };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isFreshPidState(
|
|
80
|
+
pidState: PidState,
|
|
81
|
+
observedAtMs: number,
|
|
82
|
+
): boolean {
|
|
83
|
+
return (
|
|
84
|
+
"updatedAtMs" in pidState &&
|
|
85
|
+
observedAtMs - pidState.updatedAtMs <= STARTING_GRACE_MS
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function httpHealthCheck(port: number): Promise<boolean> {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const req = http.get(
|
|
92
|
+
{
|
|
93
|
+
hostname: "127.0.0.1",
|
|
94
|
+
port,
|
|
95
|
+
path: "/healthz",
|
|
96
|
+
timeout: HEALTH_TIMEOUT_MS,
|
|
97
|
+
},
|
|
98
|
+
(res) => {
|
|
99
|
+
const chunks: Buffer[] = [];
|
|
100
|
+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
101
|
+
res.on("end", () => {
|
|
102
|
+
if (res.statusCode !== 200) {
|
|
103
|
+
resolve(false);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const body = JSON.parse(Buffer.concat(chunks).toString()) as {
|
|
109
|
+
status?: string;
|
|
110
|
+
};
|
|
111
|
+
resolve(
|
|
112
|
+
body.status === undefined ||
|
|
113
|
+
body.status === "healthy" ||
|
|
114
|
+
body.status === "ok",
|
|
115
|
+
);
|
|
116
|
+
} catch {
|
|
117
|
+
resolve(true);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
req.on("timeout", () => {
|
|
124
|
+
req.destroy();
|
|
125
|
+
resolve(false);
|
|
126
|
+
});
|
|
127
|
+
req.on("error", () => resolve(false));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function localOnlyEntry(
|
|
132
|
+
entry: LockfileAssistant | undefined,
|
|
133
|
+
): LockfileAssistant | null {
|
|
134
|
+
if (!entry || (entry.cloud != null && entry.cloud !== "local")) return null;
|
|
135
|
+
return entry;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
139
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parsePortFromUrl(url: unknown): number | undefined {
|
|
143
|
+
if (typeof url !== "string") return undefined;
|
|
144
|
+
try {
|
|
145
|
+
const parsed = new URL(url);
|
|
146
|
+
const port = Number(parsed.port);
|
|
147
|
+
return Number.isInteger(port) && port > 0 ? port : undefined;
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function defaultPorts(env: Record<string, string | undefined>): {
|
|
154
|
+
daemon: number;
|
|
155
|
+
gateway: number;
|
|
156
|
+
} {
|
|
157
|
+
const envName = env.VELLUM_ENVIRONMENT?.trim() || PRODUCTION_ENVIRONMENT_NAME;
|
|
158
|
+
const seed = SEEDS[envName] ?? SEEDS[PRODUCTION_ENVIRONMENT_NAME];
|
|
159
|
+
return {
|
|
160
|
+
daemon: seed?.portsOverride?.daemon ?? DEFAULT_PORTS.daemon,
|
|
161
|
+
gateway: seed?.portsOverride?.gateway ?? DEFAULT_PORTS.gateway,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function defaultInstanceDir(
|
|
166
|
+
env: Record<string, string | undefined>,
|
|
167
|
+
assistantId: string,
|
|
168
|
+
): string {
|
|
169
|
+
const envName = env.VELLUM_ENVIRONMENT?.trim() || PRODUCTION_ENVIRONMENT_NAME;
|
|
170
|
+
const xdgDataHome =
|
|
171
|
+
env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share");
|
|
172
|
+
const dataRoot =
|
|
173
|
+
envName === PRODUCTION_ENVIRONMENT_NAME ? "vellum" : `vellum-${envName}`;
|
|
174
|
+
return path.join(xdgDataHome, dataRoot, "assistants", assistantId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function firstString(...values: unknown[]): string | undefined {
|
|
178
|
+
for (const value of values) {
|
|
179
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function firstNumber(...values: unknown[]): number | undefined {
|
|
185
|
+
for (const value of values) {
|
|
186
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function findRawAssistant(
|
|
192
|
+
lockfilePaths: string[],
|
|
193
|
+
assistantId: string,
|
|
194
|
+
): Record<string, unknown> | null {
|
|
195
|
+
for (const candidate of lockfilePaths) {
|
|
196
|
+
let data: unknown;
|
|
197
|
+
try {
|
|
198
|
+
data = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
199
|
+
} catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!isRecord(data) || !Array.isArray(data.assistants)) return null;
|
|
203
|
+
const entry = data.assistants.find(
|
|
204
|
+
(assistant) =>
|
|
205
|
+
isRecord(assistant) && assistant.assistantId === assistantId,
|
|
206
|
+
);
|
|
207
|
+
return isRecord(entry) ? entry : null;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resolveStatusResources(
|
|
213
|
+
entry: LockfileAssistant,
|
|
214
|
+
rawEntry: Record<string, unknown> | null,
|
|
215
|
+
env: Record<string, string | undefined>,
|
|
216
|
+
): StatusResources {
|
|
217
|
+
const rawResources = isRecord(rawEntry?.resources)
|
|
218
|
+
? rawEntry.resources
|
|
219
|
+
: undefined;
|
|
220
|
+
const ports = defaultPorts(env);
|
|
221
|
+
const instanceDir =
|
|
222
|
+
firstString(
|
|
223
|
+
entry.resources?.instanceDir,
|
|
224
|
+
rawResources?.instanceDir,
|
|
225
|
+
rawEntry?.baseDataDir,
|
|
226
|
+
) ?? defaultInstanceDir(env, entry.assistantId);
|
|
227
|
+
return {
|
|
228
|
+
instanceDir,
|
|
229
|
+
daemonPort:
|
|
230
|
+
firstNumber(entry.resources?.daemonPort, rawResources?.daemonPort) ??
|
|
231
|
+
ports.daemon,
|
|
232
|
+
gatewayPort:
|
|
233
|
+
firstNumber(entry.resources?.gatewayPort, rawResources?.gatewayPort) ??
|
|
234
|
+
parsePortFromUrl(rawEntry?.localUrl) ??
|
|
235
|
+
parsePortFromUrl(rawEntry?.runtimeUrl ?? entry.runtimeUrl) ??
|
|
236
|
+
ports.gateway,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function runtimeStatusForEntry(
|
|
241
|
+
entry: LockfileAssistant,
|
|
242
|
+
rawEntry: Record<string, unknown> | null,
|
|
243
|
+
env: Record<string, string | undefined>,
|
|
244
|
+
): Promise<LocalAssistantStatusResult> {
|
|
245
|
+
const resources = resolveStatusResources(entry, rawEntry, env);
|
|
246
|
+
const observedAtMs = Date.now();
|
|
247
|
+
|
|
248
|
+
const assistantPid = readPidState(getDaemonPidPath(resources.instanceDir));
|
|
249
|
+
if (assistantPid.state === "missing") {
|
|
250
|
+
return { ok: true, state: "sleeping" };
|
|
251
|
+
}
|
|
252
|
+
if (assistantPid.state === "starting") {
|
|
253
|
+
return { ok: true, state: "starting" };
|
|
254
|
+
}
|
|
255
|
+
if (assistantPid.state === "dead") {
|
|
256
|
+
return { ok: true, state: "sleeping", pid: assistantPid.pid };
|
|
257
|
+
}
|
|
258
|
+
if (assistantPid.state === "invalid") {
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
state: "crashed",
|
|
262
|
+
detail: "assistant PID file is invalid",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const assistantHealthy = await httpHealthCheck(resources.daemonPort);
|
|
267
|
+
if (!assistantHealthy) {
|
|
268
|
+
if (isFreshPidState(assistantPid, observedAtMs)) {
|
|
269
|
+
return { ok: true, state: "starting", pid: assistantPid.pid };
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
ok: true,
|
|
273
|
+
state: "crashed",
|
|
274
|
+
pid: assistantPid.pid,
|
|
275
|
+
detail: "assistant process is not responding",
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const gatewayPid = readPidState(getGatewayPidPath(resources.instanceDir));
|
|
280
|
+
if (gatewayPid.state === "starting") {
|
|
281
|
+
return { ok: true, state: "starting", pid: assistantPid.pid };
|
|
282
|
+
}
|
|
283
|
+
if (gatewayPid.state !== "alive") {
|
|
284
|
+
if (
|
|
285
|
+
isFreshPidState(assistantPid, observedAtMs) ||
|
|
286
|
+
isFreshPidState(gatewayPid, observedAtMs)
|
|
287
|
+
) {
|
|
288
|
+
return { ok: true, state: "starting", pid: assistantPid.pid };
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
ok: true,
|
|
292
|
+
state: "crashed",
|
|
293
|
+
pid: assistantPid.pid,
|
|
294
|
+
detail: "gateway process is not running",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const gatewayHealthy = await httpHealthCheck(resources.gatewayPort);
|
|
299
|
+
if (!gatewayHealthy) {
|
|
300
|
+
if (isFreshPidState(gatewayPid, observedAtMs)) {
|
|
301
|
+
return { ok: true, state: "starting", pid: gatewayPid.pid };
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
state: "crashed",
|
|
306
|
+
pid: gatewayPid.pid,
|
|
307
|
+
detail: "gateway process is not responding",
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { ok: true, state: "healthy", pid: assistantPid.pid };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function getLocalAssistantStatus(
|
|
315
|
+
lockfilePaths: string[],
|
|
316
|
+
assistantId: string,
|
|
317
|
+
env: Record<string, string | undefined> = process.env,
|
|
318
|
+
): Promise<LocalAssistantStatusResult> {
|
|
319
|
+
const result = getLockfileData(lockfilePaths);
|
|
320
|
+
if (!result.ok) {
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
status: result.status,
|
|
324
|
+
error: result.error ?? "Failed to read lockfile",
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const entry = localOnlyEntry(
|
|
329
|
+
result.data.assistants.find(
|
|
330
|
+
(assistant) => assistant.assistantId === assistantId,
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
if (!entry) {
|
|
334
|
+
return { ok: false, status: 404, error: "Local assistant not found" };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return runtimeStatusForEntry(
|
|
338
|
+
entry,
|
|
339
|
+
findRawAssistant(lockfilePaths, assistantId),
|
|
340
|
+
env,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
@@ -14,6 +14,11 @@ export type WakeResult =
|
|
|
14
14
|
| { ok: true }
|
|
15
15
|
| { ok: false; status: number; error: string };
|
|
16
16
|
|
|
17
|
+
export interface WakeOptions {
|
|
18
|
+
/** Pass --repair-guardian to re-provision a missing/expired guardian token. Revokes the assistant's other device-bound tokens, so callers must gate this behind explicit user confirmation. */
|
|
19
|
+
repairGuardian?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
/**
|
|
18
23
|
* Start (or restart) a local assistant's daemon and gateway via the CLI's
|
|
19
24
|
* `wake`, which also re-seeds the guardian token from a sibling environment.
|
|
@@ -27,11 +32,17 @@ export type WakeResult =
|
|
|
27
32
|
export function runWake(
|
|
28
33
|
invocation: CliInvocation,
|
|
29
34
|
assistantId: string,
|
|
35
|
+
options?: WakeOptions,
|
|
30
36
|
): Promise<WakeResult> {
|
|
31
37
|
return new Promise((resolve) => {
|
|
32
38
|
const child = spawn(
|
|
33
39
|
invocation.command,
|
|
34
|
-
[
|
|
40
|
+
[
|
|
41
|
+
...invocation.baseArgs,
|
|
42
|
+
"wake",
|
|
43
|
+
assistantId,
|
|
44
|
+
...(options?.repairGuardian ? ["--repair-guardian"] : []),
|
|
45
|
+
],
|
|
35
46
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
36
47
|
);
|
|
37
48
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-staging.1",
|
|
4
4
|
"description": "CLI tools for vellum-assistant",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"lint": "eslint",
|
|
24
24
|
"lint:unused": "knip --include files,dependencies,unlisted",
|
|
25
25
|
"prepack": "node ../scripts/prepack-bundled-deps.mjs",
|
|
26
|
+
"compile:check": "bun build --compile --external react-devtools-core src/index.ts --outfile /dev/null",
|
|
26
27
|
"test": "bun test",
|
|
27
28
|
"typecheck": "bunx tsc --noEmit"
|
|
28
29
|
},
|
|
@@ -34,8 +35,7 @@
|
|
|
34
35
|
"chalk": "5.6.2",
|
|
35
36
|
"ink": "6.8.0",
|
|
36
37
|
"nanoid": "5.1.7",
|
|
37
|
-
"react": "19.2.4"
|
|
38
|
-
"react-devtools-core": "6.1.5"
|
|
38
|
+
"react": "19.2.4"
|
|
39
39
|
},
|
|
40
40
|
"bundledDependencies": [
|
|
41
41
|
"@vellumai/environments",
|
|
@@ -75,12 +75,11 @@ describe("assistant-config", () => {
|
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
test("saveAssistantEntry and loadAllAssistants round-trip", () => {
|
|
78
|
-
const entry = makeEntry("test-1"
|
|
78
|
+
const entry = makeEntry("test-1");
|
|
79
79
|
saveAssistantEntry(entry);
|
|
80
80
|
const all = loadAllAssistants();
|
|
81
81
|
expect(all).toHaveLength(1);
|
|
82
82
|
expect(all[0].assistantId).toBe("test-1");
|
|
83
|
-
expect(all[0].version).toBe("0.7.0");
|
|
84
83
|
});
|
|
85
84
|
|
|
86
85
|
test("findAssistantByName returns matching entry", () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect,
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
@@ -11,22 +11,17 @@ import {
|
|
|
11
11
|
import { tmpdir } from "os";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
|
|
14
|
-
// Bun's os.homedir() ignores runtime HOME changes, so mock it (
|
|
15
|
-
//
|
|
14
|
+
// Bun's os.homedir() ignores runtime HOME changes, so mock it (via the shared
|
|
15
|
+
// helper) to keep production-path tests off the real ~/.vellum.
|
|
16
16
|
let fakeHome: string | undefined;
|
|
17
|
-
|
|
18
|
-
const osMock = () => ({
|
|
19
|
-
...realOs,
|
|
20
|
-
homedir: () => fakeHome ?? realOs.homedir(),
|
|
21
|
-
});
|
|
22
|
-
mock.module("node:os", osMock);
|
|
23
|
-
mock.module("os", osMock);
|
|
17
|
+
await mockOsHomedir((realHomedir) => () => fakeHome ?? realHomedir());
|
|
24
18
|
|
|
25
19
|
import {
|
|
26
20
|
getOrCreateHostDeviceId,
|
|
27
21
|
resetHostDeviceIdCache,
|
|
28
22
|
} from "../lib/device-id.js";
|
|
29
23
|
import { snapshotEnv } from "./helpers/env.js";
|
|
24
|
+
import { mockOsHomedir } from "./helpers/os-mock.js";
|
|
30
25
|
|
|
31
26
|
const UUID_RE =
|
|
32
27
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
@@ -154,10 +149,7 @@ describe("getOrCreateHostDeviceId (production)", () => {
|
|
|
154
149
|
|
|
155
150
|
test("reuses an existing ~/.vellum/device.json", () => {
|
|
156
151
|
mkdirSync(join(tempHome, ".vellum"), { recursive: true });
|
|
157
|
-
writeFileSync(
|
|
158
|
-
deviceFile,
|
|
159
|
-
JSON.stringify({ deviceId: "shared-prod-id" }),
|
|
160
|
-
);
|
|
152
|
+
writeFileSync(deviceFile, JSON.stringify({ deviceId: "shared-prod-id" }));
|
|
161
153
|
|
|
162
154
|
expect(getOrCreateHostDeviceId()).toBe("shared-prod-id");
|
|
163
155
|
expect(readFileSync(deviceFile, "utf-8")).toBe(
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mock `os.homedir()` (both the `"os"` and `"node:os"` specifiers) while
|
|
5
|
+
* keeping every other os export intact.
|
|
6
|
+
*
|
|
7
|
+
* The factory passed to `mock.module()` must only close over plain snapshots
|
|
8
|
+
* captured before the mock is installed. When another test file has already
|
|
9
|
+
* loaded "os", `mock.module()` patches the live namespace in place — a
|
|
10
|
+
* factory that spreads or calls back through that namespace resolves to the
|
|
11
|
+
* mock itself and recurses forever, a synchronous spin that froze the whole
|
|
12
|
+
* suite (and CI) at whichever file loaded next. This helper owns that
|
|
13
|
+
* invariant so test files don't have to.
|
|
14
|
+
*
|
|
15
|
+
* @param makeHomedir Receives the real (pre-mock) `homedir` and returns the
|
|
16
|
+
* replacement implementation.
|
|
17
|
+
*/
|
|
18
|
+
export async function mockOsHomedir(
|
|
19
|
+
makeHomedir: (realHomedir: () => string) => () => string,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const realOs = await import("node:os");
|
|
22
|
+
const realOsSnapshot = { ...realOs };
|
|
23
|
+
const homedir = makeHomedir(realOs.homedir);
|
|
24
|
+
const factory = () => ({ ...realOsSnapshot, homedir });
|
|
25
|
+
mock.module("node:os", factory);
|
|
26
|
+
mock.module("os", factory);
|
|
27
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { startLoopbackListener } from "../commands/login.js";
|
|
4
|
+
|
|
5
|
+
/** Resolve "settled"/"pending" — proves whether `waitForCode` resolved. */
|
|
6
|
+
async function settleState(p: Promise<unknown>): Promise<"settled" | "pending"> {
|
|
7
|
+
return Promise.race([
|
|
8
|
+
p.then(
|
|
9
|
+
() => "settled" as const,
|
|
10
|
+
() => "settled" as const,
|
|
11
|
+
),
|
|
12
|
+
new Promise<"pending">((r) => setTimeout(() => r("pending"), 50)),
|
|
13
|
+
]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("startLoopbackListener", () => {
|
|
17
|
+
test("rejects a state-mismatched callback (CSRF) without settling", async () => {
|
|
18
|
+
const listener = await startLoopbackListener("expected-state");
|
|
19
|
+
try {
|
|
20
|
+
// Wrong state — the load-bearing CSRF check. Any local process can
|
|
21
|
+
// hit the loopback port, so a mismatched state must NOT deliver a code.
|
|
22
|
+
const res = await fetch(`${listener.redirectUri}?code=evil&state=wrong`);
|
|
23
|
+
expect(res.status).toBe(404);
|
|
24
|
+
expect(await settleState(listener.waitForCode)).toBe("pending");
|
|
25
|
+
|
|
26
|
+
// Wrong path on the right port is also ignored.
|
|
27
|
+
const noise = await fetch(
|
|
28
|
+
`${listener.redirectUri.replace("/auth/callback", "/evil")}?state=expected-state&code=c`,
|
|
29
|
+
);
|
|
30
|
+
expect(noise.status).toBe(404);
|
|
31
|
+
expect(await settleState(listener.waitForCode)).toBe("pending");
|
|
32
|
+
|
|
33
|
+
// A state-matched callback then settles it — the listener kept
|
|
34
|
+
// listening through the noise above.
|
|
35
|
+
const ok = await fetch(
|
|
36
|
+
`${listener.redirectUri}?code=good-code&state=expected-state`,
|
|
37
|
+
);
|
|
38
|
+
expect(ok.status).toBe(200);
|
|
39
|
+
expect(await listener.waitForCode).toBe("good-code");
|
|
40
|
+
} finally {
|
|
41
|
+
listener.close();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rejects on an error callback with the matching state", async () => {
|
|
46
|
+
const listener = await startLoopbackListener("st");
|
|
47
|
+
try {
|
|
48
|
+
const settled = listener.waitForCode.then(
|
|
49
|
+
() => null,
|
|
50
|
+
(e: Error) => e,
|
|
51
|
+
);
|
|
52
|
+
const res = await fetch(`${listener.redirectUri}?error=access_denied&state=st`);
|
|
53
|
+
expect(res.status).toBe(400);
|
|
54
|
+
const err = await settled;
|
|
55
|
+
expect(err?.message).toMatch(/access_denied/);
|
|
56
|
+
} finally {
|
|
57
|
+
listener.close();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("close rejects a pending waiter with the given reason", async () => {
|
|
62
|
+
const listener = await startLoopbackListener("st");
|
|
63
|
+
const settled = listener.waitForCode.then(
|
|
64
|
+
() => null,
|
|
65
|
+
(e: Error) => e,
|
|
66
|
+
);
|
|
67
|
+
listener.close("Login timed out. Please try again.");
|
|
68
|
+
const err = await settled;
|
|
69
|
+
expect(err?.message).toMatch(/timed out/);
|
|
70
|
+
});
|
|
71
|
+
});
|