Files
clawhub/scripts/setup-worktree.ts
T
Patrick Erichsen 2aa2a449e5 chore: route worktree startup through Worktrunk (#2297)
* fix: add worktree preflight checks

* fix: remove worktree preflight spec note

* chore: compare worktrunk worktree startup

* fix: stop worktrunk dev process group

* chore: route worktree startup through worktrunk

* chore: keep dev seeding as project command

* chore: clarify worktrunk worktree script boundaries

* fix: keep worktree CI green after main merge
2026-05-18 14:02:14 -07:00

233 lines
6.8 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; site?: 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 isLocalHost(hostname: string) {
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "0.0.0.0" ||
hostname === "[::1]"
);
}
function validateLocalSiteUrl(name: string, value: string | undefined, expectedPort: number) {
if (!value) {
return `${name} is required for local Convex HTTP routes; set it to http://127.0.0.1:${expectedPort}`;
}
let url: URL;
try {
url = new URL(value);
} catch {
return `${name} is not a valid URL`;
}
if (!isLocalHost(url.hostname)) return null;
const port = Number(url.port);
if (!port) return `${name} must include local site proxy port ${expectedPort}`;
if (port !== expectedPort) {
return `${name} port ${port} does not match ${LOCAL_CONVEX_CONFIG} site port ${expectedPort}`;
}
return null;
}
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";
}
}
const sitePort = source.convexConfig.ports?.site ?? (configPort ? configPort + 1 : null);
if (sitePort) {
const invalidViteSiteUrl = validateLocalSiteUrl(
"VITE_CONVEX_SITE_URL",
source.env.VITE_CONVEX_SITE_URL,
sitePort,
);
if (invalidViteSiteUrl) return invalidViteSiteUrl;
const invalidServerSiteUrl = validateLocalSiteUrl(
"CONVEX_SITE_URL",
source.env.CONVEX_SITE_URL,
sitePort,
);
if (invalidServerSiteUrl) return invalidServerSiteUrl;
}
}
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 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);
if (!options.quiet) {
console.log(`Worktree env 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);
}
}