@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, run, reset, and migrate commands
209 lines (206 loc) • 8.14 kB
JavaScript
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { LunoraError } from '@lunora/errors';
import { r as resolveAdminBaseUrl } from '../packem_shared/admin-url-4UzT-CI4.mjs';
import { d as defineHandler } from '../packem_shared/command-lYnl4QyF.mjs';
import { a as resolveProductionWorkerUrl } from '../packem_shared/resolve-target-qbsJ_5sF.mjs';
import { runExportCommand, runImportCommand } from '../packem_shared/DEFAULT_IMPORT_BATCH_SIZE-D0VOTerB.mjs';
const DEFAULT_BACKUP_DIR = ".lunora-backups";
const MANIFEST_FILE = "manifest.json";
const PITR_ENDPOINT_PATH = "/_lunora/admin/pitr";
const GET_PITR_BOOKMARK_OP = "__lunora_admin__:getPitrBookmark";
const PITR_RESTORE_OP = "__lunora_admin__:pitrRestore";
const isManifestEntry = (value) => typeof value === "object" && value !== null && typeof value.id === "string" && typeof value.file === "string";
const readManifest = async (directory) => {
const path = join(directory, MANIFEST_FILE);
if (!existsSync(path)) {
return [];
}
let parsed;
try {
parsed = JSON.parse(await readFile(path, "utf8"));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new LunoraError("INTERNAL", `backup: ${path} exists but is not valid JSON (${message}) — refusing to overwrite it; fix or remove it manually`, {
cause: error
});
}
if (!Array.isArray(parsed)) {
throw new TypeError(`backup: ${path} exists but is not a JSON array — refusing to overwrite it; fix or remove it manually`);
}
return parsed.filter(isManifestEntry);
};
const writeManifest = async (directory, entries) => {
await writeFile(join(directory, MANIFEST_FILE), `${JSON.stringify(entries, void 0, 2)}
`, "utf8");
};
const runBackupCreate = async (options, directory) => {
await mkdir(directory, { recursive: true });
const timestamp = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
const file = `lunora-backup-${timestamp.replaceAll(/[.:]/gu, "-")}.ndjson`;
const result = await runExportCommand({
fetchImpl: options.fetchImpl,
logger: options.logger,
out: join(directory, file),
prod: options.prod,
tables: options.tables,
token: options.token,
url: options.url
});
if (result.code !== 0) {
return { code: result.code };
}
const entry = { bytes: result.bytes, createdAt: timestamp, file, id: timestamp, rows: result.rows, tables: options.tables };
const manifest = await readManifest(directory);
manifest.push(entry);
await writeManifest(directory, manifest);
options.logger.success(`backup created: ${file} (${result.rows.toString()} rows, ${result.bytes.toString()} bytes)`);
return { code: 0, entry };
};
const runBackupList = async (options, directory) => {
const manifest = await readManifest(directory);
if (manifest.length === 0) {
options.logger.info(`no backups found in ${directory}`);
return { code: 0 };
}
for (const entry of manifest) {
options.logger.info(`${entry.id} ${entry.rows.toString()} rows ${entry.bytes.toString()} bytes ${entry.file}`);
}
return { code: 0 };
};
const runBackupRestore = async (options, directory) => {
const { target } = options;
if (target === void 0 || target.length === 0) {
options.logger.error("restore requires a backup id or file path. Usage: lunora backup restore <id|file>");
return { code: 1 };
}
const manifest = await readManifest(directory);
const matched = manifest.find((entry) => entry.id === target);
const file = matched ? join(directory, matched.file) : target;
if (!existsSync(file)) {
options.logger.error(`backup not found: ${target}`);
return { code: 1 };
}
const result = await runImportCommand({
fetchImpl: options.fetchImpl,
file,
logger: options.logger,
prod: options.prod,
token: options.token,
url: options.url,
yes: options.yes
});
return { code: result.code };
};
const resolvePitrRequest = (options) => {
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;
}
if (options.prod && options.url === void 0) {
options.logger.error("--prod requires an explicit --url (refusing to target the implicit localhost worker)");
return void 0;
}
if (options.restore === true && options.at === void 0 && options.bookmark === void 0) {
options.logger.error("pitr --restore requires --at <time> or --bookmark <bookmark>");
return void 0;
}
if (options.restore === true && options.prod === true && options.yes !== true) {
options.logger.error("pitr --restore --prod restores production data in place. Re-run with --yes to confirm.");
return void 0;
}
const baseUrl = resolveAdminBaseUrl(options.url, options.logger);
if (baseUrl === void 0) {
return void 0;
}
const fetchImpl = options.pitrFetch ?? globalThis.fetch;
if (typeof fetchImpl !== "function") {
throw new TypeError("no fetch implementation available — pass pitrFetch or run on Node >= 18");
}
return { fetchImpl, requestUrl: `${baseUrl}${PITR_ENDPOINT_PATH}`, token };
};
const buildPitrArgs = (options, isRestore) => {
const args = {};
if (options.at !== void 0) {
args.time = options.at;
}
if (isRestore && options.bookmark !== void 0) {
args.bookmark = options.bookmark;
}
if (isRestore && options.restart === true) {
args.restart = true;
}
return args;
};
const runBackupPitr = async (options) => {
const request = resolvePitrRequest(options);
if (request === void 0) {
return { code: 1 };
}
const isRestore = options.restore === true;
const functionPath = isRestore ? PITR_RESTORE_OP : GET_PITR_BOOKMARK_OP;
const args = buildPitrArgs(options, isRestore);
const action = isRestore ? "restore" : "bookmark";
options.logger.info(`POST ${request.requestUrl} -> pitr ${action}${options.shard === void 0 ? "" : ` (shard "${options.shard}")`}`);
const response = await request.fetchImpl(request.requestUrl, {
body: JSON.stringify({ args, functionPath, shardKey: options.shard }),
headers: { authorization: `Bearer ${request.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 { code: response.ok ? 0 : 1 };
};
const runBackupCommand = async (options) => {
const cwd = options.cwd ?? process.cwd();
const directory = join(cwd, options.dir ?? DEFAULT_BACKUP_DIR);
try {
if (options.subcommand === "create") {
return await runBackupCreate(options, directory);
}
if (options.subcommand === "list") {
return await runBackupList(options, directory);
}
if (options.subcommand === "pitr") {
return await runBackupPitr(options);
}
return await runBackupRestore(options, directory);
} catch (error) {
options.logger.error(error instanceof Error ? error.message : String(error));
return { code: 1 };
}
};
const isBackupSubcommand = (value) => value === "create" || value === "list" || value === "pitr" || value === "restore";
const execute = defineHandler(({ argument, cwd, logger, options }) => {
const sub = argument[0];
if (!isBackupSubcommand(sub)) {
logger.error(`backup: unknown subcommand "${sub ?? ""}" — expected create | list | restore | pitr`);
return { code: 1 };
}
return runBackupCommand({
at: options.at,
bookmark: options.bookmark,
cwd,
dir: options.dir,
logger,
prod: options.prod === true,
restart: options.restart === true,
restore: options.restore === true,
shard: options.shard,
subcommand: sub,
tables: options.tables,
target: argument[1],
token: options.token,
url: resolveProductionWorkerUrl({ cwd, prod: options.prod === true, url: options.url }),
yes: options.yes === true
});
});
export { execute, runBackupCommand };