@vellumai/cli 0.4.48 → 0.4.50
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/README.md +7 -9
- package/package.json +1 -1
- package/src/adapters/install.sh +6 -6
- package/src/commands/clean.ts +11 -6
- package/src/commands/client.ts +5 -13
- package/src/commands/hatch.ts +8 -20
- package/src/commands/ps.ts +24 -4
- package/src/commands/recover.ts +1 -1
- package/src/commands/retire.ts +2 -5
- package/src/commands/setup.ts +172 -0
- package/src/commands/sleep.ts +1 -1
- package/src/commands/wake.ts +2 -2
- package/src/components/DefaultMainScreen.tsx +270 -22
- package/src/components/TextInput.tsx +159 -12
- package/src/index.ts +101 -22
- package/src/lib/assistant-config.ts +1 -6
- package/src/lib/constants.ts +7 -1
- package/src/lib/docker.ts +103 -9
- package/src/lib/doctor-client.ts +1 -1
- package/src/lib/gcp.ts +1 -1
- package/src/lib/health-check.ts +87 -0
- package/src/lib/http-client.ts +1 -2
- package/src/lib/input-history.ts +44 -0
- package/src/lib/local.ts +160 -161
- package/src/lib/orphan-detection.ts +13 -3
- package/src/lib/process.ts +3 -1
- package/src/lib/terminal-capabilities.ts +133 -0
- package/src/lib/xdg-log.ts +2 -2
|
@@ -6,6 +6,9 @@ interface TextInputProps {
|
|
|
6
6
|
value: string;
|
|
7
7
|
onChange: (value: string) => void;
|
|
8
8
|
onSubmit?: (value: string) => void;
|
|
9
|
+
onHistoryUp?: () => void;
|
|
10
|
+
onHistoryDown?: () => void;
|
|
11
|
+
completionCommands?: string[];
|
|
9
12
|
focus?: boolean;
|
|
10
13
|
placeholder?: string;
|
|
11
14
|
}
|
|
@@ -14,12 +17,19 @@ function TextInput({
|
|
|
14
17
|
value,
|
|
15
18
|
onChange,
|
|
16
19
|
onSubmit,
|
|
20
|
+
onHistoryUp,
|
|
21
|
+
onHistoryDown,
|
|
22
|
+
completionCommands,
|
|
17
23
|
focus = true,
|
|
18
24
|
placeholder = "",
|
|
19
25
|
}: TextInputProps): ReactElement {
|
|
20
26
|
const cursorOffsetRef = useRef(value.length);
|
|
21
27
|
const valueRef = useRef(value);
|
|
22
28
|
|
|
29
|
+
// Tab completion state
|
|
30
|
+
const [completionIndex, setCompletionIndex] = useState(-1);
|
|
31
|
+
const [completionMatches, setCompletionMatches] = useState<string[]>([]);
|
|
32
|
+
|
|
23
33
|
valueRef.current = value;
|
|
24
34
|
|
|
25
35
|
if (cursorOffsetRef.current > value.length) {
|
|
@@ -28,40 +38,162 @@ function TextInput({
|
|
|
28
38
|
|
|
29
39
|
const [, setRenderTick] = useState(0);
|
|
30
40
|
|
|
41
|
+
const clearCompletion = () => {
|
|
42
|
+
setCompletionIndex(-1);
|
|
43
|
+
setCompletionMatches([]);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const getMatches = (text: string): string[] => {
|
|
47
|
+
if (!completionCommands || !text.startsWith("/") || text.includes(" ")) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const prefix = text.toLowerCase();
|
|
51
|
+
return completionCommands.filter((cmd) =>
|
|
52
|
+
cmd.toLowerCase().startsWith(prefix),
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
31
56
|
useInput(
|
|
32
57
|
(input, key) => {
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
if (key.upArrow && !key.shift && !key.meta) {
|
|
59
|
+
clearCompletion();
|
|
60
|
+
onHistoryUp?.();
|
|
61
|
+
cursorOffsetRef.current = Infinity;
|
|
62
|
+
setRenderTick((t) => t + 1);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (key.downArrow && !key.shift && !key.meta) {
|
|
66
|
+
clearCompletion();
|
|
67
|
+
onHistoryDown?.();
|
|
68
|
+
cursorOffsetRef.current = Infinity;
|
|
69
|
+
setRenderTick((t) => t + 1);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (key.ctrl && input === "c") {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Tab completion handling
|
|
77
|
+
if (key.tab) {
|
|
78
|
+
const currentValue = valueRef.current;
|
|
79
|
+
|
|
80
|
+
if (completionMatches.length > 0) {
|
|
81
|
+
// Already in completion mode — cycle through matches
|
|
82
|
+
const direction = key.shift ? -1 : 1;
|
|
83
|
+
const nextIndex =
|
|
84
|
+
(completionIndex + direction + completionMatches.length) %
|
|
85
|
+
completionMatches.length;
|
|
86
|
+
setCompletionIndex(nextIndex);
|
|
87
|
+
|
|
88
|
+
const completed = completionMatches[nextIndex]!;
|
|
89
|
+
valueRef.current = completed;
|
|
90
|
+
cursorOffsetRef.current = completed.length;
|
|
91
|
+
onChange(completed);
|
|
92
|
+
setRenderTick((t) => t + 1);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Start completion mode
|
|
97
|
+
const matches = getMatches(currentValue);
|
|
98
|
+
if (matches.length === 1) {
|
|
99
|
+
// Single match — accept immediately with trailing space
|
|
100
|
+
const completed = matches[0]! + " ";
|
|
101
|
+
valueRef.current = completed;
|
|
102
|
+
cursorOffsetRef.current = completed.length;
|
|
103
|
+
onChange(completed);
|
|
104
|
+
setRenderTick((t) => t + 1);
|
|
105
|
+
} else if (matches.length > 1) {
|
|
106
|
+
setCompletionMatches(matches);
|
|
107
|
+
const idx = key.shift ? matches.length - 1 : 0;
|
|
108
|
+
setCompletionIndex(idx);
|
|
109
|
+
|
|
110
|
+
const completed = matches[idx]!;
|
|
111
|
+
valueRef.current = completed;
|
|
112
|
+
cursorOffsetRef.current = completed.length;
|
|
113
|
+
onChange(completed);
|
|
114
|
+
setRenderTick((t) => t + 1);
|
|
115
|
+
}
|
|
40
116
|
return;
|
|
41
117
|
}
|
|
42
118
|
|
|
119
|
+
// Escape cancels completion mode
|
|
120
|
+
if (key.escape) {
|
|
121
|
+
if (completionMatches.length > 0) {
|
|
122
|
+
clearCompletion();
|
|
123
|
+
setRenderTick((t) => t + 1);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Enter accepts completion and submits
|
|
43
129
|
if (key.return) {
|
|
44
|
-
|
|
130
|
+
if (completionMatches.length > 0) {
|
|
131
|
+
// Append trailing space so the command is recognized by handleInput
|
|
132
|
+
const completed = valueRef.current + " ";
|
|
133
|
+
valueRef.current = completed;
|
|
134
|
+
cursorOffsetRef.current = completed.length;
|
|
135
|
+
onChange(completed);
|
|
136
|
+
clearCompletion();
|
|
137
|
+
onSubmit?.(completed);
|
|
138
|
+
} else {
|
|
139
|
+
clearCompletion();
|
|
140
|
+
onSubmit?.(valueRef.current);
|
|
141
|
+
}
|
|
45
142
|
return;
|
|
46
143
|
}
|
|
47
144
|
|
|
145
|
+
// Space accepts completion, then continues editing
|
|
146
|
+
if (input === " " && completionMatches.length > 0) {
|
|
147
|
+
clearCompletion();
|
|
148
|
+
// Let the space be inserted normally below
|
|
149
|
+
} else if (completionMatches.length > 0) {
|
|
150
|
+
// Any other key exits completion mode
|
|
151
|
+
clearCompletion();
|
|
152
|
+
}
|
|
153
|
+
|
|
48
154
|
const currentValue = valueRef.current;
|
|
49
155
|
const currentOffset = cursorOffsetRef.current;
|
|
50
156
|
let nextValue = currentValue;
|
|
51
157
|
let nextOffset = currentOffset;
|
|
52
158
|
|
|
53
|
-
if (key.
|
|
159
|
+
if (key.ctrl && input === "a") {
|
|
160
|
+
// Ctrl+A — move cursor to start
|
|
161
|
+
nextOffset = 0;
|
|
162
|
+
} else if (key.ctrl && input === "e") {
|
|
163
|
+
// Ctrl+E — move cursor to end
|
|
164
|
+
nextOffset = currentValue.length;
|
|
165
|
+
} else if (key.ctrl && input === "u") {
|
|
166
|
+
// Ctrl+U — clear line before cursor
|
|
167
|
+
nextValue = currentValue.slice(currentOffset);
|
|
168
|
+
nextOffset = 0;
|
|
169
|
+
} else if (key.ctrl && input === "k") {
|
|
170
|
+
// Ctrl+K — kill from cursor to end
|
|
171
|
+
nextValue = currentValue.slice(0, currentOffset);
|
|
172
|
+
} else if (key.ctrl && input === "w") {
|
|
173
|
+
// Ctrl+W — delete word backwards (handles tabs and other whitespace)
|
|
174
|
+
const before = currentValue.slice(0, currentOffset);
|
|
175
|
+
// Skip trailing whitespace, then find previous whitespace boundary
|
|
176
|
+
const match = before.match(/^(.*\s)?\S+\s*$/);
|
|
177
|
+
const wordStart = match?.[1]?.length ?? 0;
|
|
178
|
+
nextValue =
|
|
179
|
+
currentValue.slice(0, wordStart) + currentValue.slice(currentOffset);
|
|
180
|
+
nextOffset = wordStart;
|
|
181
|
+
} else if (key.leftArrow) {
|
|
54
182
|
nextOffset = Math.max(0, currentOffset - 1);
|
|
55
183
|
} else if (key.rightArrow) {
|
|
56
184
|
nextOffset = Math.min(currentValue.length, currentOffset + 1);
|
|
57
185
|
} else if (key.backspace || key.delete) {
|
|
58
186
|
if (currentOffset > 0) {
|
|
59
|
-
nextValue =
|
|
187
|
+
nextValue =
|
|
188
|
+
currentValue.slice(0, currentOffset - 1) +
|
|
189
|
+
currentValue.slice(currentOffset);
|
|
60
190
|
nextOffset = currentOffset - 1;
|
|
61
191
|
}
|
|
62
192
|
} else {
|
|
63
193
|
nextValue =
|
|
64
|
-
currentValue.slice(0, currentOffset) +
|
|
194
|
+
currentValue.slice(0, currentOffset) +
|
|
195
|
+
input +
|
|
196
|
+
currentValue.slice(currentOffset);
|
|
65
197
|
nextOffset = currentOffset + input.length;
|
|
66
198
|
}
|
|
67
199
|
|
|
@@ -78,6 +210,14 @@ function TextInput({
|
|
|
78
210
|
);
|
|
79
211
|
|
|
80
212
|
const cursorOffset = cursorOffsetRef.current;
|
|
213
|
+
const isCompleting = completionMatches.length > 0;
|
|
214
|
+
|
|
215
|
+
// Build completion hint text
|
|
216
|
+
let completionHint = "";
|
|
217
|
+
if (isCompleting && completionMatches.length > 1) {
|
|
218
|
+
completionHint = ` [${completionIndex + 1}/${completionMatches.length}]`;
|
|
219
|
+
}
|
|
220
|
+
|
|
81
221
|
let renderedValue: string;
|
|
82
222
|
let renderedPlaceholder: string | undefined;
|
|
83
223
|
|
|
@@ -97,6 +237,9 @@ function TextInput({
|
|
|
97
237
|
if (cursorOffset === value.length) {
|
|
98
238
|
renderedValue += chalk.inverse(" ");
|
|
99
239
|
}
|
|
240
|
+
if (completionHint) {
|
|
241
|
+
renderedValue += chalk.grey(completionHint);
|
|
242
|
+
}
|
|
100
243
|
} else {
|
|
101
244
|
renderedValue = chalk.inverse(" ");
|
|
102
245
|
}
|
|
@@ -107,7 +250,11 @@ function TextInput({
|
|
|
107
250
|
|
|
108
251
|
return (
|
|
109
252
|
<Text>
|
|
110
|
-
{placeholder
|
|
253
|
+
{placeholder
|
|
254
|
+
? value.length > 0
|
|
255
|
+
? renderedValue
|
|
256
|
+
: renderedPlaceholder
|
|
257
|
+
: renderedValue}
|
|
111
258
|
</Text>
|
|
112
259
|
);
|
|
113
260
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,11 +9,19 @@ import { pair } from "./commands/pair";
|
|
|
9
9
|
import { ps } from "./commands/ps";
|
|
10
10
|
import { recover } from "./commands/recover";
|
|
11
11
|
import { retire } from "./commands/retire";
|
|
12
|
+
import { setup } from "./commands/setup";
|
|
12
13
|
import { sleep } from "./commands/sleep";
|
|
13
14
|
import { ssh } from "./commands/ssh";
|
|
14
15
|
import { tunnel } from "./commands/tunnel";
|
|
15
16
|
import { use } from "./commands/use";
|
|
16
17
|
import { wake } from "./commands/wake";
|
|
18
|
+
import {
|
|
19
|
+
getActiveAssistant,
|
|
20
|
+
findAssistantByName,
|
|
21
|
+
loadLatestAssistant,
|
|
22
|
+
setActiveAssistant,
|
|
23
|
+
} from "./lib/assistant-config";
|
|
24
|
+
import { checkHealth } from "./lib/health-check";
|
|
17
25
|
|
|
18
26
|
const commands = {
|
|
19
27
|
clean,
|
|
@@ -25,6 +33,7 @@ const commands = {
|
|
|
25
33
|
ps,
|
|
26
34
|
recover,
|
|
27
35
|
retire,
|
|
36
|
+
setup,
|
|
28
37
|
sleep,
|
|
29
38
|
ssh,
|
|
30
39
|
tunnel,
|
|
@@ -35,36 +44,106 @@ const commands = {
|
|
|
35
44
|
|
|
36
45
|
type CommandName = keyof typeof commands;
|
|
37
46
|
|
|
47
|
+
function printHelp(): void {
|
|
48
|
+
console.log("Usage: vellum <command> [options]");
|
|
49
|
+
console.log("");
|
|
50
|
+
console.log("Commands:");
|
|
51
|
+
console.log(" clean Kill orphaned vellum processes");
|
|
52
|
+
console.log(" client Connect to a hatched assistant");
|
|
53
|
+
console.log(" hatch Create a new assistant instance");
|
|
54
|
+
console.log(" login Log in to the Vellum platform");
|
|
55
|
+
console.log(" logout Log out of the Vellum platform");
|
|
56
|
+
console.log(" pair Pair with a remote assistant via QR code");
|
|
57
|
+
console.log(
|
|
58
|
+
" ps List assistants (or processes for a specific assistant)",
|
|
59
|
+
);
|
|
60
|
+
console.log(" recover Restore a previously retired local assistant");
|
|
61
|
+
console.log(" retire Delete an assistant instance");
|
|
62
|
+
console.log(" setup Configure API keys interactively");
|
|
63
|
+
console.log(" sleep Stop the assistant process");
|
|
64
|
+
console.log(" ssh SSH into a remote assistant instance");
|
|
65
|
+
console.log(" tunnel Create a tunnel for a locally hosted assistant");
|
|
66
|
+
console.log(" use Set the active assistant for commands");
|
|
67
|
+
console.log(" wake Start the assistant and gateway");
|
|
68
|
+
console.log(" whoami Show current logged-in user");
|
|
69
|
+
console.log("");
|
|
70
|
+
console.log("Options:");
|
|
71
|
+
console.log(
|
|
72
|
+
" --no-color, --plain Disable colored output (honors NO_COLOR env)",
|
|
73
|
+
);
|
|
74
|
+
console.log(" --version, -v Show version");
|
|
75
|
+
console.log(" --help, -h Show this help");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check for --no-color / --plain flags and set NO_COLOR env var
|
|
80
|
+
* before any terminal capability detection runs.
|
|
81
|
+
*
|
|
82
|
+
* Per https://no-color.org/, setting NO_COLOR to any non-empty value
|
|
83
|
+
* signals that color output should be suppressed.
|
|
84
|
+
*/
|
|
85
|
+
function applyNoColorFlags(argv: string[]): void {
|
|
86
|
+
if (argv.includes("--no-color") || argv.includes("--plain")) {
|
|
87
|
+
process.env.NO_COLOR = "1";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* If a running assistant is detected, launch the TUI client and return true.
|
|
93
|
+
* Otherwise return false so the caller can fall back to help text.
|
|
94
|
+
*/
|
|
95
|
+
async function tryLaunchClient(): Promise<boolean> {
|
|
96
|
+
const activeName = getActiveAssistant();
|
|
97
|
+
const entry = activeName
|
|
98
|
+
? findAssistantByName(activeName)
|
|
99
|
+
: loadLatestAssistant();
|
|
100
|
+
|
|
101
|
+
if (!entry) return false;
|
|
102
|
+
|
|
103
|
+
const url = entry.localUrl || entry.runtimeUrl;
|
|
104
|
+
if (!url) return false;
|
|
105
|
+
|
|
106
|
+
const result = await checkHealth(url, entry.bearerToken);
|
|
107
|
+
if (result.status !== "healthy") return false;
|
|
108
|
+
|
|
109
|
+
// Ensure the resolved assistant is active so client() can find it
|
|
110
|
+
// (client() independently reads the active assistant from config).
|
|
111
|
+
setActiveAssistant(String(entry.assistantId));
|
|
112
|
+
|
|
113
|
+
await client();
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
38
117
|
async function main() {
|
|
39
118
|
const args = process.argv.slice(2);
|
|
40
|
-
|
|
119
|
+
|
|
120
|
+
// Must run before any command or terminal-capabilities usage
|
|
121
|
+
applyNoColorFlags(args);
|
|
122
|
+
|
|
123
|
+
// Global flags that are not command names
|
|
124
|
+
const GLOBAL_FLAGS = new Set(["--no-color", "--plain"]);
|
|
125
|
+
const commandName = args.find((a) => !GLOBAL_FLAGS.has(a));
|
|
126
|
+
|
|
127
|
+
// Strip global flags from process.argv so subcommands that parse
|
|
128
|
+
// process.argv.slice(3) don't see them as positional arguments.
|
|
129
|
+
const filteredArgs = args.filter((a) => !GLOBAL_FLAGS.has(a));
|
|
130
|
+
process.argv = [...process.argv.slice(0, 2), ...filteredArgs];
|
|
41
131
|
|
|
42
132
|
if (commandName === "--version" || commandName === "-v") {
|
|
43
133
|
console.log(`@vellumai/cli v${cliPkg.version}`);
|
|
44
134
|
process.exit(0);
|
|
45
135
|
}
|
|
46
136
|
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
console.log(
|
|
58
|
-
" ps List assistants (or processes for a specific assistant)",
|
|
59
|
-
);
|
|
60
|
-
console.log(" recover Restore a previously retired local assistant");
|
|
61
|
-
console.log(" retire Delete an assistant instance");
|
|
62
|
-
console.log(" sleep Stop the assistant process");
|
|
63
|
-
console.log(" ssh SSH into a remote assistant instance");
|
|
64
|
-
console.log(" tunnel Create a tunnel for a locally hosted assistant");
|
|
65
|
-
console.log(" use Set the active assistant for commands");
|
|
66
|
-
console.log(" wake Start the assistant and gateway");
|
|
67
|
-
console.log(" whoami Show current logged-in user");
|
|
137
|
+
if (commandName === "--help" || commandName === "-h") {
|
|
138
|
+
printHelp();
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!commandName) {
|
|
143
|
+
const launched = await tryLaunchClient();
|
|
144
|
+
if (!launched) {
|
|
145
|
+
printHelp();
|
|
146
|
+
}
|
|
68
147
|
process.exit(0);
|
|
69
148
|
}
|
|
70
149
|
|
|
@@ -190,11 +190,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
190
190
|
mutated = true;
|
|
191
191
|
}
|
|
192
192
|
if (typeof res.pidFile !== "string") {
|
|
193
|
-
res.pidFile = join(
|
|
194
|
-
res.instanceDir as string,
|
|
195
|
-
".vellum",
|
|
196
|
-
"vellum.pid",
|
|
197
|
-
);
|
|
193
|
+
res.pidFile = join(res.instanceDir as string, ".vellum", "vellum.pid");
|
|
198
194
|
mutated = true;
|
|
199
195
|
}
|
|
200
196
|
}
|
|
@@ -363,7 +359,6 @@ export async function allocateLocalResources(
|
|
|
363
359
|
if (existingLocals.length === 0) {
|
|
364
360
|
const home = homedir();
|
|
365
361
|
const vellumDir = join(home, ".vellum");
|
|
366
|
-
mkdirSync(vellumDir, { recursive: true });
|
|
367
362
|
return {
|
|
368
363
|
instanceDir: home,
|
|
369
364
|
daemonPort: DEFAULT_DAEMON_PORT,
|
package/src/lib/constants.ts
CHANGED
|
@@ -8,7 +8,13 @@ export const DEFAULT_DAEMON_PORT = 7821;
|
|
|
8
8
|
export const DEFAULT_GATEWAY_PORT = 7830;
|
|
9
9
|
export const DEFAULT_QDRANT_PORT = 6333;
|
|
10
10
|
|
|
11
|
-
export const VALID_REMOTE_HOSTS = [
|
|
11
|
+
export const VALID_REMOTE_HOSTS = [
|
|
12
|
+
"local",
|
|
13
|
+
"gcp",
|
|
14
|
+
"aws",
|
|
15
|
+
"docker",
|
|
16
|
+
"custom",
|
|
17
|
+
] as const;
|
|
12
18
|
export type RemoteHost = (typeof VALID_REMOTE_HOSTS)[number];
|
|
13
19
|
export const VALID_SPECIES = ["openclaw", "vellum"] as const;
|
|
14
20
|
export type Species = (typeof VALID_SPECIES)[number];
|
package/src/lib/docker.ts
CHANGED
|
@@ -7,7 +7,6 @@ import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
|
7
7
|
import type { AssistantEntry } from "./assistant-config";
|
|
8
8
|
import { DEFAULT_GATEWAY_PORT } from "./constants";
|
|
9
9
|
import type { Species } from "./constants";
|
|
10
|
-
import { discoverPublicUrl } from "./local";
|
|
11
10
|
import { generateRandomSuffix } from "./random-name";
|
|
12
11
|
import { exec, execOutput } from "./step-runner";
|
|
13
12
|
import {
|
|
@@ -19,6 +18,102 @@ import {
|
|
|
19
18
|
|
|
20
19
|
const _require = createRequire(import.meta.url);
|
|
21
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Checks whether the `docker` CLI and daemon are available on the system.
|
|
23
|
+
* Installs Colima and Docker via Homebrew if the CLI is missing, and starts
|
|
24
|
+
* Colima if the Docker daemon is not reachable.
|
|
25
|
+
*/
|
|
26
|
+
async function ensureDockerInstalled(): Promise<void> {
|
|
27
|
+
let installed = false;
|
|
28
|
+
try {
|
|
29
|
+
await execOutput("docker", ["--version"]);
|
|
30
|
+
installed = true;
|
|
31
|
+
} catch {
|
|
32
|
+
// docker CLI not found — install it
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!installed) {
|
|
36
|
+
// Check whether Homebrew is available before attempting to use it.
|
|
37
|
+
let hasBrew = false;
|
|
38
|
+
try {
|
|
39
|
+
await execOutput("brew", ["--version"]);
|
|
40
|
+
hasBrew = true;
|
|
41
|
+
} catch {
|
|
42
|
+
// brew not found
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!hasBrew) {
|
|
46
|
+
console.log("🍺 Homebrew not found. Installing Homebrew...");
|
|
47
|
+
try {
|
|
48
|
+
await exec("bash", [
|
|
49
|
+
"-c",
|
|
50
|
+
'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
|
51
|
+
]);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Failed to install Homebrew. Please install Docker manually from https://www.docker.com/products/docker-desktop/\n${message}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Homebrew on Apple Silicon installs to /opt/homebrew; add it to PATH
|
|
60
|
+
// so subsequent brew/colima/docker invocations work in this session.
|
|
61
|
+
if (!process.env.PATH?.includes("/opt/homebrew")) {
|
|
62
|
+
process.env.PATH = `/opt/homebrew/bin:/opt/homebrew/sbin:${process.env.PATH}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log("🐳 Docker not found. Installing via Homebrew...");
|
|
67
|
+
try {
|
|
68
|
+
await exec("brew", ["install", "colima", "docker"]);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Failed to install Docker via Homebrew. Please install Docker manually.\n${message}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await execOutput("docker", ["--version"]);
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"Docker was installed but is still not available on PATH. " +
|
|
81
|
+
"You may need to restart your terminal.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Verify the Docker daemon is reachable; start Colima if it isn't
|
|
87
|
+
try {
|
|
88
|
+
await exec("docker", ["info"]);
|
|
89
|
+
} catch {
|
|
90
|
+
let hasColima = false;
|
|
91
|
+
try {
|
|
92
|
+
await execOutput("colima", ["version"]);
|
|
93
|
+
hasColima = true;
|
|
94
|
+
} catch {
|
|
95
|
+
// colima not found
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!hasColima) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"Docker daemon is not running and Colima is not installed.\n" +
|
|
101
|
+
"Please start Docker Desktop, or install Colima with 'brew install colima' and run 'colima start'.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log("🚀 Docker daemon not running. Starting Colima...");
|
|
106
|
+
try {
|
|
107
|
+
await exec("colima", ["start"]);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Failed to start Colima. Please run 'colima start' manually.\n${message}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
22
117
|
interface DockerRoot {
|
|
23
118
|
/** Directory to use as the Docker build context */
|
|
24
119
|
root: string;
|
|
@@ -180,6 +275,8 @@ export async function hatchDocker(
|
|
|
180
275
|
): Promise<void> {
|
|
181
276
|
resetLogFile("hatch.log");
|
|
182
277
|
|
|
278
|
+
await ensureDockerInstalled();
|
|
279
|
+
|
|
183
280
|
let repoRoot: string;
|
|
184
281
|
let dockerfileDir: string;
|
|
185
282
|
try {
|
|
@@ -251,12 +348,7 @@ export async function hatchDocker(
|
|
|
251
348
|
];
|
|
252
349
|
|
|
253
350
|
// Pass through environment variables the assistant needs
|
|
254
|
-
for (const envVar of [
|
|
255
|
-
"ANTHROPIC_API_KEY",
|
|
256
|
-
"GATEWAY_RUNTIME_PROXY_ENABLED",
|
|
257
|
-
"RUNTIME_PROXY_BEARER_TOKEN",
|
|
258
|
-
"VELLUM_ASSISTANT_PLATFORM_URL",
|
|
259
|
-
]) {
|
|
351
|
+
for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
|
|
260
352
|
if (process.env[envVar]) {
|
|
261
353
|
runArgs.push("-e", `${envVar}=${process.env[envVar]}`);
|
|
262
354
|
}
|
|
@@ -278,8 +370,10 @@ export async function hatchDocker(
|
|
|
278
370
|
);
|
|
279
371
|
}
|
|
280
372
|
|
|
281
|
-
|
|
282
|
-
|
|
373
|
+
// Docker containers bind to 0.0.0.0 so localhost always works. Skip
|
|
374
|
+
// mDNS/LAN discovery — the .local hostname often fails to resolve on the
|
|
375
|
+
// host machine itself (mDNS is designed for cross-device discovery).
|
|
376
|
+
const runtimeUrl = `http://localhost:${gatewayPort}`;
|
|
283
377
|
const dockerEntry: AssistantEntry = {
|
|
284
378
|
assistantId: instanceName,
|
|
285
379
|
runtimeUrl,
|
package/src/lib/doctor-client.ts
CHANGED
package/src/lib/gcp.ts
CHANGED
|
@@ -637,7 +637,7 @@ export async function hatchGcp(
|
|
|
637
637
|
species === "vellum" &&
|
|
638
638
|
(await checkCurlFailure(instanceName, project, zone, account))
|
|
639
639
|
) {
|
|
640
|
-
const installScriptUrl = `${process.env.
|
|
640
|
+
const installScriptUrl = `${process.env.VELLUM_PLATFORM_URL ?? "https://assistant.vellum.ai"}/install.sh`;
|
|
641
641
|
console.log(
|
|
642
642
|
`\ud83d\udd04 Detected install script curl failure for ${installScriptUrl}, attempting recovery...`,
|
|
643
643
|
);
|
package/src/lib/health-check.ts
CHANGED
|
@@ -10,6 +10,93 @@ export interface HealthCheckResult {
|
|
|
10
10
|
detail: string | null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
interface OrgListResponse {
|
|
14
|
+
results: { id: string }[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function fetchOrganizationId(
|
|
18
|
+
platformUrl: string,
|
|
19
|
+
token: string,
|
|
20
|
+
): Promise<{ orgId: string } | { error: string }> {
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(`${platformUrl}/v1/organizations/`, {
|
|
23
|
+
headers: { "X-Session-Token": token },
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
return { error: `org lookup failed (${response.status})` };
|
|
27
|
+
}
|
|
28
|
+
const body = (await response.json()) as OrgListResponse;
|
|
29
|
+
const orgId = body.results?.[0]?.id;
|
|
30
|
+
if (!orgId) {
|
|
31
|
+
return { error: "no organization found" };
|
|
32
|
+
}
|
|
33
|
+
return { orgId };
|
|
34
|
+
} catch {
|
|
35
|
+
return { error: "org lookup unreachable" };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function checkManagedHealth(
|
|
40
|
+
runtimeUrl: string,
|
|
41
|
+
assistantId: string,
|
|
42
|
+
): Promise<HealthCheckResult> {
|
|
43
|
+
const { readPlatformToken } = await import("./platform-client.js");
|
|
44
|
+
const token = readPlatformToken();
|
|
45
|
+
if (!token) {
|
|
46
|
+
return {
|
|
47
|
+
status: "error (auth)",
|
|
48
|
+
detail: "not logged in — run `vellum login`",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const orgResult = await fetchOrganizationId(runtimeUrl, token);
|
|
53
|
+
if ("error" in orgResult) {
|
|
54
|
+
return {
|
|
55
|
+
status: "error (auth)",
|
|
56
|
+
detail: orgResult.error,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const { orgId } = orgResult;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const url = `${runtimeUrl}/v1/assistants/${encodeURIComponent(assistantId)}/healthz/`;
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const timeoutId = setTimeout(
|
|
65
|
+
() => controller.abort(),
|
|
66
|
+
HEALTH_CHECK_TIMEOUT_MS,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const headers: Record<string, string> = {
|
|
70
|
+
"X-Session-Token": token,
|
|
71
|
+
"Vellum-Organization-Id": orgId,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const response = await fetch(url, {
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
headers,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
clearTimeout(timeoutId);
|
|
80
|
+
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
return { status: `error (${response.status})`, detail: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const data = (await response.json()) as HealthResponse;
|
|
86
|
+
const status = data.status || "unknown";
|
|
87
|
+
return {
|
|
88
|
+
status,
|
|
89
|
+
detail: status !== "healthy" ? (data.message ?? null) : null,
|
|
90
|
+
};
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const status =
|
|
93
|
+
error instanceof Error && error.name === "AbortError"
|
|
94
|
+
? "timeout"
|
|
95
|
+
: "unreachable";
|
|
96
|
+
return { status, detail: null };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
13
100
|
export async function checkHealth(
|
|
14
101
|
runtimeUrl: string,
|
|
15
102
|
bearerToken?: string,
|