@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, run, reset, and migrate commands
445 lines (441 loc) • 16.4 kB
JavaScript
import { existsSync, mkdirSync, writeFileSync, mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { discoverSchema, discoverMigrations } from '@lunora/codegen';
import { isInteractive, promptSelect, promptText } from '@lunora/config';
import { LunoraError } from '@lunora/errors';
import { join } from '@visulima/path';
import { Project } from 'ts-morph';
import { r as resolveAdminBaseUrl } from '../packem_shared/admin-url-4UzT-CI4.mjs';
import { d as defineHandler } from '../packem_shared/command-lYnl4QyF.mjs';
import { diffSnapshots, renderMigrationFile } from '../packem_shared/diffSnapshots-BeDvvNiF.mjs';
import { a as resolveProductionWorkerUrl } from '../packem_shared/resolve-target-qbsJ_5sF.mjs';
import schemaIrToSnapshot from '../packem_shared/schemaIrToSnapshot-DdsljJT-.mjs';
import { runExportCommand, runImportCommand } from '../packem_shared/DEFAULT_IMPORT_BATCH_SIZE-D0VOTerB.mjs';
const SNAPSHOT_FILENAME = ".snapshot.json";
const NON_ALPHANUMERIC = /[^\da-z]+/gu;
const trimChar = (value, char) => {
let start = 0;
let end = value.length;
while (start < end && value[start] === char) {
start += 1;
}
while (end > start && value[end - 1] === char) {
end -= 1;
}
return value.slice(start, end);
};
const slugify = (input) => {
const slug = trimChar(input.toLowerCase().replaceAll(NON_ALPHANUMERIC, "_"), "_");
return slug === "" ? "auto" : slug;
};
const formatTimestamp = (now) => {
const pad = (n, w = 2) => n.toString().padStart(w, "0");
return `${String(now.getUTCFullYear())}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}${pad(now.getUTCHours())}${pad(
now.getUTCMinutes()
)}${pad(now.getUTCSeconds())}`;
};
const loadSnapshot = (path) => {
if (!existsSync(path)) {
return void 0;
}
try {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw);
if (parsed.version !== 1) {
throw new LunoraError("INTERNAL", `unsupported snapshot version: ${parsed.version}`);
}
return parsed;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new LunoraError("INTERNAL", `failed to read ${path}: ${message}`, { cause: error });
}
};
const runMigrateGenerateCommand = (options) => {
const cwd = options.cwd ?? process.cwd();
const schemaPath = join(cwd, "lunora", "schema.ts");
if (!existsSync(schemaPath)) {
options.logger.error(`schema not found: ${schemaPath} — run \`vis generate lunora-table --name=<name>\` to create one`);
return { code: 1, empty: true, migrationFile: "" };
}
const project = new Project({ skipAddingFilesFromTsConfig: true });
const schemaIr = discoverSchema(project, schemaPath);
const nextSnapshot = schemaIrToSnapshot(schemaIr);
const migrationsDirectory = join(cwd, "lunora", "migrations");
const snapshotPath = join(migrationsDirectory, SNAPSHOT_FILENAME);
let previousSnapshot;
try {
previousSnapshot = loadSnapshot(snapshotPath);
} catch (error) {
options.logger.error(error instanceof Error ? error.message : String(error));
return { code: 1, empty: true, migrationFile: "" };
}
const diff = diffSnapshots(previousSnapshot, nextSnapshot);
if (diff.empty) {
options.logger.info("no schema changes detected — snapshot is already up to date");
return { code: 0, empty: true, migrationFile: "" };
}
const nowFunction = options.now ?? (() => /* @__PURE__ */ new Date());
const now = nowFunction();
const slug = slugify(options.name ?? "auto");
const timestamp = formatTimestamp(now);
const filename = `${timestamp}_${slug}.sql`;
const migrationFile = join(migrationsDirectory, filename);
mkdirSync(migrationsDirectory, { recursive: true });
const body = renderMigrationFile(slug, diff, now.toISOString());
writeFileSync(migrationFile, body, "utf8");
writeFileSync(snapshotPath, `${JSON.stringify(nextSnapshot, void 0, 4)}
`, "utf8");
options.logger.success(`wrote ${migrationFile}`);
if (diff.unsupported.length > 0) {
options.logger.warn(`${String(diff.unsupported.length)} unsupported diff(s) — see the comment block in ${filename} and write the SQL manually`);
}
return { code: 0, empty: false, migrationFile };
};
const DATA_MIGRATIONS_FILENAME = "migrations.ts";
const IDENTIFIER_PATTERN = /^[A-Za-z_]\w*$/u;
const RESERVED_WORDS = /* @__PURE__ */ new Set([
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"enum",
"export",
"extends",
"false",
"finally",
"for",
"function",
"if",
"implements",
"import",
"in",
"instanceof",
"interface",
"let",
"new",
"null",
"package",
"private",
"protected",
"public",
"return",
"static",
"super",
"switch",
"this",
"throw",
"true",
"try",
"typeof",
"var",
"void",
"while",
"with",
"yield"
]);
const DEFINE_MIGRATION_IMPORT = `import { defineMigration } from "@lunora/server";`;
const RUN_MIGRATION_OP = "__lunora_admin__:runMigration";
const MIGRATION_STATUS_OP = "__lunora_admin__:migrationStatus";
const MIGRATE_ENDPOINT_PATH = "/_lunora/migrate";
const kebabCase = (input) => trimChar(input.trim().toLowerCase().replaceAll(NON_ALPHANUMERIC, "-"), "-");
const camelCase = (slug) => slug.split("-").filter((part) => part.length > 0).map((part, index) => {
if (index === 0) {
return part;
}
return part.charAt(0).toUpperCase() + part.slice(1);
}).join("");
const discoverKnownTables = (cwd) => {
const schemaPath = join(cwd, "lunora", "schema.ts");
if (!existsSync(schemaPath)) {
return [];
}
try {
const project = new Project({ skipAddingFilesFromTsConfig: true });
return discoverSchema(project, schemaPath).tables.map((table) => table.name);
} catch {
return [];
}
};
const promptForTable = async (tables) => {
if (tables.length > 0) {
return promptSelect(
"Which table does this migration iterate?",
tables.map((name) => {
return { label: name, value: name };
})
);
}
return promptText("Target table for the migration: ");
};
const resolveCreateTable = async (cwd, options) => {
if (options.table !== void 0) {
return options.table;
}
const prompt = options.promptTable ?? (isInteractive() ? promptForTable : void 0);
if (prompt === void 0) {
options.logger.error("migrate create requires a target table when not running interactively — re-run with --table <table>");
return void 0;
}
const answer = await prompt(discoverKnownTables(cwd));
const table = answer?.trim();
if (table === void 0 || table === "") {
options.logger.error("no table selected — re-run with --table <table>");
return void 0;
}
return table;
};
const runMigrateCreateCommand = async (options) => {
const cwd = options.cwd ?? process.cwd();
const slug = kebabCase(options.name);
if (slug === "") {
options.logger.error(`invalid migration name: "${options.name}" — must contain at least one alphanumeric character`);
return { code: 1, file: "" };
}
const exportName = camelCase(slug);
if (!IDENTIFIER_PATTERN.test(exportName) || RESERVED_WORDS.has(exportName)) {
options.logger.error(
`invalid migration name: "${options.name}" derives the export \`${exportName}\`, which is not a valid identifier — pick a name that starts with a letter and isn't a reserved word`
);
return { code: 1, file: "" };
}
const table = await resolveCreateTable(cwd, options);
if (table === void 0) {
return { code: 1, file: "" };
}
if (!IDENTIFIER_PATTERN.test(table)) {
options.logger.error(`invalid table: "${table}" — must be a valid identifier ([A-Za-z_][A-Za-z0-9_]*)`);
return { code: 1, file: "" };
}
const lunoraDirectory = join(cwd, "lunora");
const file = join(lunoraDirectory, DATA_MIGRATIONS_FILENAME);
let content = existsSync(file) ? readFileSync(file, "utf8") : "";
if (content.includes(`id: "${slug}"`) || new RegExp(String.raw`\bexport const ${exportName}\b`, "u").test(content)) {
options.logger.error(`a migration with id "${slug}" (export \`${exportName}\`) already exists in ${file}`);
return { code: 1, file: "" };
}
if (content.trim() === "") {
content = `${DEFINE_MIGRATION_IMPORT}
`;
} else if (!content.includes(DEFINE_MIGRATION_IMPORT)) {
content = `${DEFINE_MIGRATION_IMPORT}
${content}`;
}
const block = `export const ${exportName} = defineMigration({
id: "${slug}",
table: "${table}",
up: (document) => document,
});`;
mkdirSync(lunoraDirectory, { recursive: true });
writeFileSync(file, `${content.trimEnd()}
${block}
`, "utf8");
options.logger.success(`scaffolded migration "${slug}" in ${file}`);
return { code: 0, file };
};
const resolveMigrationTable = (cwd, id) => {
const project = new Project({ skipAddingFilesFromTsConfig: true });
const migrations = discoverMigrations(project, join(cwd, "lunora"));
return migrations.find((migration) => migration.id === id)?.table;
};
const resolveValidatedTable = (cwd, options) => {
let table;
try {
table = resolveMigrationTable(cwd, options.id);
} catch (error) {
options.logger.error(error instanceof Error ? error.message : String(error));
return void 0;
}
if (table === void 0) {
options.logger.error(`migration "${options.id}" not found under lunora/ — declare it with defineMigration({ id: "${options.id}", ... })`);
return void 0;
}
if (table === "") {
options.logger.error(`migration "${options.id}" must declare \`table\` as a static string literal`);
return void 0;
}
return table;
};
const resolveMigrateDataRequest = (options) => {
const cwd = options.cwd ?? process.cwd();
if (options.prod && options.url === void 0) {
options.logger.error("--prod requires an explicit --url (refusing to migrate the implicit localhost worker)");
return void 0;
}
if (options.prod && (options.subcommand === "up" || options.subcommand === "down") && !options.yes) {
options.logger.error(`migrate ${options.subcommand} --prod runs the migration against production. Re-run with --yes to confirm.`);
return void 0;
}
const token = options.token ?? process.env.LUNORA_ADMIN_TOKEN;
if (!token) {
options.logger.error("admin token required — pass --token or set LUNORA_ADMIN_TOKEN");
return void 0;
}
const table = resolveValidatedTable(cwd, options);
if (table === void 0) {
return void 0;
}
const baseUrl = resolveAdminBaseUrl(options.url, options.logger);
if (baseUrl === void 0) {
return void 0;
}
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
if (typeof fetchImpl !== "function") {
throw new TypeError("no fetch implementation available — pass fetchImpl or run on Node >= 18");
}
return { fetchImpl, requestUrl: `${baseUrl}${MIGRATE_ENDPOINT_PATH}`, table, token };
};
const buildMigrateArgs = (options) => {
const args = { id: options.id };
if (options.subcommand === "status") {
return args;
}
args.direction = options.subcommand;
if (options.dryRun) {
args.dryRun = true;
}
if (options.batchSize !== void 0) {
args.batchSize = options.batchSize;
}
if (options.maxBatches !== void 0) {
args.maxBatches = options.maxBatches;
}
return args;
};
const runMigrateDataCommand = async (options) => {
const request = resolveMigrateDataRequest(options);
if (request === void 0) {
return { body: void 0, code: 1, requestUrl: "" };
}
const { fetchImpl, requestUrl, table, token } = request;
const functionPath = options.subcommand === "status" ? MIGRATION_STATUS_OP : RUN_MIGRATION_OP;
const args = buildMigrateArgs(options);
options.logger.info(`POST ${requestUrl} -> ${options.subcommand} ${options.id} (table "${table}")`);
const response = await fetchImpl(requestUrl, {
body: JSON.stringify({ args, functionPath, table }),
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
method: "POST"
});
const text = await response.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = text;
}
options.logger.info(JSON.stringify(body, void 0, 2));
return { body, code: response.ok ? 0 : 1, requestUrl };
};
const runMigrateToHyperdriveCommand = async (options) => {
const { logger } = options;
const fromUrl = options.fromUrl ?? options.toUrl;
const toUrl = options.toUrl ?? options.fromUrl;
if (fromUrl !== void 0 && fromUrl === toUrl) {
logger.error(
"source and target are the same deployment — pass distinct --from-url and --to-url so the D1 export and Hyperdrive import don't run against one database"
);
return { code: 1 };
}
const temporaryDirectory = options.out === void 0 ? mkdtempSync(join(tmpdir(), "lunora-d1ps-")) : void 0;
const dumpPath = options.out ?? join(temporaryDirectory, "dump.ndjson");
try {
logger.info(`Exporting .global() data from the D1 source (${fromUrl ?? "http://localhost:8787"}) …`);
const exportResult = await runExportCommand({
fetchImpl: options.fetchImpl,
logger,
out: dumpPath,
prod: options.prod,
tables: options.tables,
token: options.fromToken,
url: fromUrl
});
if (exportResult.code !== 0) {
return { code: exportResult.code };
}
logger.info(`Exported ${String(exportResult.rows)} row(s) (${String(exportResult.bytes)} bytes).`);
logger.info(`Importing into the Hyperdrive target (${toUrl ?? "http://localhost:8787"}) …`);
const importResult = await runImportCommand({
batchSize: options.batchSize,
fetchImpl: options.fetchImpl,
file: dumpPath,
logger,
prod: options.prod,
token: options.toToken,
url: toUrl
});
if (importResult.code !== 0) {
return { code: importResult.code };
}
if (importResult.inserted === exportResult.rows) {
logger.info(
`✓ Migrated ${String(exportResult.rows)} row(s) — counts match. Verify your app reads from Hyperdrive, then decommission the D1 binding.`
);
} else {
logger.warn(
`Imported ${String(importResult.inserted)} of ${String(exportResult.rows)} exported row(s) — the remainder likely already existed in the target (see conflicts above). Re-run after resolving, or inspect the dump with --out.`
);
}
return { code: 0 };
} finally {
if (temporaryDirectory !== void 0) {
rmSync(temporaryDirectory, { force: true, recursive: true });
}
}
};
const execute = defineHandler(({ argument, cwd, logger, options }) => {
const sub = argument[0];
if (sub === "generate") {
return runMigrateGenerateCommand({ cwd, logger, name: argument[1] ?? options.name });
}
if (sub === "d1-to-hyperdrive") {
return runMigrateToHyperdriveCommand({
batchSize: options.batchSize,
fromToken: options.fromToken ?? options.token,
fromUrl: options.fromUrl ?? options.url,
logger,
out: options.out,
prod: options.prod === true,
tables: options.tables,
toToken: options.toToken ?? options.token,
toUrl: options.toUrl ?? options.url
});
}
if (sub === "create") {
const name = argument[1] ?? options.name;
if (!name) {
logger.error("migrate create requires a name. Usage: lunora migrate create <name> [--table <table>]");
return { code: 1 };
}
return runMigrateCreateCommand({ cwd, logger, name, table: options.table });
}
if (sub === "up" || sub === "down" || sub === "status") {
const id = argument[1] ?? options.name;
if (!id) {
logger.error(`migrate ${sub} requires a migration id. Usage: lunora migrate ${sub} <id>`);
return { code: 1 };
}
return runMigrateDataCommand({
batchSize: options.batchSize,
cwd,
dryRun: options.dryRun === true,
id,
logger,
maxBatches: options.steps,
prod: options.prod === true,
subcommand: sub,
token: options.token,
url: resolveProductionWorkerUrl({ cwd, prod: options.prod === true, url: options.url }),
yes: options.yes === true
});
}
logger.error(`unknown migrate subcommand: "${sub ?? ""}" — expected generate | create | up | down | status`);
return { code: 1 };
});
export { execute, runMigrateCreateCommand, runMigrateDataCommand, runMigrateGenerateCommand, runMigrateToHyperdriveCommand };