freestyle
Version:
Learn more at [docs.freestyle.sh](https://docs.freestyle.sh)
1,571 lines (1,567 loc) • 96.7 kB
JavaScript
#!/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