@vellumai/cli 0.1.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 +291 -0
- package/eslint.config.mjs +17 -0
- package/package.json +26 -0
- package/src/adapters/install.sh +99 -0
- package/src/adapters/openclaw-http-server.ts +189 -0
- package/src/adapters/openclaw.ts +118 -0
- package/src/commands/hatch.ts +806 -0
- package/src/components/DefaultMainScreen.tsx +217 -0
- package/src/index.ts +39 -0
- package/src/lib/constants.ts +75 -0
- package/src/lib/gcp.ts +261 -0
- package/src/lib/health-check.ts +38 -0
- package/src/lib/interfaces-seed.ts +25 -0
- package/src/lib/openclaw-runtime-server.ts +18 -0
- package/src/lib/random-name.ts +133 -0
- package/src/lib/status-emoji.ts +14 -0
- package/src/lib/step-runner.ts +103 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import { Box, render as inkRender, Text } from "ink";
|
|
3
|
+
import { basename } from "path";
|
|
4
|
+
|
|
5
|
+
import { SPECIES_CONFIG, type Species } from "../lib/constants";
|
|
6
|
+
import { checkHealth } from "../lib/health-check";
|
|
7
|
+
import { withStatusEmoji } from "../lib/status-emoji";
|
|
8
|
+
|
|
9
|
+
export const ANSI = {
|
|
10
|
+
reset: "\x1b[0m",
|
|
11
|
+
bold: "\x1b[1m",
|
|
12
|
+
dim: "\x1b[2m",
|
|
13
|
+
cyan: "\x1b[36m",
|
|
14
|
+
green: "\x1b[32m",
|
|
15
|
+
yellow: "\x1b[33m",
|
|
16
|
+
red: "\x1b[31m",
|
|
17
|
+
magenta: "\x1b[35m",
|
|
18
|
+
gray: "\x1b[90m",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export const SLASH_COMMANDS = ["/clear", "/doctor", "/exit", "/help", "/q", "/quit", "/retire"];
|
|
22
|
+
|
|
23
|
+
export const TYPING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
24
|
+
|
|
25
|
+
export interface RuntimeMessage {
|
|
26
|
+
id: string;
|
|
27
|
+
role: "user" | "assistant";
|
|
28
|
+
content: string;
|
|
29
|
+
timestamp: string;
|
|
30
|
+
toolCalls?: unknown[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatTimestamp(ts: string): string {
|
|
34
|
+
try {
|
|
35
|
+
const date = new Date(ts);
|
|
36
|
+
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
37
|
+
} catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderMessage(msg: RuntimeMessage): void {
|
|
43
|
+
const time = formatTimestamp(msg.timestamp);
|
|
44
|
+
const timeStr = time ? `${ANSI.gray}${time}${ANSI.reset} ` : "";
|
|
45
|
+
|
|
46
|
+
if (msg.role === "user") {
|
|
47
|
+
console.log(`${timeStr}${ANSI.green}${ANSI.bold}You:${ANSI.reset} ${msg.content}`);
|
|
48
|
+
} else {
|
|
49
|
+
console.log(`${timeStr}${ANSI.cyan}${ANSI.bold}Assistant:${ANSI.reset} ${msg.content}`);
|
|
50
|
+
if (msg.toolCalls && Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) {
|
|
51
|
+
for (const tc of msg.toolCalls) {
|
|
52
|
+
const call = tc as Record<string, unknown>;
|
|
53
|
+
const name = typeof call.name === "string" ? call.name : "unknown";
|
|
54
|
+
console.log(` ${ANSI.dim}[tool: ${name}]${ANSI.reset}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function renderHelp(): void {
|
|
61
|
+
console.log(`${ANSI.bold}Commands:${ANSI.reset}`);
|
|
62
|
+
console.log(
|
|
63
|
+
` /doctor [question] ${ANSI.dim}Run diagnostics on the remote instance via SSH${ANSI.reset}`,
|
|
64
|
+
);
|
|
65
|
+
console.log(` /retire ${ANSI.dim}Retire the remote instance and exit${ANSI.reset}`);
|
|
66
|
+
console.log(` /quit, /exit, /q ${ANSI.dim}Disconnect and exit${ANSI.reset}`);
|
|
67
|
+
console.log(` /clear ${ANSI.dim}Clear the screen${ANSI.reset}`);
|
|
68
|
+
console.log(` /help, ? ${ANSI.dim}Show this help${ANSI.reset}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderErrorMainScreen(error: unknown): number {
|
|
72
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
73
|
+
console.log(`${ANSI.red}${ANSI.bold}Failed to render MainWindow${ANSI.reset}`);
|
|
74
|
+
console.log(`${ANSI.dim}${msg}${ANSI.reset}`);
|
|
75
|
+
console.log(`${ANSI.dim}Run /clear to retry${ANSI.reset}`);
|
|
76
|
+
return 3;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stripAnsi(str: string): string {
|
|
80
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface DefaultMainScreenProps {
|
|
84
|
+
runtimeUrl: string;
|
|
85
|
+
assistantId: string;
|
|
86
|
+
species: Species;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function DefaultMainScreen({
|
|
90
|
+
runtimeUrl,
|
|
91
|
+
assistantId,
|
|
92
|
+
species,
|
|
93
|
+
}: DefaultMainScreenProps): ReactElement {
|
|
94
|
+
const cwd = process.cwd();
|
|
95
|
+
const dirName = basename(cwd);
|
|
96
|
+
const config = SPECIES_CONFIG[species];
|
|
97
|
+
const art = config.art;
|
|
98
|
+
const accentColor = species === "openclaw" ? "red" : "magenta";
|
|
99
|
+
|
|
100
|
+
const tips = ["Send a message to start chatting", "Use /help to see available commands"];
|
|
101
|
+
|
|
102
|
+
const leftLines = [
|
|
103
|
+
" ",
|
|
104
|
+
" Meet your Assistant!",
|
|
105
|
+
" ",
|
|
106
|
+
...art.map((l) => ` ${stripAnsi(l)}`),
|
|
107
|
+
" ",
|
|
108
|
+
` ${runtimeUrl}`,
|
|
109
|
+
` ~/${dirName}`,
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const rightLines = [
|
|
113
|
+
" ",
|
|
114
|
+
"Tips for getting started",
|
|
115
|
+
...tips,
|
|
116
|
+
" ",
|
|
117
|
+
"Assistant",
|
|
118
|
+
assistantId,
|
|
119
|
+
"Species",
|
|
120
|
+
`${config.hatchedEmoji} ${species}`,
|
|
121
|
+
"Status",
|
|
122
|
+
withStatusEmoji("checking..."),
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const maxLines = Math.max(leftLines.length, rightLines.length);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Box flexDirection="column" width={72}>
|
|
129
|
+
<Text dimColor>{"── Vellum " + "─".repeat(62)}</Text>
|
|
130
|
+
<Box flexDirection="row">
|
|
131
|
+
<Box flexDirection="column" width={LEFT_PANEL_WIDTH}>
|
|
132
|
+
{Array.from({ length: maxLines }, (_, i) => {
|
|
133
|
+
const line = leftLines[i] ?? " ";
|
|
134
|
+
if (i === 1) {
|
|
135
|
+
return (
|
|
136
|
+
<Text key={i} bold>
|
|
137
|
+
{line}
|
|
138
|
+
</Text>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (i > 2 && i <= 2 + art.length) {
|
|
142
|
+
return (
|
|
143
|
+
<Text key={i} color={accentColor}>
|
|
144
|
+
{line}
|
|
145
|
+
</Text>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (i > 2 + art.length) {
|
|
149
|
+
return (
|
|
150
|
+
<Text key={i} dimColor>
|
|
151
|
+
{line}
|
|
152
|
+
</Text>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return <Text key={i}>{line}</Text>;
|
|
156
|
+
})}
|
|
157
|
+
</Box>
|
|
158
|
+
<Box flexDirection="column">
|
|
159
|
+
{Array.from({ length: maxLines }, (_, i) => {
|
|
160
|
+
const line = rightLines[i] ?? " ";
|
|
161
|
+
const isHeading = i === 1 || i === 6;
|
|
162
|
+
const isDim = i === 5 || i === 7 || i === 9;
|
|
163
|
+
if (isHeading) {
|
|
164
|
+
return (
|
|
165
|
+
<Text key={i} color={accentColor}>
|
|
166
|
+
{line}
|
|
167
|
+
</Text>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (isDim) {
|
|
171
|
+
return (
|
|
172
|
+
<Text key={i} dimColor>
|
|
173
|
+
{line}
|
|
174
|
+
</Text>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return <Text key={i}>{line}</Text>;
|
|
178
|
+
})}
|
|
179
|
+
</Box>
|
|
180
|
+
</Box>
|
|
181
|
+
<Text dimColor>{"─".repeat(72)}</Text>
|
|
182
|
+
<Text> </Text>
|
|
183
|
+
<Text dimColor> ? for shortcuts</Text>
|
|
184
|
+
<Text> </Text>
|
|
185
|
+
</Box>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const LEFT_PANEL_WIDTH = 36;
|
|
190
|
+
|
|
191
|
+
export function render(runtimeUrl: string, assistantId: string, species: Species): number {
|
|
192
|
+
const config = SPECIES_CONFIG[species];
|
|
193
|
+
const art = config.art;
|
|
194
|
+
|
|
195
|
+
const leftLineCount = 3 + art.length + 3;
|
|
196
|
+
const rightLineCount = 11;
|
|
197
|
+
const maxLines = Math.max(leftLineCount, rightLineCount);
|
|
198
|
+
|
|
199
|
+
const { unmount } = inkRender(
|
|
200
|
+
<DefaultMainScreen runtimeUrl={runtimeUrl} assistantId={assistantId} species={species} />,
|
|
201
|
+
{ exitOnCtrlC: false },
|
|
202
|
+
);
|
|
203
|
+
unmount();
|
|
204
|
+
|
|
205
|
+
const statusCanvasLine = rightLineCount + 1;
|
|
206
|
+
const statusCol = LEFT_PANEL_WIDTH + 1;
|
|
207
|
+
checkHealth(runtimeUrl)
|
|
208
|
+
.then((health) => {
|
|
209
|
+
const statusText = health.detail
|
|
210
|
+
? `${withStatusEmoji(health.status)} (${health.detail})`
|
|
211
|
+
: withStatusEmoji(health.status);
|
|
212
|
+
process.stdout.write(`\x1b7\x1b[${statusCanvasLine};${statusCol}H\x1b[K${statusText}\x1b8`);
|
|
213
|
+
})
|
|
214
|
+
.catch(() => {});
|
|
215
|
+
|
|
216
|
+
return 1 + maxLines + 4;
|
|
217
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { hatch } from "./commands/hatch";
|
|
4
|
+
|
|
5
|
+
const commands = {
|
|
6
|
+
hatch,
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
type CommandName = keyof typeof commands;
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const commandName = args[0];
|
|
14
|
+
|
|
15
|
+
if (!commandName || commandName === "--help" || commandName === "-h") {
|
|
16
|
+
console.log("Usage: vellum-cli <command> [options]");
|
|
17
|
+
console.log("");
|
|
18
|
+
console.log("Commands:");
|
|
19
|
+
console.log(" hatch Create a new assistant instance");
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const command = commands[commandName as CommandName];
|
|
24
|
+
|
|
25
|
+
if (!command) {
|
|
26
|
+
console.error(`Error: Unknown command '${commandName}'`);
|
|
27
|
+
console.error("Run 'vellum-cli --help' for usage information.");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await command();
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error("Error:", error instanceof Error ? error.message : error);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
main();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const FIREWALL_TAG = "vellum-assistant";
|
|
2
|
+
export const GATEWAY_PORT = 7830;
|
|
3
|
+
export const VALID_REMOTE_HOSTS = ["local", "gcp", "aws", "custom"] as const;
|
|
4
|
+
export type RemoteHost = (typeof VALID_REMOTE_HOSTS)[number];
|
|
5
|
+
export const VALID_SPECIES = ["openclaw", "vellum"] as const;
|
|
6
|
+
export type Species = (typeof VALID_SPECIES)[number];
|
|
7
|
+
|
|
8
|
+
const ANSI = {
|
|
9
|
+
reset: "\x1b[0m",
|
|
10
|
+
bold: "\x1b[1m",
|
|
11
|
+
red: "\x1b[31m",
|
|
12
|
+
magenta: "\x1b[35m",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
interface SpeciesConfig {
|
|
16
|
+
color: string;
|
|
17
|
+
art: string[];
|
|
18
|
+
hatchedEmoji: string;
|
|
19
|
+
waitingMessages: string[];
|
|
20
|
+
runningMessages: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const SPECIES_CONFIG: Record<Species, SpeciesConfig> = {
|
|
24
|
+
openclaw: {
|
|
25
|
+
color: ANSI.red,
|
|
26
|
+
art: [
|
|
27
|
+
`${ANSI.red} ___${ANSI.reset}`,
|
|
28
|
+
`${ANSI.red} / ${ANSI.reset}${ANSI.bold}o${ANSI.reset}${ANSI.red} \\${ANSI.reset}`,
|
|
29
|
+
`${ANSI.red} | ${ANSI.reset}${ANSI.bold}>${ANSI.reset}${ANSI.red} |${ANSI.reset}`,
|
|
30
|
+
`${ANSI.red} /| |\\${ANSI.reset}`,
|
|
31
|
+
`${ANSI.red} / |___| \\${ANSI.reset}`,
|
|
32
|
+
`${ANSI.red} | / \\ |${ANSI.reset}`,
|
|
33
|
+
`${ANSI.red} |_/ \\_|${ANSI.reset}`,
|
|
34
|
+
`${ANSI.red} V V${ANSI.reset}`,
|
|
35
|
+
`${ANSI.red} |_| |_|${ANSI.reset}`,
|
|
36
|
+
],
|
|
37
|
+
hatchedEmoji: "🦞",
|
|
38
|
+
waitingMessages: [
|
|
39
|
+
"Warming up the egg...",
|
|
40
|
+
"Getting cozy in there...",
|
|
41
|
+
"Preparing the nest...",
|
|
42
|
+
"Gathering shell fragments...",
|
|
43
|
+
],
|
|
44
|
+
runningMessages: [
|
|
45
|
+
"Running startup script...",
|
|
46
|
+
"Teaching the hatchling to code...",
|
|
47
|
+
"Growing stronger...",
|
|
48
|
+
"Almost ready to peek out...",
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
vellum: {
|
|
52
|
+
color: ANSI.magenta,
|
|
53
|
+
art: [
|
|
54
|
+
`${ANSI.magenta} ,___,${ANSI.reset}`,
|
|
55
|
+
`${ANSI.magenta} (${ANSI.reset}${ANSI.bold} O O ${ANSI.reset}${ANSI.magenta})${ANSI.reset}`,
|
|
56
|
+
`${ANSI.magenta} /)${ANSI.reset}${ANSI.bold}V${ANSI.reset}${ANSI.magenta}(\\${ANSI.reset}`,
|
|
57
|
+
`${ANSI.magenta} // \\\\${ANSI.reset}`,
|
|
58
|
+
`${ANSI.magenta} /" "\\${ANSI.reset}`,
|
|
59
|
+
`${ANSI.magenta} ^ ^${ANSI.reset}`,
|
|
60
|
+
],
|
|
61
|
+
hatchedEmoji: "🦉",
|
|
62
|
+
waitingMessages: [
|
|
63
|
+
"Warming up the nest...",
|
|
64
|
+
"Getting cozy in there...",
|
|
65
|
+
"Fluffing the feathers...",
|
|
66
|
+
"Preening in the moonlight...",
|
|
67
|
+
],
|
|
68
|
+
runningMessages: [
|
|
69
|
+
"Running startup script...",
|
|
70
|
+
"Teaching the owlet to code...",
|
|
71
|
+
"Spreading wings...",
|
|
72
|
+
"Almost ready to take flight...",
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
};
|
package/src/lib/gcp.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { exec, execOutput } from "./step-runner";
|
|
2
|
+
|
|
3
|
+
export async function getActiveProject(): Promise<string> {
|
|
4
|
+
const output = await execOutput("gcloud", [
|
|
5
|
+
"config",
|
|
6
|
+
"get-value",
|
|
7
|
+
"project",
|
|
8
|
+
]);
|
|
9
|
+
const project = output.trim();
|
|
10
|
+
if (!project || project === "(unset)") {
|
|
11
|
+
throw new Error(
|
|
12
|
+
"No active GCP project. Run `gcloud config set project <project>` first.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return project;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FirewallRuleSpec {
|
|
19
|
+
name: string;
|
|
20
|
+
direction: "INGRESS" | "EGRESS";
|
|
21
|
+
action: "ALLOW" | "DENY";
|
|
22
|
+
rules: string;
|
|
23
|
+
sourceRanges?: string;
|
|
24
|
+
destinationRanges?: string;
|
|
25
|
+
targetTags: string;
|
|
26
|
+
description: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FirewallRuleState {
|
|
30
|
+
name: string;
|
|
31
|
+
direction: string;
|
|
32
|
+
allowed: string;
|
|
33
|
+
sourceRanges: string;
|
|
34
|
+
destinationRanges: string;
|
|
35
|
+
targetTags: string;
|
|
36
|
+
description: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function describeFirewallRule(
|
|
40
|
+
ruleName: string,
|
|
41
|
+
project: string,
|
|
42
|
+
): Promise<FirewallRuleState | null> {
|
|
43
|
+
try {
|
|
44
|
+
const output = await execOutput("gcloud", [
|
|
45
|
+
"compute",
|
|
46
|
+
"firewall-rules",
|
|
47
|
+
"describe",
|
|
48
|
+
ruleName,
|
|
49
|
+
`--project=${project}`,
|
|
50
|
+
"--format=json(name,direction,allowed,sourceRanges,destinationRanges,targetTags,description)",
|
|
51
|
+
]);
|
|
52
|
+
const parsed = JSON.parse(output);
|
|
53
|
+
const allowed = (parsed.allowed ?? [])
|
|
54
|
+
.map((a: { IPProtocol: string; ports?: string[] }) => {
|
|
55
|
+
const ports = a.ports ?? [];
|
|
56
|
+
if (ports.length === 0) {
|
|
57
|
+
return a.IPProtocol;
|
|
58
|
+
}
|
|
59
|
+
return ports.map((p: string) => `${a.IPProtocol}:${p}`).join(",");
|
|
60
|
+
})
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join(",");
|
|
63
|
+
return {
|
|
64
|
+
name: parsed.name ?? "",
|
|
65
|
+
direction: parsed.direction ?? "",
|
|
66
|
+
allowed,
|
|
67
|
+
sourceRanges: (parsed.sourceRanges ?? []).join(","),
|
|
68
|
+
destinationRanges: (parsed.destinationRanges ?? []).join(","),
|
|
69
|
+
targetTags: (parsed.targetTags ?? []).join(","),
|
|
70
|
+
description: parsed.description ?? "",
|
|
71
|
+
};
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ruleNeedsUpdate(spec: FirewallRuleSpec, state: FirewallRuleState): boolean {
|
|
78
|
+
return (
|
|
79
|
+
spec.direction !== state.direction ||
|
|
80
|
+
spec.rules !== state.allowed ||
|
|
81
|
+
(spec.sourceRanges ?? "") !== state.sourceRanges ||
|
|
82
|
+
(spec.destinationRanges ?? "") !== state.destinationRanges ||
|
|
83
|
+
spec.targetTags !== state.targetTags ||
|
|
84
|
+
spec.description !== state.description
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function createFirewallRule(spec: FirewallRuleSpec, project: string): Promise<void> {
|
|
89
|
+
const args = [
|
|
90
|
+
"compute",
|
|
91
|
+
"firewall-rules",
|
|
92
|
+
"create",
|
|
93
|
+
spec.name,
|
|
94
|
+
`--project=${project}`,
|
|
95
|
+
`--direction=${spec.direction}`,
|
|
96
|
+
`--action=${spec.action}`,
|
|
97
|
+
`--rules=${spec.rules}`,
|
|
98
|
+
`--target-tags=${spec.targetTags}`,
|
|
99
|
+
`--description=${spec.description}`,
|
|
100
|
+
];
|
|
101
|
+
if (spec.sourceRanges) {
|
|
102
|
+
args.push(`--source-ranges=${spec.sourceRanges}`);
|
|
103
|
+
}
|
|
104
|
+
if (spec.destinationRanges) {
|
|
105
|
+
args.push(`--destination-ranges=${spec.destinationRanges}`);
|
|
106
|
+
}
|
|
107
|
+
await exec("gcloud", args);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function deleteFirewallRule(ruleName: string, project: string): Promise<void> {
|
|
111
|
+
await exec("gcloud", [
|
|
112
|
+
"compute",
|
|
113
|
+
"firewall-rules",
|
|
114
|
+
"delete",
|
|
115
|
+
ruleName,
|
|
116
|
+
`--project=${project}`,
|
|
117
|
+
"--quiet",
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function syncFirewallRules(
|
|
122
|
+
desiredRules: FirewallRuleSpec[],
|
|
123
|
+
project: string,
|
|
124
|
+
tag: string,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
let existingNames: string[];
|
|
127
|
+
try {
|
|
128
|
+
const output = await execOutput("gcloud", [
|
|
129
|
+
"compute",
|
|
130
|
+
"firewall-rules",
|
|
131
|
+
"list",
|
|
132
|
+
`--project=${project}`,
|
|
133
|
+
`--filter=targetTags:${tag}`,
|
|
134
|
+
"--format=value(name)",
|
|
135
|
+
]);
|
|
136
|
+
existingNames = output
|
|
137
|
+
.split("\n")
|
|
138
|
+
.map((s) => s.trim())
|
|
139
|
+
.filter(Boolean);
|
|
140
|
+
} catch {
|
|
141
|
+
existingNames = [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const desiredNames = new Set(desiredRules.map((r) => r.name));
|
|
145
|
+
|
|
146
|
+
for (const existingName of existingNames) {
|
|
147
|
+
if (!desiredNames.has(existingName)) {
|
|
148
|
+
console.log(` 🗑️ Deleting stale firewall rule: ${existingName}`);
|
|
149
|
+
await deleteFirewallRule(existingName, project);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const spec of desiredRules) {
|
|
154
|
+
const state = await describeFirewallRule(spec.name, project);
|
|
155
|
+
|
|
156
|
+
if (!state) {
|
|
157
|
+
console.log(` ➕ Creating firewall rule: ${spec.name}`);
|
|
158
|
+
await createFirewallRule(spec, project);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (ruleNeedsUpdate(spec, state)) {
|
|
163
|
+
console.log(` 🔄 Updating firewall rule: ${spec.name}`);
|
|
164
|
+
await deleteFirewallRule(spec.name, project);
|
|
165
|
+
await createFirewallRule(spec, project);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log(` ✅ Firewall rule up to date: ${spec.name}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function fetchFirewallRules(
|
|
174
|
+
project: string,
|
|
175
|
+
tag: string,
|
|
176
|
+
): Promise<string> {
|
|
177
|
+
const output = await execOutput("gcloud", [
|
|
178
|
+
"compute",
|
|
179
|
+
"firewall-rules",
|
|
180
|
+
"list",
|
|
181
|
+
`--project=${project}`,
|
|
182
|
+
`--filter=targetTags:${tag}`,
|
|
183
|
+
"--format=json",
|
|
184
|
+
]);
|
|
185
|
+
return output;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface GcpInstance {
|
|
189
|
+
name: string;
|
|
190
|
+
zone: string;
|
|
191
|
+
externalIp: string | null;
|
|
192
|
+
species: string | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function listAssistantInstances(project: string): Promise<GcpInstance[]> {
|
|
196
|
+
const output = await execOutput("gcloud", [
|
|
197
|
+
"compute",
|
|
198
|
+
"instances",
|
|
199
|
+
"list",
|
|
200
|
+
`--project=${project}`,
|
|
201
|
+
"--filter=labels.vellum-assistant=true",
|
|
202
|
+
"--format=json(name,zone,networkInterfaces[0].accessConfigs[0].natIP,labels)",
|
|
203
|
+
]);
|
|
204
|
+
const parsed = JSON.parse(output) as Array<{
|
|
205
|
+
name: string;
|
|
206
|
+
zone: string;
|
|
207
|
+
networkInterfaces?: Array<{ accessConfigs?: Array<{ natIP?: string }> }>;
|
|
208
|
+
labels?: Record<string, string>;
|
|
209
|
+
}>;
|
|
210
|
+
return parsed.map((inst) => {
|
|
211
|
+
const zoneParts = (inst.zone ?? "").split("/");
|
|
212
|
+
return {
|
|
213
|
+
name: inst.name,
|
|
214
|
+
zone: zoneParts[zoneParts.length - 1] || "",
|
|
215
|
+
externalIp: inst.networkInterfaces?.[0]?.accessConfigs?.[0]?.natIP ?? null,
|
|
216
|
+
species: inst.labels?.species ?? null,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function instanceExists(
|
|
222
|
+
instanceName: string,
|
|
223
|
+
project: string,
|
|
224
|
+
zone: string,
|
|
225
|
+
): Promise<boolean> {
|
|
226
|
+
try {
|
|
227
|
+
await execOutput("gcloud", [
|
|
228
|
+
"compute",
|
|
229
|
+
"instances",
|
|
230
|
+
"describe",
|
|
231
|
+
instanceName,
|
|
232
|
+
`--project=${project}`,
|
|
233
|
+
`--zone=${zone}`,
|
|
234
|
+
"--format=get(name)",
|
|
235
|
+
]);
|
|
236
|
+
return true;
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function sshCommand(
|
|
243
|
+
instanceName: string,
|
|
244
|
+
project: string,
|
|
245
|
+
zone: string,
|
|
246
|
+
command: string,
|
|
247
|
+
): Promise<string> {
|
|
248
|
+
return execOutput("gcloud", [
|
|
249
|
+
"compute",
|
|
250
|
+
"ssh",
|
|
251
|
+
instanceName,
|
|
252
|
+
`--project=${project}`,
|
|
253
|
+
`--zone=${zone}`,
|
|
254
|
+
"--quiet",
|
|
255
|
+
"--ssh-flag=-o StrictHostKeyChecking=no",
|
|
256
|
+
"--ssh-flag=-o UserKnownHostsFile=/dev/null",
|
|
257
|
+
"--ssh-flag=-o ConnectTimeout=5",
|
|
258
|
+
"--ssh-flag=-o LogLevel=ERROR",
|
|
259
|
+
`--command=${command}`,
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const HEALTH_CHECK_TIMEOUT_MS = 1500;
|
|
2
|
+
|
|
3
|
+
interface HealthResponse {
|
|
4
|
+
status: string;
|
|
5
|
+
message?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface HealthCheckResult {
|
|
9
|
+
status: string;
|
|
10
|
+
detail: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function checkHealth(runtimeUrl: string): Promise<HealthCheckResult> {
|
|
14
|
+
try {
|
|
15
|
+
const url = `${runtimeUrl}/healthz`;
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS);
|
|
18
|
+
|
|
19
|
+
const response = await fetch(url, {
|
|
20
|
+
signal: controller.signal,
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
clearTimeout(timeoutId);
|
|
25
|
+
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
return { status: `error (${response.status})`, detail: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const data = (await response.json()) as HealthResponse;
|
|
31
|
+
const status = data.status || "unknown";
|
|
32
|
+
return { status, detail: status !== "healthy" ? (data.message ?? null) : null };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const status =
|
|
35
|
+
error instanceof Error && error.name === "AbortError" ? "timeout" : "unreachable";
|
|
36
|
+
return { status, detail: null };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
const COMPONENTS_DIR = join(import.meta.dir, "..", "components");
|
|
5
|
+
const CONSTANTS_PATH = join(import.meta.dir, "constants.ts");
|
|
6
|
+
|
|
7
|
+
function inlineLocalImports(source: string): string {
|
|
8
|
+
const constantsSource = readFileSync(CONSTANTS_PATH, "utf-8");
|
|
9
|
+
|
|
10
|
+
return source
|
|
11
|
+
.replace(/import\s*\{[^}]*\}\s*from\s*["'][^"']*\/constants["'];?\s*\n/, constantsSource + "\n")
|
|
12
|
+
.replace(/import\s*\{[^}]*\}\s*from\s*["']path["'];?\s*\n/, "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildInterfacesSeed(): string {
|
|
16
|
+
const rawSource = readFileSync(join(COMPONENTS_DIR, "DefaultMainScreen.tsx"), "utf-8");
|
|
17
|
+
const mainWindowSource = inlineLocalImports(rawSource);
|
|
18
|
+
|
|
19
|
+
return `
|
|
20
|
+
INTERFACES_SEED_DIR="/tmp/interfaces-seed"
|
|
21
|
+
mkdir -p "\$INTERFACES_SEED_DIR/tui"
|
|
22
|
+
cat > "\$INTERFACES_SEED_DIR/tui/main-window.tsx" << 'INTERFACES_SEED_EOF'
|
|
23
|
+
${mainWindowSource}INTERFACES_SEED_EOF
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
const ADAPTER_PATH = join(import.meta.dir, "..", "adapters", "openclaw-http-server.ts");
|
|
5
|
+
|
|
6
|
+
export function buildOpenclawRuntimeServer(): string {
|
|
7
|
+
const serverSource = readFileSync(ADAPTER_PATH, "utf-8");
|
|
8
|
+
|
|
9
|
+
return `
|
|
10
|
+
cat > /opt/openclaw-runtime-server.ts << 'RUNTIME_SERVER_EOF'
|
|
11
|
+
${serverSource}
|
|
12
|
+
RUNTIME_SERVER_EOF
|
|
13
|
+
|
|
14
|
+
mkdir -p "\$HOME/.vellum"
|
|
15
|
+
nohup bun run /opt/openclaw-runtime-server.ts >> "\$HOME/.vellum/http-gateway.log" 2>&1 &
|
|
16
|
+
echo "OpenClaw runtime server started (PID: \$!)"
|
|
17
|
+
`;
|
|
18
|
+
}
|