clawhub/scripts/dev-worktree.ts
2026-05-06 15:23:03 -03:00

331 lines
8.9 KiB
TypeScript

#!/usr/bin/env bun
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
type Options = {
port: string;
envFile: string | null;
seed: boolean;
seedOnly: boolean;
};
const DEFAULT_ENV_SOURCES = [".env.local"];
const CONVEX_START_TIMEOUT_MS = 120_000;
const CONVEX_FUNCTIONS_READY_TIMEOUT_MS = 120_000;
const REACHABILITY_POLL_MS = 500;
const managedChildren = new Set<ChildProcess>();
function parseArgs(argv: string[]): Options {
const options: Options = {
port: "3000",
envFile: process.env.CLAWHUB_ENV_FILE ?? null,
seed: false,
seedOnly: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--seed") {
options.seed = true;
} else if (arg === "--seed-only") {
options.seed = true;
options.seedOnly = true;
} else if (arg === "--port") {
options.port = argv[index + 1] ?? options.port;
index += 1;
} else if (arg.startsWith("--port=")) {
options.port = arg.slice("--port=".length);
} else if (arg === "--env-file") {
options.envFile = argv[index + 1] ?? options.envFile;
index += 1;
} else if (arg.startsWith("--env-file=")) {
options.envFile = arg.slice("--env-file=".length);
}
}
return options;
}
export function parseGitWorktreeList(text: string) {
return text
.split(/\r?\n/)
.filter((line) => line.startsWith("worktree "))
.map((line) => line.slice("worktree ".length).trim())
.filter(Boolean);
}
function listGitWorktrees() {
const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
cwd: process.cwd(),
encoding: "utf8",
});
if (result.status !== 0 || typeof result.stdout !== "string") return [];
return parseGitWorktreeList(result.stdout);
}
export function buildEnvFileCandidates(options: {
explicit: string | null;
cwd: string;
worktrees?: string[];
}) {
if (options.explicit) return [options.explicit];
const primaryWorktree = options.worktrees?.[0];
return [
...DEFAULT_ENV_SOURCES,
...(primaryWorktree && resolve(primaryWorktree) !== resolve(options.cwd)
? [`${primaryWorktree}/.env.local`]
: []),
];
}
function findEnvFile(explicit: string | null) {
const candidates = buildEnvFileCandidates({
explicit,
cwd: process.cwd(),
worktrees: explicit ? [] : listGitWorktrees(),
});
return candidates
.map((candidate) => resolve(candidate))
.find((candidate) => existsSync(candidate));
}
function stripInlineComment(value: string) {
let quote: '"' | "'" | null = null;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
const previous = value[index - 1];
if ((char === '"' || char === "'") && previous !== "\\") {
quote = quote === char ? null : (quote ?? char);
continue;
}
if (char === "#" && quote === null && (index === 0 || /\s/.test(previous ?? ""))) {
return value.slice(0, index).trimEnd();
}
}
return value;
}
export function parseEnv(text: string) {
const env: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
if (!match) continue;
const [, key, rawValue] = match;
let value = stripInlineComment(rawValue.trim());
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
async function loadEnv(envFile: string) {
const parsed = parseEnv(await readFile(envFile, "utf8"));
for (const [key, value] of Object.entries(parsed)) {
process.env[key] = value;
}
return parsed;
}
async function isReachable(url: string) {
try {
const response = await fetch(url, { method: "GET" });
return response.status < 500;
} catch {
return false;
}
}
function runSync(command: string, args: string[], extraEnv: Record<string, string | undefined>) {
return (
spawnSync(command, args, {
cwd: process.cwd(),
env: { ...process.env, ...extraEnv },
stdio: "inherit",
}).status ?? 1
);
}
function runSyncBuffered(
command: string,
args: string[],
extraEnv: Record<string, string | undefined>,
) {
const result = spawnSync(command, args, {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, ...extraEnv },
});
return {
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
status: result.status ?? 1,
};
}
function writeBufferedOutput(output: string) {
if (output) process.stdout.write(output);
}
export function isConvexFunctionUnavailableOutput(output: string) {
return (
output.includes("Could not find function for") &&
output.includes("Did you forget to run `npx convex dev`")
);
}
async function runConvexFunctionWhenReady(args: string[]) {
const startedAt = Date.now();
while (true) {
const result = runSyncBuffered("bunx", args, {});
if (result.status === 0) {
writeBufferedOutput(result.output);
return 0;
}
if (
!isConvexFunctionUnavailableOutput(result.output) ||
Date.now() - startedAt >= CONVEX_FUNCTIONS_READY_TIMEOUT_MS
) {
writeBufferedOutput(result.output);
return result.status;
}
console.log("Convex functions are not queryable yet; retrying...");
await sleep(REACHABILITY_POLL_MS);
}
}
function spawnManaged(command: string, args: string[]) {
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
managedChildren.add(child);
child.once("exit", () => managedChildren.delete(child));
return child;
}
function stopManagedChildren() {
for (const child of managedChildren) {
if (!child.killed) child.kill("SIGTERM");
}
}
function waitForExit(child: ChildProcess) {
return new Promise<number>((resolve) => {
child.once("exit", (code, signal) => {
if (typeof code === "number") {
resolve(code);
} else {
resolve(signal === "SIGINT" ? 130 : 1);
}
});
});
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitUntilReachable(url: string, timeoutMs: number) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isReachable(url)) return true;
await sleep(REACHABILITY_POLL_MS);
}
return false;
}
async function ensureConvex(convexUrl: string) {
if (await isReachable(convexUrl)) return;
console.log(`Convex is not reachable at ${convexUrl}.`);
console.log("Starting local Convex with: bunx convex dev --typecheck=disable");
const convex = spawnManaged("bunx", ["convex", "dev", "--typecheck=disable"]);
if (!(await waitUntilReachable(convexUrl, CONVEX_START_TIMEOUT_MS))) {
stopManagedChildren();
console.error(`Convex did not become reachable at ${convexUrl}.`);
console.error("If Convex is prompting for setup, finish that setup and rerun this command.");
process.exit(await waitForExit(convex));
}
}
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => {
stopManagedChildren();
process.exit(signal === "SIGINT" ? 130 : 143);
});
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const envFile = findEnvFile(options.envFile);
if (!envFile) {
console.error(
"Could not find .env.local in this checkout or the primary git worktree. Pass --env-file <path> or set CLAWHUB_ENV_FILE to a shared local env file.",
);
process.exit(1);
}
await loadEnv(envFile);
const convexUrl = process.env.VITE_CONVEX_URL;
if (!convexUrl) {
console.error(`${basename(envFile)} is missing VITE_CONVEX_URL.`);
process.exit(1);
}
if (!existsSync("node_modules/.bin/vite")) {
console.log("Installing dependencies for this worktree...");
const installStatus = runSync("bun", ["install"], {});
if (installStatus !== 0) process.exit(installStatus);
}
await ensureConvex(convexUrl);
if (options.seed) {
console.log("Seeding sample skills...");
const seedStatus = await runConvexFunctionWhenReady([
"convex",
"run",
"--no-push",
"devSeed:seedNixSkills",
]);
if (seedStatus !== 0) process.exit(seedStatus);
const statsStatus = await runConvexFunctionWhenReady([
"convex",
"run",
"--no-push",
"statsMaintenance:updateGlobalStatsAction",
]);
if (statsStatus !== 0) process.exit(statsStatus);
}
if (options.seedOnly) {
stopManagedChildren();
return;
}
console.log(`Starting ClawHub from ${process.cwd()}`);
console.log(`Using env file: ${envFile}`);
const vite = spawnManaged("bun", ["--bun", "vite", "dev", "--port", options.port]);
process.exit(await waitForExit(vite));
}
if (import.meta.main) {
await main();
}