UNPKG

freestyle

Version:

Learn more at [docs.freestyle.sh](https://docs.freestyle.sh)

1,571 lines (1,567 loc) 96.7 kB
#!/usr/bin/env node import * as dotenv from 'dotenv'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { Freestyle, VmSpec, readFiles } from './index.mjs'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { spawn, spawnSync } from 'child_process'; const DEFAULT_STACK_API_URL = "https://api.stack-auth.com"; const DEFAULT_STACK_APP_URL = "https://dash.freestyle.sh"; const DEFAULT_STACK_PROJECT_ID = "0edf478c-f123-46fb-818f-34c0024a9f35"; const DEFAULT_STACK_PUBLISHABLE_CLIENT_KEY = "pck_h2aft7g9pqjzrkdnzs199h1may5wjtdtdxeex7m2wzp1r"; const CLI_AUTH_TIMEOUT_MILLIS = 10 * 60 * 1e3; const POLL_INTERVAL_MILLIS = 2e3; const STACK_REFRESH_TOKEN_ENV_KEY = "FREESTYLE_STACK_REFRESH_TOKEN"; const STACK_SAVE_TO_DOTENV_ENV_KEY = "FREESTYLE_STACK_SAVE_TO_DOTENV"; function isTruthy(value) { if (!value) { return false; } const normalized = value.trim().toLowerCase(); return normalized === "1" || normalized === "true" || normalized === "yes"; } function loadRefreshTokenFromDotenv() { const refreshToken = process.env[STACK_REFRESH_TOKEN_ENV_KEY]; if (!refreshToken || typeof refreshToken !== "string") { return null; } const trimmed = refreshToken.trim(); return trimmed.length > 0 ? trimmed : null; } function shouldSaveToDotenv(options) { if (typeof options?.saveToDotenv === "boolean") { return options.saveToDotenv; } return isTruthy(process.env[STACK_SAVE_TO_DOTENV_ENV_KEY]); } function persistRefreshTokenToDotenv(refreshToken, options) { if (!shouldSaveToDotenv(options)) { return; } const envPath = path.join(process.cwd(), ".env"); const line = `${STACK_REFRESH_TOKEN_ENV_KEY}=${refreshToken}`; let existing = ""; if (fs.existsSync(envPath)) { existing = fs.readFileSync(envPath, "utf-8"); } const pattern = new RegExp(`^${STACK_REFRESH_TOKEN_ENV_KEY}=.*$`, "m"); let next; if (pattern.test(existing)) { next = existing.replace(pattern, line); } else if (existing.length === 0) { next = `${line} `; } else if (existing.endsWith("\n")) { next = `${existing}${line} `; } else { next = `${existing} ${line} `; } fs.writeFileSync(envPath, next, { encoding: "utf-8" }); } function removeRefreshTokenFromDotenv() { const envPath = path.join(process.cwd(), ".env"); if (!fs.existsSync(envPath)) { return false; } const existing = fs.readFileSync(envPath, "utf-8"); const lines = existing.split(/\r?\n/); const nextLines = lines.filter( (line) => !line.startsWith(`${STACK_REFRESH_TOKEN_ENV_KEY}=`) ); if (nextLines.length === lines.length) { return false; } const next = nextLines.filter((line) => line.length > 0).join("\n"); fs.writeFileSync(envPath, next.length > 0 ? `${next} ` : "", { encoding: "utf-8" }); return true; } function walkUpDirectories(startDir) { const result = []; let current = path.resolve(startDir); while (true) { result.push(current); const parent = path.dirname(current); if (parent === current) { break; } current = parent; } return result; } function readEnvFileValue(filePath, key) { if (!fs.existsSync(filePath)) { return void 0; } const content = fs.readFileSync(filePath, "utf-8"); const pattern = new RegExp(`^${key}=(.*)$`, "m"); const match = content.match(pattern); if (!match?.[1]) { return void 0; } return match[1].trim().replace(/^['\"]|['\"]$/g, ""); } function readYamlEnvValue(filePath, envName) { if (!fs.existsSync(filePath)) { return void 0; } const content = fs.readFileSync(filePath, "utf-8"); const escapedEnv = envName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const pattern = new RegExp( `-\\s+name:\\s+${escapedEnv}\\s*[\\r\\n]+\\s*value:\\s*([^\\r\\n#]+)`, "m" ); const match = content.match(pattern); if (!match?.[1]) { return void 0; } return match[1].trim().replace(/^['\"]|['\"]$/g, ""); } function discoverStackConfigFromWorkspace() { const discovered = {}; const roots = walkUpDirectories(process.cwd()); for (const root of roots) { if (!discovered.projectId || !discovered.publishableClientKey) { const dashboardEnv = path.join(root, "freestyle-dashboard", ".env.local"); discovered.projectId ||= readEnvFileValue( dashboardEnv, "VITE_STACK_PROJECT_ID" ); discovered.publishableClientKey ||= readEnvFileValue( dashboardEnv, "VITE_STACK_PUBLISHABLE_CLIENT_KEY" ); } if (!discovered.projectId || !discovered.publishableClientKey) { const adminEnv = path.join(root, "freestyle-sandbox-admin", ".env.local"); discovered.projectId ||= readEnvFileValue( adminEnv, "NEXT_PUBLIC_STACK_PROJECT_ID" ); discovered.publishableClientKey ||= readEnvFileValue( adminEnv, "NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY" ); } if (!discovered.projectId || !discovered.publishableClientKey) { const dashK8s = path.join(root, "k8s", "freestyle-dash.yml"); discovered.projectId ||= readYamlEnvValue( dashK8s, "VITE_STACK_PROJECT_ID" ); discovered.publishableClientKey ||= readYamlEnvValue( dashK8s, "VITE_STACK_PUBLISHABLE_CLIENT_KEY" ); } if (discovered.projectId && discovered.publishableClientKey) { break; } } return discovered; } function resolveAuthFilePath() { return process.env.FREESTYLE_STACK_AUTH_FILE ?? path.join(os.homedir(), ".freestyle", "stack-auth.json"); } function resolveStackConfig() { const discovered = discoverStackConfigFromWorkspace(); const projectId = process.env.FREESTYLE_STACK_PROJECT_ID ?? process.env.NEXT_PUBLIC_STACK_PROJECT_ID ?? process.env.VITE_STACK_PROJECT_ID ?? discovered.projectId ?? DEFAULT_STACK_PROJECT_ID; const publishableClientKey = process.env.FREESTYLE_STACK_PUBLISHABLE_CLIENT_KEY ?? process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY ?? process.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY ?? discovered.publishableClientKey ?? DEFAULT_STACK_PUBLISHABLE_CLIENT_KEY; if (!projectId || !publishableClientKey) { return null; } const stackApiUrl = (process.env.FREESTYLE_STACK_API_URL ?? DEFAULT_STACK_API_URL).replace(/\/+$/, ""); const appUrl = (process.env.FREESTYLE_STACK_APP_URL ?? process.env.FREESTYLE_DASHBOARD_URL ?? DEFAULT_STACK_APP_URL).replace(/\/+$/, ""); const authFilePath = process.env.FREESTYLE_STACK_AUTH_FILE ?? resolveAuthFilePath(); return { stackApiUrl, appUrl, projectId, publishableClientKey, authFilePath }; } function clientHeaders(config) { return { "Content-Type": "application/json", "x-stack-project-id": config.projectId, "x-stack-access-type": "client", "x-stack-publishable-client-key": config.publishableClientKey }; } function loadStoredAuth(config) { try { if (!fs.existsSync(config.authFilePath)) { return null; } const auth = JSON.parse(fs.readFileSync(config.authFilePath, "utf-8")); if (!auth.refreshToken || typeof auth.refreshToken !== "string") { return null; } return { refreshToken: auth.refreshToken, updatedAt: typeof auth.updatedAt === "number" ? auth.updatedAt : Date.now(), defaultTeamId: typeof auth.defaultTeamId === "string" ? auth.defaultTeamId : void 0 }; } catch { return null; } } function persistAuth(config, auth) { const dirPath = path.dirname(config.authFilePath); fs.mkdirSync(dirPath, { recursive: true }); fs.writeFileSync( config.authFilePath, JSON.stringify( { refreshToken: auth.refreshToken, updatedAt: auth.updatedAt, defaultTeamId: auth.defaultTeamId }, null, 2 ), { encoding: "utf-8", mode: 384 } ); } function clearStoredAuth(config) { try { if (fs.existsSync(config.authFilePath)) { fs.unlinkSync(config.authFilePath); } } catch { } } function logoutCliAuth(options) { const authFilePath = resolveAuthFilePath(); let clearedStored = false; try { if (fs.existsSync(authFilePath)) { fs.unlinkSync(authFilePath); clearedStored = true; } } catch { } let clearedDotenv = false; if (options?.removeFromDotenv) { clearedDotenv = removeRefreshTokenFromDotenv(); } delete process.env[STACK_REFRESH_TOKEN_ENV_KEY]; return { clearedStored, clearedDotenv }; } function tryOpenBrowser(url) { try { if (process.platform === "darwin") { const child2 = spawn("open", [url], { stdio: "ignore", detached: true }); child2.unref(); return true; } if (process.platform === "win32") { const child2 = spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }); child2.unref(); return true; } const child = spawn("xdg-open", [url], { stdio: "ignore", detached: true }); child.unref(); return true; } catch { return false; } } async function startCliLogin(config) { const initResponse = await fetch(`${config.stackApiUrl}/api/v1/auth/cli`, { method: "POST", headers: clientHeaders(config), body: JSON.stringify({ expires_in_millis: CLI_AUTH_TIMEOUT_MILLIS }) }); if (!initResponse.ok) { const errorText = await initResponse.text(); throw new Error( `Failed to start authentication login (${initResponse.status}). ${errorText || "Check project ID and client key configuration."}` ); } const initData = await initResponse.json(); if (!initData.polling_code || !initData.login_code) { throw new Error("Authentication login did not return polling/login codes."); } const loginUrl = `${config.appUrl}/handler/cli-auth-confirm?login_code=${encodeURIComponent(initData.login_code)}`; console.log("\nAuthentication is required."); console.log(`Open this URL to continue: ${loginUrl} `); const opened = tryOpenBrowser(loginUrl); if (opened) { console.log("Opened your browser for authentication..."); } else { console.log("Could not open browser automatically. Open the URL manually."); } const deadline = Date.now() + CLI_AUTH_TIMEOUT_MILLIS; while (Date.now() < deadline) { const pollResponse = await fetch( `${config.stackApiUrl}/api/v1/auth/cli/poll`, { method: "POST", headers: clientHeaders(config), body: JSON.stringify({ polling_code: initData.polling_code }) } ); if (![200, 201].includes(pollResponse.status)) { throw new Error( `Failed while polling authentication login (${pollResponse.status}).` ); } const pollData = await pollResponse.json(); if (pollData.status && pollData.status !== "pending") { console.log("Auth poll status:", pollData.status); } if (pollData.status === "completed" || pollData.status === "success") { if (!pollData.refresh_token) { throw new Error("Login completed without a refresh token response."); } return pollData.refresh_token; } if (pollData.status && pollData.status !== "pending" && pollData.status !== "waiting") { throw new Error( pollData.error || `Authentication ${pollData.status}. Please retry.` ); } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MILLIS)); } throw new Error("Timed out waiting for authentication."); } async function refreshStackAccessToken(config, refreshToken) { const response = await fetch( `${config.stackApiUrl}/api/v1/auth/sessions/current/refresh`, { method: "POST", headers: { ...clientHeaders(config), "x-stack-refresh-token": refreshToken }, body: "{}" } ); if (!response.ok) { return null; } const data = await response.json(); if (!data.access_token) { return null; } return { accessToken: data.access_token, refreshToken: data.refresh_token }; } async function getStackAccessTokenForCli(options) { const config = resolveStackConfig(); if (!config) { return null; } let refreshTokenFromEnv = loadRefreshTokenFromDotenv(); const stored = loadStoredAuth(config); if (options?.forceRelogin) { refreshTokenFromEnv = null; clearStoredAuth(config); } let refreshToken = refreshTokenFromEnv ?? stored?.refreshToken; if (!refreshToken) { refreshToken = await startCliLogin(config); const auth = { refreshToken, updatedAt: Date.now() }; persistAuth(config, auth); persistRefreshTokenToDotenv(refreshToken, options); } let refreshed = await refreshStackAccessToken(config, refreshToken); if (!refreshed) { if (!refreshTokenFromEnv) { clearStoredAuth(config); } refreshToken = await startCliLogin(config); const auth = { refreshToken, updatedAt: Date.now(), defaultTeamId: stored?.defaultTeamId }; persistAuth(config, auth); persistRefreshTokenToDotenv(refreshToken, options); refreshed = await refreshStackAccessToken(config, refreshToken); } if (!refreshed) { throw new Error("Failed to authenticate."); } if (refreshed.refreshToken && refreshed.refreshToken !== refreshToken) { const auth = { refreshToken: refreshed.refreshToken, updatedAt: Date.now(), defaultTeamId: stored?.defaultTeamId }; persistAuth(config, auth); persistRefreshTokenToDotenv(refreshed.refreshToken, options); } return refreshed.accessToken; } function getDashboardApiUrl() { return process.env.FREESTYLE_DASHBOARD_URL || "https://dash.freestyle.sh"; } async function callDashboardApi(endpoint, accessToken, body) { const response = await fetch(`${getDashboardApiUrl()}${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: { accessToken, ...body } }) }); if (!response.ok) { throw new Error( `Dashboard API call failed: ${response.status} ${response.statusText}` ); } return response.json(); } async function getTeamsForCli() { const config = resolveStackConfig(); if (!config) { throw new Error( "Stack Auth is not configured. Please check your environment variables." ); } const stored = loadStoredAuth(config); if (!stored?.refreshToken) { throw new Error( "No authentication found. Please run 'npx freestyle@latest login' first." ); } const tokenResponse = await refreshStackAccessToken( config, stored.refreshToken ); if (!tokenResponse) { throw new Error("Failed to refresh access token."); } const teams = await callDashboardApi("/api/cli/teams", tokenResponse.accessToken); return teams; } async function setDefaultTeam(teamId) { const config = resolveStackConfig(); if (!config) { throw new Error( "Stack Auth is not configured. Please check your environment variables." ); } const stored = loadStoredAuth(config); if (!stored?.refreshToken) { throw new Error( "No authentication found. Please run 'npx freestyle@latest login' first." ); } const auth = { refreshToken: stored.refreshToken, updatedAt: Date.now(), defaultTeamId: teamId }; persistAuth(config, auth); } function getDefaultTeamId() { const config = resolveStackConfig(); if (!config) { return void 0; } const stored = loadStoredAuth(config); return stored?.defaultTeamId; } function normalizeCliProxyErrorWithStatus(errorText, status) { const fallbackCode = status === 400 ? "BAD_REQUEST" : status === 401 ? "UNAUTHORIZED_ERROR" : status === 403 ? "FORBIDDEN" : "INTERNAL_ERROR"; try { const parsed = JSON.parse(errorText); if (typeof parsed.code === "string" && typeof parsed.message === "string") { return { body: JSON.stringify(parsed), contentType: "application/json" }; } const message2 = [parsed.error, parsed.message, parsed.reason].find( (value) => typeof value === "string" && value.length > 0 ); if (message2) { const normalized2 = fallbackCode === "UNAUTHORIZED_ERROR" ? { code: fallbackCode, message: message2, route: "/api/proxy/request", reason: message2 } : { code: fallbackCode, message: message2 }; return { body: JSON.stringify(normalized2), contentType: "application/json" }; } } catch { } const message = errorText || "Request failed"; const normalized = fallbackCode === "UNAUTHORIZED_ERROR" ? { code: fallbackCode, message, route: "/api/proxy/request", reason: message } : { code: fallbackCode, message }; return { body: JSON.stringify(normalized), contentType: "application/json" }; } function createProxyFetch(accessToken, teamId) { const dashboardApiUrl = process.env.FREESTYLE_DASHBOARD_URL || "https://dash.freestyle.sh"; return async (url, init) => { const urlObj = typeof url === "string" ? new URL(url) : url instanceof URL ? url : new URL(url.url); const path2 = urlObj.pathname + urlObj.search; const proxyResponse = await fetch(`${dashboardApiUrl}/api/proxy/request`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: { accessToken, teamId, path: path2.startsWith("/") ? path2.substring(1) : path2, method: init?.method || "GET", headers: init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : {}, body: init?.body ? init.body.toString() : void 0 } }) }); if (!proxyResponse.ok) { const errorText = await proxyResponse.text(); const normalizedError = normalizeCliProxyErrorWithStatus( errorText, proxyResponse.status ); return new Response(normalizedError.body, { status: proxyResponse.status, statusText: proxyResponse.statusText, headers: { "Content-Type": normalizedError.contentType } }); } const data = await proxyResponse.json(); return new Response(JSON.stringify(data), { status: 200, headers: { "Content-Type": "application/json" } }); }; } async function getFreestyleClient(teamId) { const directApiKey = process.env.FREESTYLE_API_KEY; if (directApiKey) { const baseUrl2 = process.env.FREESTYLE_API_URL; return new Freestyle({ apiKey: directApiKey, baseUrl: baseUrl2 }); } const accessToken = await getStackAccessTokenForCli(); if (!accessToken) { console.error( "Error: No API key found. Please run 'npx freestyle@latest login' or set FREESTYLE_API_KEY in your .env file." ); process.exit(1); } const resolvedTeamId = process.env.FREESTYLE_TEAM_ID ?? getDefaultTeamId(); if (!resolvedTeamId) { console.error( "Error: No team selected. Please run 'npx freestyle@latest login' to set a default team." ); process.exit(1); } const baseUrl = process.env.FREESTYLE_API_URL || "https://api.freestyle.sh"; return new Freestyle({ apiKey: "placeholder", // Need something to pass validation baseUrl, fetch: createProxyFetch(accessToken, resolvedTeamId) }); } function handleError(error) { if (error.response) { console.error("API Error:", error.response.data); } else if (error.message) { console.error("Error:", error.message); } else { console.error("Error:", error); } process.exit(1); } function loadEnv() { const envPath = path.join(process.cwd(), ".env"); if (fs.existsSync(envPath)) { dotenv.config({ path: envPath, quiet: true }); } } function formatTable(headers, rows) { const colWidths = headers.map((h, i) => { const maxRowWidth = Math.max(...rows.map((r) => (r[i] || "").length)); return Math.max(h.length, maxRowWidth); }); const headerRow = headers.map((h, i) => h.padEnd(colWidths[i] || 0)).join(" "); const separator = colWidths.map((w) => "-".repeat(w)).join(" "); console.log(headerRow); console.log(separator); rows.forEach((row) => { console.log( row.map((cell, i) => (cell || "").padEnd(colWidths[i] || 0)).join(" ") ); }); } const REMOTE_SPEC_RE = /^([a-z0-9]{5,20})(?:\+([^:]+))?:(.*)$/; function parseRemoteSpec(arg) { const match = arg.match(REMOTE_SPEC_RE); if (!match) return null; return { vmId: match[1], user: match[2], path: match[3] }; } function shellQuote(arg) { return `'${arg.replace(/'/g, "'\\''")}'`; } async function scpToFromVm(source, destination, options = {}) { const srcRemote = parseRemoteSpec(source); const dstRemote = parseRemoteSpec(destination); if (!srcRemote && !dstRemote) { console.error( "Error: at least one of <source> or <destination> must be a remote path in the form '<vmId>[+<user>]:<path>'." ); process.exit(1); } if (srcRemote && dstRemote) { console.error( "Error: VM-to-VM transfers are not supported. Only one of <source> or <destination> may be remote." ); process.exit(1); } const remote = srcRemote ?? dstRemote; const { vmId, user, path: remotePath } = remote; const freestyle = await getFreestyleClient(); console.log("Setting up SCP connection..."); const { identity, identityId } = await freestyle.identities.create(); console.log(`Created identity: ${identityId}`); await identity.permissions.vms.grant({ vmId }); const { token, tokenId } = await identity.tokens.create(); const sshUser = user ? `${vmId}+${user},${token}` : `${vmId},${token}`; const remoteHostPath = `${sshUser}@vm-ssh.freestyle.sh:${remotePath}`; const scpSource = srcRemote ? remoteHostPath : source; const scpDestination = dstRemote ? remoteHostPath : destination; const flags = ["-P", "22"]; if (options.recursive) flags.push("-r"); const scpCommand = `scp ${flags.join(" ")} ${shellQuote(scpSource)} ${shellQuote(scpDestination)}`; console.log( `${srcRemote ? "Downloading from" : "Uploading to"} VM ${vmId}...` ); console.log(`Command: ${scpCommand} `); return new Promise((resolve, reject) => { const scpProcess = spawn(scpCommand, { shell: true, stdio: "inherit" }); scpProcess.on("close", async (code) => { console.log( ` SCP transfer ${code === 0 ? "complete" : `failed (exit ${code})`}.` ); try { console.log("Cleaning up identity and token..."); await identity.tokens.revoke({ tokenId }); await freestyle.identities.delete({ identityId }); console.log("\u2713 Cleanup complete"); if (code !== 0) { process.exit(code ?? 1); } resolve(); } catch (error) { console.error("Error during cleanup:", error); reject(error); } }); scpProcess.on("error", (error) => { console.error("Error starting SCP:", error); reject(error); }); }); } async function sshIntoVm(vmId, options = {}) { const freestyle = await getFreestyleClient(); console.log("Setting up SSH connection..."); const { identity, identityId } = await freestyle.identities.create(); console.log(`Created identity: ${identityId}`); await identity.permissions.vms.grant({ vmId }); const { token, tokenId } = await identity.tokens.create(); const sshCommand = `ssh ${vmId}:${token}@vm-ssh.freestyle.sh -p 22`; console.log(`Connecting to VM ${vmId}...`); console.log(`Command: ${sshCommand} `); return new Promise((resolve, reject) => { const sshProcess = spawn(sshCommand, { shell: true, stdio: "inherit" }); sshProcess.on("close", async (code) => { console.log("\nSSH session ended."); try { console.log("Cleaning up identity and token..."); await identity.tokens.revoke({ tokenId }); await freestyle.identities.delete({ identityId }); console.log("\u2713 Cleanup complete"); if (options.deleteOnExit) { console.log(`Deleting VM ${vmId}...`); await freestyle.vms.delete({ vmId }); console.log("\u2713 VM deleted"); } resolve(); } catch (error) { console.error("Error during cleanup:", error); reject(error); } }); sshProcess.on("error", (error) => { console.error("Error starting SSH:", error); reject(error); }); }); } function buildSubcommands(yargs, resolveBuild, idName) { return yargs.command( `get <${idName}>`, "Show the build record", (y) => y.positional(idName, { type: "string", demandOption: true }).option("json", { type: "boolean", default: false }), async (argv) => { loadEnv(); try { const build = await resolveBuild(argv[idName]); const record = await build.get(); console.log(JSON.stringify(record, null, 2)); } catch (e) { handleError(e); } } ).command( `phases <${idName}>`, "List build phases", (y) => y.positional(idName, { type: "string", demandOption: true }).option("json", { type: "boolean", default: false }), async (argv) => { loadEnv(); try { const build = await resolveBuild(argv[idName]); const phases = await build.phases(); if (argv.json) { console.log(JSON.stringify(phases, null, 2)); return; } console.log( formatTable( ["Phase ID", "Name", "Snapshot", "Started"], phases.map((p) => [ p.phaseId, p.name, p.snapshotId ?? "\u2014", p.startedAt ]) ) ); } catch (e) { handleError(e); } } ).command( `debug <${idName}>`, "Boot a debug VM from the failed phase's snapshot and SSH in", (y) => y.positional(idName, { type: "string", demandOption: true }), async (argv) => { loadEnv(); try { const fs = await getFreestyleClient(); const build = await resolveBuild(argv[idName]); const failed = await build.failedPhase(); if (!failed) { console.error( `No bookable failed-phase snapshot for build ${build.buildId} \u2014 nothing to debug.` ); process.exit(1); } console.log( `Failed phase: ${failed.name} (snapshot ${failed.snapshotId})` ); console.log("Booting debug VM\u2026"); const result = await fs.vms.create({ spec: new VmSpec().snapshotId(failed.snapshotId) }); console.log(`\u2713 VM ${result.vmId} running`); await sshIntoVm(result.vmId); } catch (e) { handleError(e); } } ).command( `wait <${idName}>`, "Wait until the build reaches a terminal state", (y) => y.positional(idName, { type: "string", demandOption: true }), async (argv) => { loadEnv(); try { const build = await resolveBuild(argv[idName]); const record = await build.wait(); console.log(JSON.stringify(record, null, 2)); } catch (e) { handleError(e); } } ).demandCommand(1, "Specify a build action"); } const vmCommand = { command: "vm <action>", describe: "Manage Virtual Machines", builder: (yargs) => { return yargs.command( "create", "Create a new VM", (yargs2) => { return yargs2.option("name", { alias: "n", type: "string", description: "VM name/discriminator" }).option("domain", { alias: "d", type: "string", description: "Custom domain to attach" }).option("port", { alias: "p", type: "number", description: "VM port to expose (default: 3000)", default: 3e3 }).option("apt", { type: "array", description: "APT packages to install", default: [] }).option("snapshot", { alias: "s", type: "string", description: "Snapshot ID to create VM from" }).option("exec", { alias: "e", type: "string", description: "Execute a command on the VM after creation" }).option("ssh", { type: "boolean", description: "SSH into VM after creation and delete VM on exit (for debugging)", default: false }).option("delete", { type: "boolean", description: "Delete VM after exec completes or when SSH session ends", default: false }).option("json", { type: "boolean", description: "Output as JSON", default: false }); }, async (argv) => { loadEnv(); const args = argv; try { const freestyle = await getFreestyleClient(); let createOptions = {}; if (args.snapshot) { createOptions.spec = new VmSpec().snapshotId(args.snapshot); } else { const spec = new VmSpec(); if (args.name) { spec.discriminator(args.name); } if (args.apt) { spec.aptDeps(...args.apt); } createOptions.spec = spec; } if (args.domain) { createOptions.domains = [ { domain: args.domain, vmPort: args.port } ]; } console.log("Creating VM..."); const result = await freestyle.vms.create(createOptions); let execResult; if (args.exec) { const vm = freestyle.vms.ref({ vmId: result.vmId }); console.log(`Executing command on VM ${result.vmId}...`); execResult = await vm.exec({ command: args.exec }); } if (args.json && !args.ssh) { if (execResult) { console.log( JSON.stringify( { vm: result, exec: execResult }, null, 2 ) ); } else { console.log(JSON.stringify(result, null, 2)); } } else { console.log("\n\u2713 VM created successfully!"); console.log(` VM ID: ${result.vmId}`); const domainStr = result.domains?.[0]; if (domainStr) { console.log(` Domain: https://${domainStr}`); } if (execResult) { if (execResult.stdout) { console.log("\nExec output:"); console.log(execResult.stdout); } if (execResult.stderr) { console.error("\nExec errors:"); console.error(execResult.stderr); } console.log(` Exec exit code: ${execResult.statusCode || 0}`); } } if (args.ssh) { console.log(""); await sshIntoVm(result.vmId, { deleteOnExit: args.delete }); } else if (args.delete) { console.log(`Deleting VM ${result.vmId}...`); await freestyle.vms.delete({ vmId: result.vmId }); console.log("\u2713 VM deleted"); } } catch (error) { handleError(error); } } ).command( "list", "List all VMs", (yargs2) => { return yargs2.option("json", { type: "boolean", description: "Output as JSON", default: false }); }, async (argv) => { loadEnv(); const args = argv; try { const freestyle = await getFreestyleClient(); const vms = await freestyle.vms.list(); if (args.json) { console.log(JSON.stringify(vms, null, 2)); } else { if (vms.vms.length === 0) { console.log("No VMs found."); return; } const rows = vms.vms.map((vm) => [ vm.id, vm.state || "unknown", vm.createdAt ? new Date(vm.createdAt).toLocaleString() : "N/A" ]); formatTable(["VM ID", "Status", "Created"], rows); } } catch (error) { handleError(error); } } ).command( "ssh <vmId>", "SSH into a VM", (yargs2) => { return yargs2.positional("vmId", { type: "string", description: "VM ID to SSH into", demandOption: true }).option("delete", { type: "boolean", description: "Delete VM when SSH session ends", default: false }); }, async (argv) => { loadEnv(); const args = argv; try { await sshIntoVm(args.vmId, { deleteOnExit: args.delete }); } catch (error) { handleError(error); } } ).command( "scp <source> <destination>", "Copy files to or from a VM via scp (use '<vmId>[+<user>]:<path>' for the remote side)", (yargs2) => { return yargs2.positional("source", { type: "string", description: "Source path. Local path, or '<vmId>[+<user>]:<path>' to download from a VM.", demandOption: true }).positional("destination", { type: "string", description: "Destination path. Local path, or '<vmId>[+<user>]:<path>' to upload to a VM.", demandOption: true }).option("recursive", { alias: "r", type: "boolean", description: "Recursively copy directories", default: false }); }, async (argv) => { loadEnv(); const args = argv; try { await scpToFromVm(args.source, args.destination, { recursive: args.recursive }); } catch (error) { handleError(error); } } ).command( "exec <vmId> <command>", "Execute a command on a VM", (yargs2) => { return yargs2.positional("vmId", { type: "string", description: "VM ID", demandOption: true }).positional("command", { type: "string", description: "Command to execute", demandOption: true }).option("json", { type: "boolean", description: "Output as JSON", default: false }); }, async (argv) => { loadEnv(); const args = argv; try { const freestyle = await getFreestyleClient(); const vm = freestyle.vms.ref({ vmId: args.vmId }); console.log(`Executing command on VM ${args.vmId}...`); const result = await vm.exec({ command: args.command }); if (args.json) { console.log(JSON.stringify(result, null, 2)); } else { if (result.stdout) { console.log("\nOutput:"); console.log(result.stdout); } if (result.stderr) { console.error("\nErrors:"); console.error(result.stderr); } console.log(` Exit code: ${result.statusCode || 0}`); } } catch (error) { handleError(error); } } ).command( "delete <vmId>", "Delete a VM", (yargs2) => { return yargs2.positional("vmId", { type: "string", description: "VM ID to delete", demandOption: true }); }, async (argv) => { loadEnv(); const args = argv; try { const freestyle = await getFreestyleClient(); console.log(`Deleting VM ${args.vmId}...`); await freestyle.vms.delete({ vmId: args.vmId }); console.log("\u2713 VM deleted successfully!"); } catch (error) { handleError(error); } } ).command( "build <action>", "Inspect the build that produced a VM", (yargs2) => buildSubcommands(yargs2, async (vmId) => { const fs = await getFreestyleClient(); return fs.vms.ref({ vmId }).getBuild(); }, "vmId"), () => { } ).command( "snapshot <action>", "Manage snapshots", (yargs2) => yargs2.command( "list", "List snapshots", (y) => y.option("show-failed", { type: "boolean", default: true }).option("show-deleted", { type: "boolean", default: false }).option("show-cancelled", { type: "boolean", default: false }).option("show-lost", { type: "boolean", default: false }).option("json", { type: "boolean", default: false }), async (argv) => { loadEnv(); try { const fs = await getFreestyleClient(); const result = await fs.vms.snapshots.list({ includeFailed: argv["show-failed"], includeDeleted: argv["show-deleted"], includeCancelled: argv["show-cancelled"], includeLost: argv["show-lost"] }); if (argv.json) { console.log(JSON.stringify(result, null, 2)); return; } console.log( formatTable( ["Snapshot ID", "Name", "Source VM", "State", "Created"], result.snapshots.map((s) => [ s.snapshotId, s.name ?? "\u2014", s.sourceVmId ?? "\u2014", s.state ?? (s.failed ? "failed" : "ready"), s.createdAt ]) ) ); } catch (e) { handleError(e); } } ).command( "get <snapshotId>", "Show a snapshot record", (y) => y.positional("snapshotId", { type: "string", demandOption: true }).option("json", { type: "boolean", default: false }), async (argv) => { loadEnv(); try { const fs = await getFreestyleClient(); const info = await fs.vms.snapshots.ref({ snapshotId: argv.snapshotId }).get(); console.log(JSON.stringify(info, null, 2)); } catch (e) { handleError(e); } } ).command( "delete <snapshotId>", "Delete a snapshot", (y) => y.positional("snapshotId", { type: "string", demandOption: true }), async (argv) => { loadEnv(); try { const fs = await getFreestyleClient(); await fs.vms.snapshots.ref({ snapshotId: argv.snapshotId }).delete(); console.log("\u2713 Snapshot deleted"); } catch (e) { handleError(e); } } ).command( "rename <snapshotId>", "Rename a snapshot", (y) => y.positional("snapshotId", { type: "string", demandOption: true }).option("name", { type: "string", demandOption: true, description: "New name" }), async (argv) => { loadEnv(); try { const fs = await getFreestyleClient(); await fs.vms.snapshots.ref({ snapshotId: argv.snapshotId }).update({ name: argv.name }); console.log("\u2713 Snapshot renamed"); } catch (e) { handleError(e); } } ).command( "boot <snapshotId>", "Boot a VM from a snapshot", (y) => y.positional("snapshotId", { type: "string", demandOption: true }).option("ssh", { type: "boolean", default: false }), async (argv) => { loadEnv(); try { const fs = await getFreestyleClient(); console.log( `Booting VM from snapshot ${argv.snapshotId}...` ); const result = await fs.vms.create({ spec: new VmSpec().snapshotId(argv.snapshotId) }); console.log(`\u2713 VM ${result.vmId} created`); if (argv.ssh) { await sshIntoVm(result.vmId); } } catch (e) { handleError(e); } } ).command( "debug <snapshotId>", "Boot a debug VM from a (possibly failed) snapshot and SSH in", (y) => y.positional("snapshotId", { type: "string", demandOption: true }), async (argv) => { loadEnv(); try { const fs = await getFreestyleClient(); console.log( `Booting debug VM from snapshot ${argv.snapshotId}...` ); const result = await fs.vms.create({ spec: new VmSpec().snapshotId(argv.snapshotId) }); console.log(`\u2713 VM ${result.vmId} running`); await sshIntoVm(result.vmId); } catch (e) { handleError(e); } } ).command( "build <action>", "Inspect the build that produced a snapshot", (yy) => buildSubcommands( yy, async (snapshotId) => { const fs = await getFreestyleClient(); return fs.vms.snapshots.ref({ snapshotId }).getBuild(); }, "snapshotId" ), () => { } ).demandCommand(1, "Specify a snapshot action"), () => { } ).demandCommand(1, "You need to specify a vm action"); }, handler: () => { } }; const ALWAYS_IGNORED_DIRS = /* @__PURE__ */ new Set([ ".git", ".hg", ".svn", ".idea", ".vscode", "node_modules" ]); const ALWAYS_IGNORED_FILES = /* @__PURE__ */ new Set([ ".DS_Store" ]); function joinPosix(...parts) { return path.posix.join(...parts.map((part) => part.replace(/\\/g, "/"))); } function shouldIgnorePath(filePath, options) { const normalizedPath = filePath.replace(/\\/g, "/"); const segments = normalizedPath.split("/").filter(Boolean); const basename = segments[segments.length - 1] || ""; for (const segment of segments.slice(0, -1)) { if (ALWAYS_IGNORED_DIRS.has(segment)) { return `ignored directory '${segment}'`; } if (options?.excludeNextArtifacts && segment === ".next") { return "ignored build artifact directory '.next'"; } } if (ALWAYS_IGNORED_FILES.has(basename)) { return `ignored file '${basename}'`; } if (basename === ".env" || basename.startsWith(".env.")) { return "ignored sensitive env file"; } if (options?.excludeNextArtifacts && basename === ".next") { return "ignored build artifact directory '.next'"; } return null; } function filterDeploymentFiles(files, options) { const ignoredSummary = {}; const filtered = []; for (const file of files) { const reason = shouldIgnorePath(file.path, options); if (reason) { ignoredSummary[reason] = (ignoredSummary[reason] || 0) + 1; continue; } filtered.push(file); } return { files: filtered, ignoredSummary }; } async function readFilesWithPrefix(dir, prefix) { const files = await readFiles(dir); return files.map((file) => ({ ...file, path: joinPosix(prefix, file.path) })); } function detectLockfile(projectRoot) { const lockfiles = [ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb" ]; return lockfiles.find( (lockfile) => fs.existsSync(path.join(projectRoot, lockfile)) ); } function detectNextJsProject(projectRoot) { const nextConfigCandidates = [ "next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs" ]; if (nextConfigCandidates.some( (fileName) => fs.existsSync(path.join(projectRoot, fileName)) )) { return true; } const packageJsonPath = path.join(projectRoot, "package.json"); if (!fs.existsSync(packageJsonPath)) { return false; } try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); return Boolean( packageJson.dependencies?.next || packageJson.devDependencies?.next ); } catch { return false; } } async function prepareNextJsBuiltFiles(projectRoot) { const standaloneDir = path.join(projectRoot, ".next", "standalone"); const standaloneEntrypoint = path.join(standaloneDir, "server.js"); if (!fs.existsSync(standaloneDir) || !fs.statSync(standaloneDir).isDirectory() || !fs.existsSync(standaloneEntrypoint)) { return null; } const files = await readFiles(standaloneDir); const existingPaths = new Set(files.map((file) => file.path)); const projectPublicDir = path.join(projectRoot, "public"); if (fs.existsSync(projectPublicDir) && fs.statSync(projectPublicDir).isDirectory()) { const publicFiles = await readFilesWithPrefix(projectPublicDir, "public"); for (const file of publicFiles) { if (!existingPaths.has(file.path)) { files.push(file); existingPaths.add(file.path); } } } const projectStaticDir = path.join(projectRoot, ".next", "static"); if (fs.existsSync(projectStaticDir) && fs.statSync(projectStaticDir).isDirectory()) { const staticFiles = await readFilesWithPrefix( projectStaticDir, ".next/static" ); for (const file of staticFiles) { if (!existingPaths.has(file.path)) { files.push(file); existingPaths.add(file.path); } } } const lockfile = detectLockfile(projectRoot); if (lockfile && !existingPaths.has(lockfile)) { const lockfileContent = fs.readFileSync(path.join(projectRoot, lockfile), "base64"); files.push({ path: lockfile, content: lockfileContent, encoding: "base64" }); } const freestyleJsonPath = path.join(projectRoot, "freestyle.json"); if (fs.existsSync(freestyleJsonPath) && !existingPaths.has("freestyle.json")) { files.push({ path: "freestyle.json", content: fs.readFileSync(freestyleJsonPath, "utf-8"), encoding: "utf-8" }); } return { files, entrypointPath: "server.js" }; } const deployCommand = { command: "deploy", describe: "Deploy a serverless function", builder: (yargs) => { return yargs.option("code", { alias: "c", type: "string", description: "Inline code to deploy" }).option("file", { alias: "f", type: "string", description: "File path containing code to deploy" }).option("dir", { alias: "d", type: "string", description: "Directory path to deploy (prebuilt files, or source files when used with --build)" }).option("repo", { alias: "r", type: "string", description: "Git repository ID to deploy" }).option("domain", { type: "array", description: "Domains to assign to the deployment (can be specified multiple times)", default: [] }).option("env", { alias: "e", type: "array", description: "Environment variables (KEY=VALUE)", default: [] }).option("build", { type: "boolean", description: "Enable server-side build (use with --repo or --dir source deployments)" }).option("build-command", { type: "string", description: "Custom build command (for example: npm run build)" }).option("build-out-dir", { type: "string", description: "Build output directory (for example: dist or .next/standalone)" }).option("build-env", { type: "array", description: "Build environment variables (KEY=VALUE)", default: [] }).option("json", { type: "boolean", description: "Output as JSON", default: false }).check((argv) => { const hasCode = !!argv.code; const hasFile = !!argv.file; const hasDir = !!argv.dir; const hasRepo = !!argv.repo; const hasBuildConfig = !!argv.build || !!argv.buildCommand || !!argv.buildOutDir; if (!hasCode && !hasFile && !hasDir && !hasRepo) { throw new Error( "You must specify one of --code, --file, --dir, or --repo" ); } if ([hasCode, hasFile, hasDir, hasRepo].filter(Boolean).length > 1) { throw new Error( "You can only specify one of --code, --file, --dir, or --repo" ); } if (hasBuildConfig && (hasCode || hasFile)) { throw new Error( "--build options are only supported with --repo or --dir" ); } if (argv.buildEnv && argv.buildEnv.length > 0 && !hasBuildConfig) { throw new Error( "--build-env requires --build, --build-command, or --build-out-dir" ); } if (!!argv.buildCommand && !argv.build) { throw new Error("--build-command requires --build"); } if (!!argv.buildOutDir && !argv.build) { throw new Error("--build-out-dir requires --build"); } if (!!argv.buildOutDir && !argv.buildCommand) { throw new Error("--build-out-dir requires --build-command"); } if (argv.buildEnv && argv.buildEnv.length > 0 && !argv.buildCommand) { throw new Error("--build-env requires --build-command"); } return true; }); }, handler: async (argv) => { loadEnv(); const args = argv; try { const freestyle = await getFreestyleClient(); let code; let files; let repo; let entrypointPath; let nextjsOptimization; if (args.code) { code = args.code; } else if (args.file) { code = fs.readFileSync(args.file, "utf-8"); } else if (args.dir) { if (!fs.existsSync(args.dir)) { throw new Error(`Directory not found: ${args.dir}`); } if (!fs.statSync(args.dir).isDirectory()) { throw new Error(`Path is not a directory: ${args.dir}`); } const nextJsBuiltFiles = await prepareNextJsBuiltFiles(args.dir); le