UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, run, reset, and migrate commands

445 lines (441 loc) 16.4 kB
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 };