clawhub/scripts/setup-worktree.ts
2026-05-06 13:37:09 -07:00

206 lines
6.4 KiB
TypeScript

#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { existsSync, lstatSync, readFileSync, rmSync, symlinkSync } from "node:fs";
import { basename, resolve } from "node:path";
type Options = {
from: string | null;
force: boolean;
quiet: boolean;
};
type Source = {
path: string;
env: Record<string, string>;
convexConfig: { deploymentName?: string; ports?: { cloud?: number } } | null;
};
const LOCAL_CONVEX_CONFIG = ".convex/local/default/config.json";
function parseArgs(argv: string[]): Options {
const options: Options = {
from: process.env.CLAWHUB_WORKTREE_SOURCE ?? null,
force: false,
quiet: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--from") {
options.from = argv[index + 1] ?? options.from;
index += 1;
} else if (arg.startsWith("--from=")) {
options.from = arg.slice("--from=".length);
} else if (arg === "--force") {
options.force = true;
} else if (arg === "--quiet") {
options.quiet = true;
}
}
return options;
}
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;
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[match[1]] = value.replace(/\s+#.*$/, "");
}
return env;
}
function listGitWorktrees() {
const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
cwd: process.cwd(),
encoding: "utf8",
});
if (result.status !== 0 || typeof result.stdout !== "string") return [];
return result.stdout
.split(/\r?\n/)
.filter((line) => line.startsWith("worktree "))
.map((line) => resolve(line.slice("worktree ".length).trim()))
.filter(Boolean);
}
function readSource(path: string): Source | null {
const envPath = resolve(path, ".env.local");
if (!existsSync(envPath)) return null;
const configPath = resolve(path, LOCAL_CONVEX_CONFIG);
const convexConfig = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : null;
return {
path,
env: parseEnv(readFileSync(envPath, "utf8")),
convexConfig,
};
}
function validateSource(source: Source) {
const deployment = source.env.CONVEX_DEPLOYMENT;
if (!deployment) return "missing CONVEX_DEPLOYMENT";
if (deployment.startsWith("local:")) {
if (!source.convexConfig) return `missing ${LOCAL_CONVEX_CONFIG}`;
const expected = deployment.slice("local:".length);
if (source.convexConfig.deploymentName !== expected) {
return `CONVEX_DEPLOYMENT=${deployment} does not match ${LOCAL_CONVEX_CONFIG} deploymentName=${source.convexConfig.deploymentName}`;
}
const convexUrl = source.env.VITE_CONVEX_URL;
const configPort = source.convexConfig.ports?.cloud;
if (convexUrl && configPort) {
try {
const urlPort = Number(new URL(convexUrl).port);
if (urlPort && urlPort !== configPort) {
return `VITE_CONVEX_URL port ${urlPort} does not match ${LOCAL_CONVEX_CONFIG} cloud port ${configPort}`;
}
} catch {
return "VITE_CONVEX_URL is not a valid URL";
}
}
}
return null;
}
export function findSource(options: Options, cwd = process.cwd()) {
const currentPath = resolve(cwd);
if (!options.from) {
const current = readSource(currentPath);
if (current && !validateSource(current)) return current;
}
const candidates = options.from
? [resolve(options.from)]
: listGitWorktrees().filter((worktree) => worktree !== currentPath);
const rejected: string[] = [];
for (const candidate of candidates) {
const source = readSource(candidate);
if (!source) continue;
const invalid = validateSource(source);
if (!invalid) return source;
rejected.push(`${candidate}: ${invalid}`);
}
const suffix = rejected.length ? `\nRejected sources:\n- ${rejected.join("\n- ")}` : "";
throw new Error(
`Could not find a usable worktree source with .env.local and matching Convex local config.${suffix}`,
);
}
function replaceableLocal(path: string) {
if (!existsSync(path)) return true;
return lstatSync(path).isSymbolicLink();
}
function linkFromSource(name: string, sourcePath: string, force: boolean) {
const target = resolve(process.cwd(), name);
if (resolve(sourcePath) === target) return false;
if (existsSync(target)) {
if (!force && !replaceableLocal(target)) {
throw new Error(
`${name} already exists as a regular local path. Move it aside or rerun setup with --force.`,
);
}
rmSync(target, { force: true, recursive: true });
}
symlinkSync(sourcePath, target, basename(sourcePath) === ".convex" ? "dir" : "file");
return true;
}
function copyOnWriteDirectory(name: string, sourceRoot: string, quiet: boolean) {
const target = resolve(process.cwd(), name);
if (existsSync(target)) return;
const source = resolve(sourceRoot, name);
if (!existsSync(source)) return;
const cpArgs = process.platform === "darwin" ? ["-cR", source, target] : ["-a", source, target];
const result = spawnSync("cp", cpArgs, { stdio: quiet ? "ignore" : "inherit" });
if (result.status !== 0) {
if (!quiet) console.log(`Copy-on-write clone failed for ${name}; running bun install instead.`);
spawnSync("bun", ["install"], { stdio: "inherit" });
}
}
function main() {
const options = parseArgs(process.argv.slice(2));
const source = findSource(options);
linkFromSource(".env.local", resolve(source.path, ".env.local"), options.force);
linkFromSource(".convex", resolve(source.path, ".convex"), options.force);
copyOnWriteDirectory("node_modules", source.path, options.quiet);
if (!existsSync("node_modules/.bin/vite")) {
if (!options.quiet) console.log("Installing dependencies for this worktree...");
const result = spawnSync("bun", ["install"], { stdio: "inherit" });
if (result.status !== 0) process.exit(result.status ?? 1);
}
if (!options.quiet) {
console.log(`Worktree setup complete using ${source.path}`);
}
}
if (import.meta.main) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}