Files
wehub-resource-sync 98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Waiting to run
CLI Smoke Test / smoke-test-linux (24) (push) Waiting to run
CLI Smoke Test / smoke-test-windows (20) (push) Waiting to run
CLI Smoke Test / smoke-test-windows (24) (push) Waiting to run
Expo App TypeScript typecheck / typecheck (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:40:49 +08:00

1072 lines
38 KiB
TypeScript

import * as fs from "fs";
import * as path from "path";
import * as net from "net";
import * as crypto from "crypto";
import { execSync, spawn, spawnSync } from "child_process";
import { pathToFileURL } from "url";
// ============================================================================
// Configuration
// ============================================================================
const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
const ENVIRONMENTS_ROOT = path.join(REPO_ROOT, "environments");
const ENVIRONMENTS_DATA_DIR = path.join(ENVIRONMENTS_ROOT, "data");
const ENVIRONMENTS_DIR = path.join(ENVIRONMENTS_DATA_DIR, "envs");
const CURRENT_ENV_PATH = path.join(ENVIRONMENTS_DATA_DIR, "current.json");
const LAB_RAT_PROJECT_TEMPLATE_DIR = path.join(ENVIRONMENTS_ROOT, "lab-rat-todo-project");
// ============================================================================
// Name generation (expanded from packages/happy-app/sources/utils/generateWorktreeName.ts)
// ============================================================================
const adjectives = [
"clever", "happy", "swift", "bright", "calm",
"bold", "quiet", "brave", "wise", "eager",
"gentle", "quick", "sharp", "smooth", "fresh",
"warm", "cool", "vivid", "lucid", "nimble",
"keen", "fair", "grand", "sleek", "merry",
"noble", "agile", "witty", "crisp", "snug",
"jolly", "lush", "deft", "tidy", "stout",
"plush", "brisk", "prime", "true", "zesty",
];
const nouns = [
"ocean", "forest", "cloud", "star", "river",
"mountain", "valley", "bridge", "beacon", "harbor",
"garden", "meadow", "canyon", "island", "desert",
"glacier", "aurora", "lagoon", "summit", "prairie",
"reef", "grove", "delta", "ridge", "oasis",
"crater", "fjord", "marsh", "bluff", "dune",
"spring", "atlas", "comet", "ember", "frost",
"pearl", "cedar", "maple", "birch", "coral",
];
function randomChoice<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
function generateName(): string {
return `${randomChoice(adjectives)}-${randomChoice(nouns)}`;
}
// ============================================================================
// Port allocation
// ============================================================================
function allocatePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (!addr || typeof addr === "string") {
server.close();
reject(new Error("Failed to allocate port"));
return;
}
const port = addr.port;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
// ============================================================================
// Types
// ============================================================================
export interface EnvironmentConfig {
name: string;
serverPort: number;
expoPort: number;
createdAt: string;
template: string;
projectTemplate: string;
projectPath: string;
authenticatedWebUrl?: string;
cliCommand?: string;
}
interface CurrentConfig {
current: string;
}
// ============================================================================
// Helpers
// ============================================================================
function ensureEnvironmentsDir() {
fs.mkdirSync(ENVIRONMENTS_DIR, { recursive: true });
}
function readCurrentConfig(): CurrentConfig | null {
if (!fs.existsSync(CURRENT_ENV_PATH)) return null;
return JSON.parse(fs.readFileSync(CURRENT_ENV_PATH, "utf-8"));
}
function writeCurrentConfig(current: string) {
fs.mkdirSync(ENVIRONMENTS_DATA_DIR, { recursive: true });
fs.writeFileSync(CURRENT_ENV_PATH, JSON.stringify({ current }, null, 4) + "\n");
}
function readEnvironmentConfig(name: string): EnvironmentConfig {
const configPath = path.join(ENVIRONMENTS_DIR, name, "environment.json");
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
}
function writeEnvironmentConfig(config: EnvironmentConfig) {
const envDir = path.join(ENVIRONMENTS_DIR, config.name);
const configPath = path.join(ENVIRONMENTS_DIR, config.name, "environment.json");
fs.writeFileSync(
configPath,
JSON.stringify({ ...config, cliCommand: buildCliCommand(envDir) }, null, 4) + "\n"
);
fs.writeFileSync(
path.join(envDir, "env.sh"),
buildEnvSh(config.name, envDir, config.serverPort, config.expoPort),
);
writeEnvCommands(envDir);
}
function listEnvironments(): string[] {
if (!fs.existsSync(ENVIRONMENTS_DIR)) return [];
return fs.readdirSync(ENVIRONMENTS_DIR).filter(entry => {
const envJsonPath = path.join(ENVIRONMENTS_DIR, entry, "environment.json");
return fs.existsSync(envJsonPath);
});
}
function ensureLabRatProjectTemplate() {
if (!fs.existsSync(LAB_RAT_PROJECT_TEMPLATE_DIR)) {
throw new Error(`Missing lab-rat project template at ${LAB_RAT_PROJECT_TEMPLATE_DIR}`);
}
}
function copyLabRatProject(envDir: string): string {
ensureLabRatProjectTemplate();
const targetDir = path.join(envDir, "project");
fs.cpSync(LAB_RAT_PROJECT_TEMPLATE_DIR, targetDir, { recursive: true });
return targetDir;
}
function isPortInUse(port: number): boolean {
try {
const result = execSync(`lsof -i tcp:${port} -sTCP:LISTEN -t 2>/dev/null`, { encoding: "utf-8" });
return result.trim().length > 0;
} catch {
return false;
}
}
function readDevAuth(envDir: string): { secret: string; token: string } | null {
const accessKeyPath = path.join(envDir, "cli", "home", "access.key");
if (!fs.existsSync(accessKeyPath)) {
return null;
}
try {
const credentials = JSON.parse(fs.readFileSync(accessKeyPath, "utf-8")) as {
secret?: string;
token?: string;
};
if (!credentials.secret || !credentials.token) {
return null;
}
return {
token: credentials.token,
secret: Buffer.from(credentials.secret, "base64").toString("base64url"),
};
} catch {
return null;
}
}
// ============================================================================
// PID file management
// ============================================================================
function writePidFile(envDir: string, service: string, pid: number): void {
const pidsDir = path.join(envDir, "pids");
fs.mkdirSync(pidsDir, { recursive: true });
fs.writeFileSync(path.join(pidsDir, `${service}.pid`), String(pid));
}
function readPidFile(envDir: string, service: string): number | null {
const pidPath = path.join(envDir, "pids", `${service}.pid`);
if (!fs.existsSync(pidPath)) return null;
const raw = fs.readFileSync(pidPath, "utf-8").trim();
const pid = parseInt(raw, 10);
return isNaN(pid) ? null : pid;
}
function removePidFile(envDir: string, service: string): void {
const pidPath = path.join(envDir, "pids", `${service}.pid`);
try { fs.unlinkSync(pidPath); } catch {}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function killProcess(pid: number): void {
try {
// Kill entire process group (detached processes get their own group)
process.kill(-pid, "SIGTERM");
} catch {
try { process.kill(pid, "SIGTERM"); } catch {}
}
}
async function waitFor(check: () => boolean | Promise<boolean>, timeoutMs: number, label: string): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try { if (await check()) return; } catch {}
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`Timed out waiting for ${label} after ${timeoutMs}ms`);
}
function spawnService(
command: string,
args: string[],
opts: { cwd: string; env: Record<string, string | undefined>; logFile: string },
): number {
fs.mkdirSync(path.dirname(opts.logFile), { recursive: true });
const logFd = fs.openSync(opts.logFile, "a");
const child = spawn(command, args, {
cwd: opts.cwd,
env: opts.env,
stdio: ["ignore", logFd, logFd],
detached: true,
});
child.unref();
fs.closeSync(logFd);
return child.pid!;
}
export const VALID_TEMPLATES = ["authenticated-empty", "empty"] as const;
export type Template = (typeof VALID_TEMPLATES)[number];
export function getEnvironmentDir(name: string): string {
return path.join(ENVIRONMENTS_DIR, name);
}
export function getEnvironmentConfig(name: string): EnvironmentConfig {
return readEnvironmentConfig(name);
}
export function setEnvironmentTemplate(name: string, template: Template): void {
const config = readEnvironmentConfig(name);
writeEnvironmentConfig({ ...config, template });
}
export async function createEnvironment(opts?: { noSwitch?: boolean }): Promise<string> {
ensureEnvironmentsDir();
const existing = new Set(listEnvironments());
let name = generateName();
let attempts = 0;
while (existing.has(name) && attempts < 100) {
name = generateName();
attempts++;
}
if (existing.has(name)) {
throw new Error("Failed to generate a unique environment name after 100 attempts.");
}
const serverPort = await allocatePort();
const expoPort = await allocatePort();
const envDir = path.join(ENVIRONMENTS_DIR, name);
fs.mkdirSync(path.join(envDir, "server", "pglite"), { recursive: true });
fs.mkdirSync(path.join(envDir, "server", "logs"), { recursive: true });
fs.mkdirSync(path.join(envDir, "cli", "home"), { recursive: true });
const projectPath = copyLabRatProject(envDir);
const config: EnvironmentConfig = {
name,
serverPort,
expoPort,
createdAt: new Date().toISOString(),
template: "empty",
projectTemplate: "lab-rat-todo-project",
projectPath,
};
writeEnvironmentConfig(config);
console.log(`Running database migration for ${name}...`);
const migrationEnv = buildEnvVars(envDir, serverPort, expoPort);
const standaloneTs = path.join(REPO_ROOT, "packages", "happy-server", "sources", "standalone.ts");
const result = spawnSync(
"tsx",
[standaloneTs, "migrate"],
{
cwd: path.join(REPO_ROOT, "packages", "happy-server"),
env: { ...process.env, ...migrationEnv },
stdio: "inherit",
}
);
if (result.status !== 0) {
throw new Error(`Migration failed with exit code ${result.status}`);
}
if (!opts?.noSwitch) {
writeCurrentConfig(name);
}
console.log("");
console.log(`Environment created: ${name}`);
console.log(` Server: http://localhost:${serverPort}`);
console.log(` Webapp: http://localhost:${expoPort}`);
console.log(` Project: ${projectPath}`);
console.log("");
const envShRelative = path.relative(process.cwd(), path.join(envDir, "env.sh"));
console.log("Start in separate terminals:");
console.log("");
console.log(` Server: pnpm env:server`);
console.log(` Webapp: pnpm env:web`);
console.log("");
console.log("CLI (from any terminal, anywhere):");
console.log("");
console.log(` One-liner: ${buildCliCommand(envDir)}`);
console.log("");
console.log(` source ${envShRelative}`);
console.log(` happy`);
console.log("");
console.log(`Full env.sh path: ${path.join(envDir, "env.sh")}`);
return name;
}
export async function startEnvironmentServices(name: string): Promise<void> {
const envDir = getEnvironmentDir(name);
const config = readEnvironmentConfig(name);
const envVars = buildEnvVars(envDir, config.serverPort, config.expoPort);
const mergedEnv: Record<string, string | undefined> = { ...process.env, ...envVars };
const serverLogFile = path.join(envDir, "server", "stdout.log");
console.log(`Starting server on port ${config.serverPort}...`);
const serverPid = spawnService("pnpm", ["standalone", "serve"], {
cwd: path.join(REPO_ROOT, "packages", "happy-server"),
env: mergedEnv,
logFile: serverLogFile,
});
writePidFile(envDir, "server", serverPid);
const serverUrl = `http://localhost:${config.serverPort}`;
try {
await waitFor(async () => {
const res = await fetch(`${serverUrl}/`);
return res.ok;
}, 30_000, "server");
} catch {
throw new Error(`Server failed to start. Check logs: ${serverLogFile}`);
}
console.log(` Server is healthy.`);
const webLogFile = path.join(envDir, "web", "stdout.log");
fs.mkdirSync(path.join(envDir, "web"), { recursive: true });
console.log(`Starting web on port ${config.expoPort}...`);
const webPid = spawnService("pnpm", ["web", "--port", String(config.expoPort)], {
cwd: path.join(REPO_ROOT, "packages", "happy-app"),
env: { ...mergedEnv, BROWSER: "none" },
logFile: webLogFile,
});
writePidFile(envDir, "web", webPid);
try {
await waitFor(() => isPortInUse(config.expoPort), 30_000, "web");
} catch {
throw new Error(`Web failed to start. Check logs: ${webLogFile}`);
}
console.log(` Web is listening.`);
}
export async function seedEnvironment(name: string): Promise<void> {
const envDir = getEnvironmentDir(name);
const config = readEnvironmentConfig(name);
const serverUrl = `http://localhost:${config.serverPort}`;
try {
const res = await fetch(`${serverUrl}/`);
if (!res.ok) throw new Error(`Status ${res.status}`);
} catch {
throw new Error(`Server not reachable at ${serverUrl}. Start it first: pnpm env:server`);
}
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const jwk = publicKey.export({ format: "jwk" }) as { x?: string };
const rawPublicKey = Buffer.from(jwk.x || "", "base64url");
const challenge = crypto.randomBytes(32);
const signature = crypto.sign(null, challenge, privateKey);
const toBase64 = (buf: Buffer | Uint8Array) => Buffer.from(buf).toString("base64");
const toBase64Url = (buf: Buffer | Uint8Array) =>
Buffer.from(buf).toString("base64url");
const authRes = await fetch(`${serverUrl}/v1/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
publicKey: toBase64(rawPublicKey),
challenge: toBase64(challenge),
signature: toBase64(signature),
}),
});
if (!authRes.ok) {
throw new Error(`Auth failed: ${authRes.status} ${await authRes.text()}`);
}
const { token } = (await authRes.json()) as { token: string };
const secret = crypto.randomBytes(32);
const secretBase64 = toBase64(secret);
const cliHome = path.join(envDir, "cli", "home");
fs.mkdirSync(cliHome, { recursive: true });
fs.writeFileSync(
path.join(cliHome, "access.key"),
JSON.stringify({ secret: secretBase64, token }, null, 2),
);
fs.writeFileSync(
path.join(cliHome, "settings.json"),
JSON.stringify(
{
schemaVersion: 2,
onboardingCompleted: true,
machineId: crypto.randomUUID(),
},
null,
2,
),
);
const authenticatedWebUrl = buildAuthenticatedWebUrl(config.expoPort, token, secretBase64);
writeEnvironmentConfig({ ...config, authenticatedWebUrl });
const daemonStatePath = path.join(envDir, "cli", "home", "daemon.state.json");
if (fs.existsSync(daemonStatePath)) {
try {
const daemonState = JSON.parse(fs.readFileSync(daemonStatePath, "utf-8"));
if (daemonState.pid && isProcessAlive(daemonState.pid)) {
console.log(`Stopping existing daemon (PID ${daemonState.pid})...`);
killProcess(daemonState.pid);
await new Promise(r => setTimeout(r, 1000));
}
} catch {}
}
const envVars = buildEnvVars(envDir, config.serverPort, config.expoPort);
const daemonEnv = { ...process.env, ...envVars };
delete daemonEnv.CLAUDECODE;
const happyBin = path.join(REPO_ROOT, "packages", "happy-cli", "bin", "happy.mjs");
const daemon = spawn("node", [happyBin, "daemon", "start"], {
env: daemonEnv,
stdio: "ignore",
detached: true,
});
daemon.unref();
const machineRegistered = await waitFor(async () => {
const res = await fetch(`${serverUrl}/v1/machines`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return false;
const machines = (await res.json()) as unknown[];
return machines.length > 0;
}, 10_000, "machine registration").then(() => true, () => false);
console.log(` Seeded: credentials written, daemon ${machineRegistered ? "registered" : "starting"}`);
console.log(` Auth URL: ${authenticatedWebUrl}`);
}
export function stopEnvironment(name: string): void {
const envDir = getEnvironmentDir(name);
let killed = 0;
for (const service of ["server", "web"] as const) {
const pid = readPidFile(envDir, service);
if (pid !== null) {
if (isProcessAlive(pid)) {
console.log(`Stopping ${service} (PID ${pid})...`);
killProcess(pid);
killed++;
} else {
console.log(`${service} PID ${pid} already dead.`);
}
removePidFile(envDir, service);
}
}
const daemonStatePath = path.join(envDir, "cli", "home", "daemon.state.json");
if (fs.existsSync(daemonStatePath)) {
try {
const daemonState = JSON.parse(fs.readFileSync(daemonStatePath, "utf-8"));
if (daemonState.pid && isProcessAlive(daemonState.pid)) {
console.log(`Stopping daemon (PID ${daemonState.pid})...`);
killProcess(daemonState.pid);
killed++;
}
} catch {}
}
if (killed === 0) {
console.log(`No running services found for "${name}".`);
} else {
console.log("");
console.log(`Environment "${name}" is down. Stopped ${killed} process(es).`);
}
}
export function removeEnvironment(name: string): void {
const envDir = getEnvironmentDir(name);
const currentConfig = readCurrentConfig();
if (currentConfig?.current === name && fs.existsSync(CURRENT_ENV_PATH)) {
fs.unlinkSync(CURRENT_ENV_PATH);
}
fs.rmSync(envDir, { recursive: true, force: true });
console.log(`Removed environment: ${name}`);
}
// ============================================================================
// Commands
// ============================================================================
async function commandNew(opts?: { noSwitch?: boolean }): Promise<string> {
return createEnvironment(opts);
}
function commandList() {
const envs = listEnvironments();
if (envs.length === 0) {
console.log("No environments. Run `pnpm env:new` to create one.");
return;
}
const currentConfig = readCurrentConfig();
const currentName = currentConfig?.current;
console.log("Environments:");
console.log("");
for (const envName of envs) {
const config = readEnvironmentConfig(envName);
const isCurrent = envName === currentName;
const marker = isCurrent ? " *" : " ";
const serverUp = isPortInUse(config.serverPort);
const expoUp = isPortInUse(config.expoPort);
const serverStatus = serverUp ? "running" : "stopped";
const expoStatus = expoUp ? "running" : "stopped";
const serverUrl = `http://localhost:${config.serverPort}`;
const bundlerUrl = `http://localhost:${config.expoPort}`;
const webAppUrl = config.authenticatedWebUrl ?? bundlerUrl;
console.log(`${marker} ${envName}`);
console.log(` Server: ${serverUrl} (${serverStatus})`);
console.log(` Bundler: ${bundlerUrl} (${expoStatus})`);
console.log(` Web app: ${webAppUrl}`);
console.log(` Created: ${config.createdAt}`);
console.log("");
}
}
function commandUse(name: string) {
const envDir = path.join(ENVIRONMENTS_DIR, name);
if (!fs.existsSync(path.join(envDir, "environment.json"))) {
console.error(`Environment "${name}" not found.`);
console.error(`Available: ${listEnvironments().join(", ") || "(none)"}`);
process.exit(1);
}
writeCurrentConfig(name);
console.log(`Switched to environment: ${name}`);
}
function commandRemove(name: string) {
const envDir = path.join(ENVIRONMENTS_DIR, name);
if (!fs.existsSync(path.join(envDir, "environment.json"))) {
console.error(`Environment "${name}" not found.`);
process.exit(1);
}
// Check if it's the current environment
const currentConfig = readCurrentConfig();
if (currentConfig?.current === name) {
// Clear current
fs.unlinkSync(CURRENT_ENV_PATH);
}
fs.rmSync(envDir, { recursive: true, force: true });
console.log(`Removed environment: ${name}`);
}
function commandCurrent() {
const currentConfig = readCurrentConfig();
if (!currentConfig?.current) {
console.error("No current environment. Run `pnpm env:new` or `pnpm env:use <name>`.");
process.exit(1);
}
const envShPath = path.join(ENVIRONMENTS_DIR, currentConfig.current, "env.sh");
if (!fs.existsSync(envShPath)) {
console.error(`Current environment "${currentConfig.current}" is missing. Run \`pnpm env:new\`.`);
process.exit(1);
}
console.log(envShPath);
const config = readEnvironmentConfig(currentConfig.current);
const webAppUrl = config.authenticatedWebUrl ?? `http://localhost:${config.expoPort}`;
console.log(`\nServer: http://localhost:${config.serverPort}`);
console.log(`Bundler: http://localhost:${config.expoPort}`);
console.log(`Web app: ${webAppUrl}`);
}
function commandRun(service: string, serviceArgs: string[] = []) {
const currentConfig = readCurrentConfig();
if (!currentConfig?.current) {
console.error("No current environment. Run `pnpm env:new` first.");
process.exit(1);
}
const envName = currentConfig.current;
const envDir = path.join(ENVIRONMENTS_DIR, envName);
const envJsonPath = path.join(envDir, "environment.json");
if (!fs.existsSync(envJsonPath)) {
console.error(`Environment "${envName}" not found. Run \`pnpm env:new\`.`);
process.exit(1);
}
const config = readEnvironmentConfig(envName);
const envVars = buildEnvVars(envDir, config.serverPort, config.expoPort);
const mergedEnv = { ...process.env, ...envVars };
switch (service) {
case "server": {
console.log(`Starting server for environment "${envName}" on port ${config.serverPort}...`);
const result = spawnSync(
"pnpm",
["standalone", "serve"],
{
cwd: path.join(REPO_ROOT, "packages", "happy-server"),
env: mergedEnv,
stdio: "inherit",
}
);
process.exit(result.status ?? 1);
break;
}
case "web": {
console.log(`Starting web app for environment "${envName}" on port ${config.expoPort}...`);
const result = spawnSync(
"pnpm",
["web", "--port", String(config.expoPort)],
{
cwd: path.join(REPO_ROOT, "packages", "happy-app"),
// Expo treats `--web` as "open in browser". Disable that for env-managed runs.
env: { ...mergedEnv, BROWSER: "none" },
stdio: "inherit",
}
);
process.exit(result.status ?? 1);
break;
}
case "ios": {
console.log(`Starting iOS app for environment "${envName}"...`);
const result = spawnSync(
"pnpm",
["ios"],
{
cwd: path.join(REPO_ROOT, "packages", "happy-app"),
env: mergedEnv,
stdio: "inherit",
}
);
process.exit(result.status ?? 1);
break;
}
case "android": {
console.log(`Starting Android app for environment "${envName}"...`);
const result = spawnSync(
"pnpm",
["android"],
{
cwd: path.join(REPO_ROOT, "packages", "happy-app"),
env: mergedEnv,
stdio: "inherit",
}
);
process.exit(result.status ?? 1);
break;
}
case "cli": {
console.log(`Starting CLI for environment "${envName}"...`);
const cliBin = path.join(REPO_ROOT, "packages", "happy-cli", "bin", "happy.mjs");
const result = spawnSync(
"node",
[cliBin, ...serviceArgs],
{
env: mergedEnv,
stdio: "inherit",
}
);
process.exit(result.status ?? 1);
break;
}
default:
console.error(`Unknown service: "${service}". Use: server, web, ios, android, cli`);
process.exit(1);
}
}
// ============================================================================
// env.sh builder
// ============================================================================
function buildEnvVars(envDir: string, serverPort: number, expoPort: number): Record<string, string> {
const devAuth = readDevAuth(envDir);
const projectDir = path.join(envDir, "project");
return {
// Server
HANDY_MASTER_SECRET: "happy-dev-secret",
PORT: String(serverPort),
NODE_ENV: "development",
DATA_DIR: path.join(envDir, "server"),
PGLITE_DIR: path.join(envDir, "server", "pglite"),
DATABASE_URL: "",
METRICS_ENABLED: "false",
// App (Expo)
EXPO_PUBLIC_SERVER_URL: `http://localhost:${serverPort}`,
EXPO_PUBLIC_HAPPY_SERVER_URL: `http://localhost:${serverPort}`,
EXPO_PUBLIC_LOG_SERVER_URL: "http://localhost:8787",
EXPO_PORT: String(expoPort),
// CLI
HAPPY_SERVER_URL: `http://localhost:${serverPort}`,
HAPPY_WEBAPP_URL: `http://localhost:${expoPort}`,
HAPPY_HOME_DIR: path.join(envDir, "cli", "home"),
HAPPY_PROJECT_DIR: projectDir,
HAPPY_VARIANT: "dev",
DEBUG: "1",
...(devAuth ? {
EXPO_PUBLIC_DEV_TOKEN: devAuth.token,
EXPO_PUBLIC_DEV_SECRET: devAuth.secret,
} : {}),
};
}
function buildEnvSh(name: string, envDir: string, serverPort: number, expoPort: number): string {
const vars = buildEnvVars(envDir, serverPort, expoPort);
const lines: string[] = [
`# Happy Dev Environment: ${name}`,
`# Generated by environments/environments.ts`,
`# Source this file in your terminal: source ${path.join(envDir, "env.sh")}`,
"",
];
// Group exports by section
lines.push("# Server");
lines.push(`export HANDY_MASTER_SECRET="${vars.HANDY_MASTER_SECRET}"`);
lines.push(`export PORT=${vars.PORT}`);
lines.push(`export NODE_ENV="${vars.NODE_ENV}"`);
lines.push(`export DATA_DIR="${vars.DATA_DIR}"`);
lines.push(`export PGLITE_DIR="${vars.PGLITE_DIR}"`);
lines.push(`export DATABASE_URL=""`);
lines.push(`export METRICS_ENABLED=false`);
lines.push("");
lines.push("# App (Expo)");
lines.push(`export EXPO_PUBLIC_SERVER_URL="${vars.EXPO_PUBLIC_SERVER_URL}"`);
lines.push(`export EXPO_PUBLIC_HAPPY_SERVER_URL="${vars.EXPO_PUBLIC_HAPPY_SERVER_URL}"`);
lines.push(`export EXPO_PUBLIC_LOG_SERVER_URL="${vars.EXPO_PUBLIC_LOG_SERVER_URL}"`);
if (vars.EXPO_PUBLIC_DEV_TOKEN && vars.EXPO_PUBLIC_DEV_SECRET) {
lines.push(`export EXPO_PUBLIC_DEV_TOKEN="${vars.EXPO_PUBLIC_DEV_TOKEN}"`);
lines.push(`export EXPO_PUBLIC_DEV_SECRET="${vars.EXPO_PUBLIC_DEV_SECRET}"`);
}
lines.push(`export EXPO_PORT=${vars.EXPO_PORT}`);
lines.push("");
lines.push("# CLI");
lines.push(`export HAPPY_SERVER_URL="${vars.HAPPY_SERVER_URL}"`);
lines.push(`export HAPPY_WEBAPP_URL="${vars.HAPPY_WEBAPP_URL}"`);
lines.push(`export HAPPY_HOME_DIR="${vars.HAPPY_HOME_DIR}"`);
lines.push(`export HAPPY_PROJECT_DIR="${vars.HAPPY_PROJECT_DIR}"`);
lines.push(`export HAPPY_VARIANT=dev`);
lines.push(`export DEBUG=1`);
lines.push(`export PATH="${path.join(envDir, "bin")}:$PATH"`);
lines.push("");
lines.push("# Commands exposed by this env");
lines.push("# - happy");
lines.push("# - happy-agent");
lines.push("");
return lines.join("\n");
}
function writeEnvCommands(envDir: string): void {
const binDir = path.join(envDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const commands = [
{
name: "happy",
entrypoint: path.join(REPO_ROOT, "packages", "happy-cli", "bin", "happy.mjs"),
},
{
name: "happy-agent",
entrypoint: path.join(REPO_ROOT, "packages", "happy-agent", "bin", "happy-agent.mjs"),
},
];
for (const command of commands) {
const wrapperPath = path.join(binDir, command.name);
const wrapper = [
"#!/usr/bin/env bash",
`exec node ${JSON.stringify(command.entrypoint)} "$@"`,
"",
].join("\n");
fs.writeFileSync(wrapperPath, wrapper);
fs.chmodSync(wrapperPath, 0o755);
}
}
function buildAuthenticatedWebUrl(expoPort: number, token: string, secret: string): string {
const webParams = new URLSearchParams({
dev_token: token,
dev_secret: Buffer.from(secret, "base64").toString("base64url"),
});
return `http://localhost:${expoPort}/?${webParams}`;
}
function buildCliCommand(envDir: string): string {
return `source "${path.join(envDir, "env.sh")}" && happy`;
}
// ============================================================================
// Seed auth
// ============================================================================
async function commandSeed(targetName?: string) {
const envName = targetName ?? readCurrentConfig()?.current;
if (!envName) {
console.error("No current environment. Run `pnpm env:new` first.");
process.exit(1);
}
await seedEnvironment(envName);
}
// ============================================================================
// Up / Down
// ============================================================================
async function commandUp(template: Template, opts?: { noSwitch?: boolean }) {
const envName = await createEnvironment(opts);
const envDir = getEnvironmentDir(envName);
const config = readEnvironmentConfig(envName);
setEnvironmentTemplate(envName, template);
await startEnvironmentServices(envName);
// Seed if template requires it
if (template === "authenticated-empty") {
// Always rebuild CLI so the daemon binary matches this worktree
console.log("Building CLI (needed for daemon)...");
const envVars = buildEnvVars(envDir, config.serverPort, config.expoPort);
const mergedEnv: Record<string, string | undefined> = { ...process.env, ...envVars };
const buildResult = spawnSync("pnpm", ["build"], {
cwd: path.join(REPO_ROOT, "packages", "happy-cli"),
env: mergedEnv,
stdio: "inherit",
});
if (buildResult.status !== 0) {
console.error("CLI build failed.");
process.exit(1);
}
console.log("Seeding auth + starting daemon...");
await seedEnvironment(envName);
}
// Print summary
const finalConfig = readEnvironmentConfig(envName);
console.log("");
console.log(`Environment "${envName}" is up!`);
console.log(` Server: http://localhost:${config.serverPort}`);
console.log(` Web: http://localhost:${config.expoPort}`);
console.log(` Project: ${finalConfig.projectPath}`);
if (finalConfig.authenticatedWebUrl) {
console.log(` Open: ${finalConfig.authenticatedWebUrl}`);
}
if (finalConfig.cliCommand) {
console.log(` CLI: ${finalConfig.cliCommand}`);
}
console.log(` Logs: ${path.relative(process.cwd(), path.join(envDir, "server", "stdout.log"))}`);
console.log(` ${path.relative(process.cwd(), path.join(envDir, "web", "stdout.log"))}`);
console.log(` Stop: pnpm env:down`);
console.log("");
}
function commandDown(targetName?: string) {
const envName = targetName ?? readCurrentConfig()?.current;
if (!envName) {
console.error("No current environment. Nothing to stop.");
process.exit(1);
}
stopEnvironment(envName);
}
// ============================================================================
// Tailscale
// ============================================================================
function commandTailscale() {
const currentConfig = readCurrentConfig();
if (!currentConfig?.current) {
console.error("No current environment. Run `pnpm env:new` first.");
process.exit(1);
}
const config = readEnvironmentConfig(currentConfig.current);
// Get tailscale hostname
let hostname: string;
try {
const statusJson = execSync("tailscale status --self --json", { encoding: "utf-8" });
const status = JSON.parse(statusJson);
hostname = status.Self.DNSName.replace(/\.$/, "");
} catch {
console.error("Failed to get Tailscale hostname. Is Tailscale running?");
process.exit(1);
}
// Reset existing funnels
try { execSync("tailscale funnel reset", { stdio: "ignore" }); } catch {}
// Expose web app on 443 and server on 8443
try {
execSync(`tailscale funnel --bg ${config.expoPort}`, { stdio: "inherit" });
execSync(`tailscale funnel --bg --https=8443 ${config.serverPort}`, { stdio: "inherit" });
} catch (e: any) {
console.error("Failed to set up Tailscale funnel:", e.message);
process.exit(1);
}
console.log("");
console.log(`Tailscale funnel active for "${currentConfig.current}":`);
console.log("");
console.log(` Web: https://${hostname}`);
console.log(` Server: https://${hostname}:8443`);
console.log("");
}
// ============================================================================
// CLI entry point
// ============================================================================
async function main(): Promise<void> {
const [subcommand, ...args] = process.argv.slice(2);
switch (subcommand) {
case "new": {
const noSwitch = args.includes("--no-switch");
await commandNew({ noSwitch });
break;
}
case "list":
commandList();
break;
case "use":
if (!args[0]) {
console.error("Usage: pnpm env:use <name>");
process.exit(1);
}
commandUse(args[0]);
break;
case "remove":
if (!args[0]) {
console.error("Usage: pnpm env:remove <name>");
process.exit(1);
}
commandRemove(args[0]);
break;
case "current":
commandCurrent();
break;
case "run":
if (!args[0]) {
console.error("Usage: pnpm env:server | pnpm env:web | pnpm env:cli");
process.exit(1);
}
commandRun(args[0], args.slice(1));
break;
case "seed":
await commandSeed();
break;
case "up": {
const templateIdx = args.indexOf("--template");
const template = templateIdx !== -1 ? args[templateIdx + 1] : undefined;
if (!template || !VALID_TEMPLATES.includes(template as Template)) {
console.error(`Usage: pnpm env:up --template <${VALID_TEMPLATES.join("|")}>`);
process.exit(1);
}
const noSwitch = args.includes("--no-switch");
await commandUp(template as Template, { noSwitch });
break;
}
case "down":
commandDown(args[0]);
break;
case "tailscale":
commandTailscale();
break;
default:
console.log(`Happy Environment Manager
Usage:
pnpm env:up --template <t> Create + start everything (templates: ${VALID_TEMPLATES.join(", ")})
pnpm env:up:authenticated Create + start everything with the authenticated template
pnpm env:down Stop all services for current environment
pnpm env:new Create a new isolated dev environment
pnpm env:list List all environments with status
pnpm env:use <name> Switch to a different environment
pnpm env:remove <name> Delete an environment
pnpm env:current Print current environment's env.sh path
pnpm env:seed Seed auth for CLI + web (requires server running)
pnpm env:server Start the server (current environment)
pnpm env:web Start the web app (current environment)
pnpm env:ios Start the iOS app (current environment)
pnpm env:android Start the Android app (current environment)
pnpm env:cli Start the CLI (current environment)
pnpm env:tailscale Expose server + web via Tailscale funnel
`);
if (subcommand && subcommand !== "--help" && subcommand !== "-h") {
process.exit(1);
}
}
}
const executedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
if (executedPath === import.meta.url) {
main().catch(err => {
console.error(err);
process.exit(1);
});
}