From fc72332a876c174f9c7aa644e9df702ed31aac2d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 12 Aug 2026 14:15:37 +0100 Subject: [PATCH 1/3] feat(core): add deployment migrate command --- .changeset/add-deployment-migrate-cli.md | 5 + packages/core/src/cli/commands/migrate.ts | 580 ++++++++++++++++++ packages/core/src/cli/index.ts | 2 + .../integration/cli/migrate-sqlite.test.ts | 180 ++++++ packages/core/tests/unit/cli/migrate.test.ts | 468 ++++++++++++++ 5 files changed, 1235 insertions(+) create mode 100644 .changeset/add-deployment-migrate-cli.md create mode 100644 packages/core/src/cli/commands/migrate.ts create mode 100644 packages/core/tests/integration/cli/migrate-sqlite.test.ts create mode 100644 packages/core/tests/unit/cli/migrate.test.ts diff --git a/.changeset/add-deployment-migrate-cli.md b/.changeset/add-deployment-migrate-cli.md new file mode 100644 index 0000000000..e1cbae3c2b --- /dev/null +++ b/.changeset/add-deployment-migrate-cli.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds `emdash migrate` for checking, reporting, and safely applying the exact core migration set recorded by a deployment build. diff --git a/packages/core/src/cli/commands/migrate.ts b/packages/core/src/cli/commands/migrate.ts new file mode 100644 index 0000000000..ed57d7abc0 --- /dev/null +++ b/packages/core/src/cli/commands/migrate.ts @@ -0,0 +1,580 @@ +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, isAbsolute, parse, resolve } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { pathToFileURL } from "node:url"; + +import { defineCommand } from "citty"; + +import { buildMigrationManifestFromConfig } from "../../migrations/config-loader.js"; +import type { CoreMigrationIdentity } from "../../migrations/identity.js"; +import type { MigrationManifestV1 } from "../../migrations/manifest.js"; +import { + MigrationManifestValidationError, + validateMigrationManifest, +} from "../../migrations/manifest.js"; +import type { + MigrationExecutor, + MigrationExecutorFactory, + MigrationReport, + MigrationTarget, + MigrationTargetOverrides, +} from "../../migrations/protocol.js"; + +const FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/; +const SAFE_TARGET_KIND_PATTERN = /^[a-z][a-z0-9_-]*$/; +const SAFE_MIGRATION_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/; +const CREDENTIAL_URL_PATTERN = /:\/\/[^/\s]+@/; +const CREDENTIAL_QUERY_PATTERN = /[?&](?:auth|credential|key|password|secret|signature|token)=/i; + +export const MIGRATE_EXIT_CODES = Object.freeze({ + success: 0, + error: 1, + pending: 2, + unknownApplied: 3, + confirmation: 4, + interrupted: 130, +} as const); + +export type MigrationSignal = "SIGINT" | "SIGTERM"; + +export interface MigrateCommandOptions extends MigrationTargetOverrides { + manifest?: string; + fromConfig?: boolean; + config?: string; + check?: boolean; + status?: boolean; + json?: boolean; + expectedTargetFingerprint?: string; +} + +export interface MigrateCommandDependencies { + cwd: string; + env: Readonly>; + interactive: boolean; + findProjectRoot: (start: string) => Promise; + readManifest: (path: string, isDefault: boolean) => Promise; + buildManifestFromConfig: (projectRoot: string, configFile?: string) => Promise; + loadProjectIdentity: (projectRoot: string) => Promise; + loadProjectExecutor: ( + projectRoot: string, + entrypoint: string, + ) => Promise; + confirm: (message: string) => Promise; + writeStdout: (value: string) => void; + writeStderr: (value: string) => void; + onSignal: (signal: MigrationSignal, handler: () => Promise) => () => void; + cleanupTimeoutMs: number; +} + +class MigrateCommandError extends Error { + constructor(message: string) { + super(message); + this.name = "MigrateCommandError"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function moduleExport(module: unknown, name: string): unknown { + if (!isRecord(module)) return undefined; + const direct = Reflect.get(module, name); + if (direct !== undefined) return direct; + const defaultExport = Reflect.get(module, "default"); + return isRecord(defaultExport) ? Reflect.get(defaultExport, name) : undefined; +} + +async function importProjectModule(projectRoot: string, specifier: string): Promise { + let resolvedEntrypoint: string; + try { + resolvedEntrypoint = createRequire(resolve(projectRoot, "package.json")).resolve(specifier); + } catch { + throw new MigrateCommandError(`Could not resolve ${specifier} from the project.`); + } + return import(pathToFileURL(resolvedEntrypoint).href); +} + +function validateIdentity(value: unknown): CoreMigrationIdentity { + if ( + !isRecord(value) || + typeof value.emdashVersion !== "string" || + !Array.isArray(value.names) || + !value.names.every((name) => typeof name === "string") || + typeof value.fingerprint !== "string" || + !FINGERPRINT_PATTERN.test(value.fingerprint) + ) { + throw new MigrateCommandError( + "The project-local emdash/migrations module returned an invalid migration identity.", + ); + } + return { + emdashVersion: value.emdashVersion, + names: [...value.names], + fingerprint: value.fingerprint, + }; +} + +export async function loadProjectMigrationIdentity( + projectRoot: string, +): Promise { + const loaded = await importProjectModule(projectRoot, "emdash/migrations"); + const getIdentity = moduleExport(loaded, "getCoreMigrationIdentity"); + if (typeof getIdentity !== "function") { + throw new MigrateCommandError( + "The project-local emdash/migrations module does not export getCoreMigrationIdentity.", + ); + } + return validateIdentity(await Reflect.apply(getIdentity, undefined, [])); +} + +export async function loadProjectMigrationExecutor( + projectRoot: string, + entrypoint: string, +): Promise { + const loaded = await importProjectModule(projectRoot, entrypoint); + const factory = moduleExport(loaded, "createMigrationExecutor"); + if (typeof factory !== "function") { + throw new MigrateCommandError( + `The project-local ${entrypoint} module does not export createMigrationExecutor.`, + ); + } + return (manifestConfig, context) => Reflect.apply(factory, undefined, [manifestConfig, context]); +} + +async function findProjectRoot(start: string): Promise { + let current = resolve(start); + const filesystemRoot = parse(current).root; + for (;;) { + try { + await readFile(resolve(current, "package.json"), "utf8"); + return current; + } catch (error) { + if (isRecord(error) && error.code !== "ENOENT" && error.code !== "ENOTDIR") { + throw new MigrateCommandError("Could not read the project package.json."); + } + } + if (current === filesystemRoot) break; + current = dirname(current); + } + throw new MigrateCommandError("Could not find a project root containing package.json."); +} + +export async function readMigrationManifestFile( + path: string, + isDefault: boolean, +): Promise { + let source: string; + try { + source = await readFile(path, "utf8"); + } catch (error) { + if (isDefault && isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) { + throw new MigrateCommandError( + `No migration manifest found at ${path}. Build the project or use --from-config.`, + ); + } + throw new MigrateCommandError(`Could not read migration manifest: ${path}`); + } + try { + return JSON.parse(source); + } catch { + throw new MigrateCommandError(`Migration manifest is not valid JSON: ${path}`); + } +} + +async function confirmWithReadline(message: string): Promise { + const prompt = createInterface({ input: process.stdin, output: process.stderr }); + try { + const answer = await prompt.question(`${message} [y/N] `); + return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes"; + } finally { + prompt.close(); + } +} + +function onProcessSignal(signal: MigrationSignal, handler: () => Promise): () => void { + const listener = () => { + void handler().finally(() => process.exit(MIGRATE_EXIT_CODES.interrupted)); + }; + process.once(signal, listener); + return () => process.removeListener(signal, listener); +} + +const defaultDependencies: MigrateCommandDependencies = { + cwd: process.cwd(), + env: process.env, + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), + findProjectRoot, + readManifest: readMigrationManifestFile, + buildManifestFromConfig: (projectRoot, configFile) => + buildMigrationManifestFromConfig({ projectRoot, configFile }), + loadProjectIdentity: loadProjectMigrationIdentity, + loadProjectExecutor: loadProjectMigrationExecutor, + confirm: confirmWithReadline, + writeStdout: (value) => process.stdout.write(`${value}\n`), + writeStderr: (value) => process.stderr.write(`${value}\n`), + onSignal: onProcessSignal, + cleanupTimeoutMs: 2_000, +}; + +function validateOptions(options: MigrateCommandOptions): void { + if (options.manifest && options.fromConfig) { + throw new MigrateCommandError("--manifest and --from-config cannot be used together."); + } + if (options.config && !options.fromConfig) { + throw new MigrateCommandError("--config requires --from-config."); + } + if (options.check && options.status) { + throw new MigrateCommandError("--check and --status cannot be used together."); + } + if ( + options.expectedTargetFingerprint && + !FINGERPRINT_PATTERN.test(options.expectedTargetFingerprint) + ) { + throw new MigrateCommandError("--expected-target-fingerprint must be a SHA-256 fingerprint."); + } +} + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function targetField(value: unknown, name: string): string | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== "string" || + value.length === 0 || + hasControlCharacter(value) || + CREDENTIAL_URL_PATTERN.test(value) || + CREDENTIAL_QUERY_PATTERN.test(value) + ) { + throw new MigrateCommandError(`The executor returned an unsafe migration target ${name}.`); + } + return value; +} + +function immutableTarget(value: unknown): Readonly { + if (!isRecord(value)) { + throw new MigrateCommandError("The executor returned an invalid migration target."); + } + const kind = targetField(value.kind, "kind"); + const label = targetField(value.label, "label"); + const fingerprint = targetField(value.fingerprint, "fingerprint"); + if ( + !kind || + !SAFE_TARGET_KIND_PATTERN.test(kind) || + !label || + !fingerprint || + !FINGERPRINT_PATTERN.test(fingerprint) + ) { + throw new MigrateCommandError("The executor returned an invalid migration target."); + } + const target: MigrationTarget = { kind, label, fingerprint }; + const accountId = targetField(value.accountId, "accountId"); + const environment = targetField(value.environment, "environment"); + const resourceId = targetField(value.resourceId, "resourceId"); + if (accountId) target.accountId = accountId; + if (environment) target.environment = environment; + if (resourceId) target.resourceId = resourceId; + return Object.freeze(target); +} + +function safeMigrationNames(value: unknown, field: string): string[] { + if ( + !Array.isArray(value) || + !value.every((name) => typeof name === "string" && SAFE_MIGRATION_NAME_PATTERN.test(name)) + ) { + throw new MigrateCommandError(`The migration executor returned an invalid ${field} report.`); + } + return [...value]; +} + +function safeReport(value: unknown, target: Readonly): MigrationReport { + if (!isRecord(value)) { + throw new MigrateCommandError("The migration executor returned an invalid report."); + } + return { + target: { ...target }, + knownApplied: safeMigrationNames(value.knownApplied, "knownApplied"), + pending: safeMigrationNames(value.pending, "pending"), + unknownApplied: safeMigrationNames(value.unknownApplied, "unknownApplied"), + executed: safeMigrationNames(value.executed, "executed"), + }; +} + +function printTarget(target: Readonly, write: (value: string) => void): void { + write(`Migration target: ${target.kind} ${target.label}`); + if (target.accountId) write(`Account: ${target.accountId}`); + if (target.environment) write(`Environment: ${target.environment}`); + if (target.resourceId) write(`Resource: ${target.resourceId}`); + write(`Target fingerprint: ${target.fingerprint}`); +} + +function printNameSet( + label: string, + names: readonly string[], + write: (value: string) => void, +): void { + write(`${label}: ${names.length === 0 ? "none" : names.join(", ")}`); +} + +function printHumanReport(report: MigrationReport, write: (value: string) => void): void { + printNameSet("Known applied", report.knownApplied, write); + printNameSet("Pending", report.pending, write); + printNameSet("Unknown applied", report.unknownApplied, write); + printNameSet("Executed", report.executed, write); +} + +function reportExitCode(options: MigrateCommandOptions, report: MigrationReport): number { + if (!options.check) return MIGRATE_EXIT_CODES.success; + if (report.unknownApplied.length > 0) return MIGRATE_EXIT_CODES.unknownApplied; + if (report.pending.length > 0) return MIGRATE_EXIT_CODES.pending; + return MIGRATE_EXIT_CODES.success; +} + +function createOverrides(options: MigrateCommandOptions): MigrationTargetOverrides { + return { + database: options.database, + databaseUrlEnv: options.databaseUrlEnv, + d1: options.d1, + accountId: options.accountId, + wranglerConfig: options.wranglerConfig, + wranglerEnv: options.wranglerEnv, + }; +} + +function bounded( + operation: Promise, + timeoutMs: number, +): Promise<"complete" | "failed" | "timeout"> { + let timer: ReturnType; + const timeout = new Promise<"timeout">((resolveTimeout) => { + timer = setTimeout(resolveTimeout, timeoutMs, "timeout"); + }); + const completion = operation.then<"complete", "failed">( + () => "complete", + () => "failed", + ); + return Promise.race([completion, timeout]).finally(() => clearTimeout(timer)); +} + +async function loadManifest( + options: MigrateCommandOptions, + projectRoot: string, + dependencies: MigrateCommandDependencies, +): Promise { + if (options.fromConfig) { + dependencies.writeStderr( + `Using trusted evaluated Astro configuration${options.config ? `: ${options.config}` : "."}`, + ); + return dependencies.buildManifestFromConfig(projectRoot, options.config); + } + const isDefault = !options.manifest; + const path = options.manifest + ? isAbsolute(options.manifest) + ? options.manifest + : resolve(projectRoot, options.manifest) + : resolve(projectRoot, ".emdash/migrations.json"); + return dependencies.readManifest(path, isDefault); +} + +function redactExecutorMessage( + message: string, + env: Readonly>, +): string { + let redacted = message + .replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+/g, "[REDACTED_URL]") + .replace( + /\b(auth|credential|key|password|secret|signature|token)\s*[=:]\s*\S+/gi, + "$1=[REDACTED]", + ); + for (const value of Object.values(env)) { + if (value && value.length >= 4) redacted = redacted.replaceAll(value, "[REDACTED]"); + } + return redacted.replaceAll(/[\r\n\t]/g, " ").slice(0, 1_000); +} + +function safeCommandMessage( + error: unknown, + env: Readonly>, +): string { + if (error instanceof MigrateCommandError || error instanceof MigrationManifestValidationError) { + return error.message; + } + if (error instanceof Error && error.message) return redactExecutorMessage(error.message, env); + return "Migration command failed. Check the project configuration, target, and credentials."; +} + +export async function runMigrateCommand( + options: MigrateCommandOptions, + dependencies: MigrateCommandDependencies = defaultDependencies, +): Promise { + let executor: MigrationExecutor | undefined; + let disposePromise: Promise | undefined; + let interrupted = false; + let primaryFailure = false; + let exitCode: number = MIGRATE_EXIT_CODES.error; + const interruptedResult = Symbol("interrupted"); + let resolveInterruption: (() => void) | undefined; + const interruption = new Promise((resolveInterrupted) => { + resolveInterruption = () => resolveInterrupted(interruptedResult); + }); + const dispose = () => { + disposePromise ??= executor?.dispose?.() ?? Promise.resolve(); + return disposePromise; + }; + const removeSignalHandlers: (() => void)[] = []; + + const execute = async (): Promise => { + try { + validateOptions(options); + const projectRoot = await dependencies.findProjectRoot(dependencies.cwd); + const unvalidatedManifest = await loadManifest(options, projectRoot, dependencies); + const identity = await dependencies.loadProjectIdentity(projectRoot); + const manifest: MigrationManifestV1 = await validateMigrationManifest( + unvalidatedManifest, + identity, + ); + const factory = await dependencies.loadProjectExecutor( + projectRoot, + manifest.database.executorEntrypoint, + ); + executor = await factory(manifest.database.executorConfig, { + projectRoot, + env: dependencies.env, + overrides: createOverrides(options), + }); + const target = immutableTarget(executor.target); + printTarget(target, dependencies.writeStderr); + + const onSignal = async () => { + interrupted = true; + await bounded(dispose(), dependencies.cleanupTimeoutMs); + resolveInterruption?.(); + }; + removeSignalHandlers.push( + dependencies.onSignal("SIGINT", onSignal), + dependencies.onSignal("SIGTERM", onSignal), + ); + + const applying = !options.check && !options.status; + if (applying && target.kind === "d1") { + dependencies.writeStderr( + "Warning: D1 migration jobs must be serialized externally by Cloudflare account and database UUID; this command does not coordinate concurrent applies.", + ); + } + if (applying) { + if (options.expectedTargetFingerprint) { + if (options.expectedTargetFingerprint !== target.fingerprint) { + dependencies.writeStderr("Expected target fingerprint does not match the target."); + exitCode = MIGRATE_EXIT_CODES.confirmation; + return exitCode; + } + } else if (!dependencies.interactive || options.json) { + dependencies.writeStderr( + "Noninteractive apply requires --expected-target-fingerprint with the displayed fingerprint.", + ); + exitCode = MIGRATE_EXIT_CODES.confirmation; + return exitCode; + } else if (!(await dependencies.confirm(`Apply EmDash migrations to ${target.label}?`))) { + dependencies.writeStderr("Migration cancelled."); + exitCode = MIGRATE_EXIT_CODES.confirmation; + return exitCode; + } + } + + const rawReport = await Promise.race([ + executor.execute({ + action: applying ? "apply" : "check", + i18n: manifest.i18n, + artifact: { + emdashVersion: manifest.emdashVersion, + migrationSetFingerprint: manifest.migrationSet.fingerprint, + }, + }), + interruption, + ]); + if (rawReport === interruptedResult || interrupted) { + exitCode = MIGRATE_EXIT_CODES.interrupted; + return exitCode; + } + const report = safeReport(rawReport, target); + if (options.json) { + dependencies.writeStdout(JSON.stringify(report)); + } else { + printHumanReport(report, dependencies.writeStdout); + } + exitCode = reportExitCode(options, report); + return exitCode; + } catch (error) { + primaryFailure = true; + dependencies.writeStderr(safeCommandMessage(error, dependencies.env)); + exitCode = interrupted ? MIGRATE_EXIT_CODES.interrupted : MIGRATE_EXIT_CODES.error; + return exitCode; + } + }; + + try { + exitCode = await execute(); + } finally { + for (const remove of removeSignalHandlers) remove(); + if (executor) { + const cleanup = await bounded(dispose(), dependencies.cleanupTimeoutMs); + if (cleanup !== "complete" && !primaryFailure && !interrupted) { + dependencies.writeStderr("Migration executor cleanup failed."); + exitCode = MIGRATE_EXIT_CODES.error; + } + } + } + return exitCode; +} + +export const migrateCommand = defineCommand({ + meta: { + name: "migrate", + description: "Check or apply deployment-managed EmDash migrations", + }, + args: { + manifest: { type: "string", description: "Migration manifest path" }, + "from-config": { type: "boolean", description: "Build the manifest from Astro config" }, + config: { type: "string", description: "Astro config path (requires --from-config)" }, + check: { type: "boolean", description: "Check for pending or unknown migrations" }, + status: { type: "boolean", description: "Report all migration status sets" }, + json: { type: "boolean", description: "Print the stable JSON report" }, + "expected-target-fingerprint": { + type: "string", + description: "Required target fingerprint for noninteractive apply", + }, + database: { type: "string", description: "Override the SQLite database path" }, + "database-url-env": { + type: "string", + description: "Override the database URL environment-variable name", + }, + d1: { type: "string", description: "Override the D1 database UUID or name" }, + "account-id": { type: "string", description: "Override the Cloudflare account ID" }, + "wrangler-config": { type: "string", description: "Override the Wrangler config path" }, + "wrangler-env": { type: "string", description: "Override the Wrangler environment" }, + }, + async run({ args }) { + process.exitCode = await runMigrateCommand({ + manifest: args.manifest, + fromConfig: args["from-config"], + config: args.config, + check: args.check, + status: args.status, + json: args.json, + expectedTargetFingerprint: args["expected-target-fingerprint"], + database: args.database, + databaseUrlEnv: args["database-url-env"], + d1: args.d1, + accountId: args["account-id"], + wranglerConfig: args["wrangler-config"], + wranglerEnv: args["wrangler-env"], + }); + }, +}); diff --git a/packages/core/src/cli/index.ts b/packages/core/src/cli/index.ts index bd8efd0e31..b7117b8546 100644 --- a/packages/core/src/cli/index.ts +++ b/packages/core/src/cli/index.ts @@ -34,6 +34,7 @@ import { initCommand } from "./commands/init.js"; import { loginCommand, logoutCommand, whoamiCommand } from "./commands/login.js"; import { mediaCommand } from "./commands/media.js"; import { menuCommand } from "./commands/menu.js"; +import { migrateCommand } from "./commands/migrate.js"; import { pluginCommand } from "./commands/plugin.js"; import { schemaCommand } from "./commands/schema.js"; import { searchCommand } from "./commands/search-cmd.js"; @@ -54,6 +55,7 @@ const main = defineCommand({ dev: devCommand, doctor: doctorCommand, seed: seedCommand, + migrate: migrateCommand, "export-seed": exportSeedCommand, secrets: secretsCommand, // Deprecated alias kept for backwards compat; will be removed in a future minor. diff --git a/packages/core/tests/integration/cli/migrate-sqlite.test.ts b/packages/core/tests/integration/cli/migrate-sqlite.test.ts new file mode 100644 index 0000000000..af5a838133 --- /dev/null +++ b/packages/core/tests/integration/cli/migrate-sqlite.test.ts @@ -0,0 +1,180 @@ +import { spawn, spawnSync } from "node:child_process"; +import { once } from "node:events"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { MIGRATE_EXIT_CODES } from "../../../src/cli/commands/migrate.js"; +import type { CoreMigrationIdentity } from "../../../src/migrations/identity.js"; +import { ensureBuilt } from "../server.js"; + +const CLI_BIN = resolve(import.meta.dirname, "../../../dist/cli/index.mjs"); +const CORE_PACKAGE = resolve(import.meta.dirname, "../../.."); + +interface CliResult { + code: number | null; + stdout: string; + stderr: string; +} + +describe("built migrate CLI with SQLite", () => { + let projectRoot: string; + let identity: CoreMigrationIdentity; + + beforeAll(async () => { + await ensureBuilt(); + projectRoot = await mkdtemp(join(tmpdir(), "emdash-migrate-cli-")); + await mkdir(join(projectRoot, "node_modules"), { recursive: true }); + await mkdir(join(projectRoot, ".emdash"), { recursive: true }); + await writeFile(join(projectRoot, "package.json"), '{"type":"module"}'); + await symlink(CORE_PACKAGE, join(projectRoot, "node_modules", "emdash"), "dir"); + + const migrationsModule: unknown = await import( + pathToFileURL(resolve(CORE_PACKAGE, "dist/migrations/index.mjs")).href + ); + if ( + typeof migrationsModule !== "object" || + migrationsModule === null || + !("getCoreMigrationIdentity" in migrationsModule) || + typeof migrationsModule.getCoreMigrationIdentity !== "function" + ) { + throw new Error("Built migration identity export is missing"); + } + identity = await migrationsModule.getCoreMigrationIdentity(); + await writeFile( + join(projectRoot, ".emdash", "migrations.json"), + JSON.stringify({ + schemaVersion: 1, + emdashVersion: identity.emdashVersion, + migrationSet: { names: identity.names, fingerprint: identity.fingerprint }, + i18n: null, + database: { + type: "sqlite", + executorEntrypoint: "emdash/db/sqlite-migrations", + executorConfig: { url: "file:./data.db" }, + }, + }), + ); + }); + + afterAll(async () => { + if (projectRoot) await rm(projectRoot, { force: true, recursive: true }); + }); + + function run(...args: string[]): CliResult { + const result = spawnSync("node", [CLI_BIN, "migrate", ...args], { + cwd: projectRoot, + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1" }, + }); + if (result.error) throw result.error; + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; + } + + it("checks, applies once with a target fingerprint, and becomes idempotent", () => { + const initial = run("--status", "--json"); + expect(initial.code).toBe(0); + const initialReport: unknown = JSON.parse(initial.stdout); + if ( + typeof initialReport !== "object" || + initialReport === null || + !("target" in initialReport) || + typeof initialReport.target !== "object" || + initialReport.target === null || + !("fingerprint" in initialReport.target) || + typeof initialReport.target.fingerprint !== "string" + ) { + throw new Error("CLI returned an invalid target report"); + } + expect(initial.stderr).toContain(initialReport.target.fingerprint); + + const applied = run( + "--json", + "--expected-target-fingerprint", + initialReport.target.fingerprint, + ); + expect(applied.code).toBe(0); + const appliedReport: unknown = JSON.parse(applied.stdout); + if ( + typeof appliedReport !== "object" || + appliedReport === null || + !("executed" in appliedReport) || + !Array.isArray(appliedReport.executed) + ) { + throw new Error("CLI returned an invalid apply report"); + } + expect(appliedReport.executed.length).toBeGreaterThan(0); + expect(appliedReport).toMatchObject({ pending: [] }); + + const check = run("--check", "--json"); + expect(check.code).toBe(0); + expect(JSON.parse(check.stdout)).toMatchObject({ pending: [], unknownApplied: [] }); + }); + + it("bounds real signal cleanup without waiting for in-flight execution", async () => { + const executorPackage = join(projectRoot, "node_modules", "emdash-test-migration-executor"); + const markerPath = join(projectRoot, "disposed.txt"); + await mkdir(executorPackage, { recursive: true }); + await writeFile( + join(executorPackage, "package.json"), + '{"name":"emdash-test-migration-executor","type":"module","exports":"./index.js"}', + ); + await writeFile( + join(executorPackage, "index.js"), + `import { writeFile } from "node:fs/promises"; + let timer; + export function createMigrationExecutor(config) { + return { + target: Object.freeze({ kind: "test", label: "signal-target", fingerprint: "${"d".repeat(64)}" }), + execute() { return new Promise(() => { timer = setInterval(() => {}, 1_000); }); }, + async dispose() { clearInterval(timer); await writeFile(config.markerPath, "disposed"); } + }; + }`, + ); + await writeFile( + join(projectRoot, "signal-migrations.json"), + JSON.stringify({ + schemaVersion: 1, + emdashVersion: identity.emdashVersion, + migrationSet: { names: identity.names, fingerprint: identity.fingerprint }, + i18n: null, + database: { + type: "sqlite", + executorEntrypoint: "emdash-test-migration-executor", + executorConfig: { markerPath }, + }, + }), + ); + + const child = spawn( + "node", + [CLI_BIN, "migrate", "--status", "--manifest", "signal-migrations.json"], + { + cwd: projectRoot, + env: { ...process.env, NO_COLOR: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + child.stderr.setEncoding("utf8"); + let stderr = ""; + await new Promise((resolveTarget, rejectTarget) => { + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + if (stderr.includes("Target fingerprint:")) resolveTarget(); + }); + child.once("error", rejectTarget); + child.once("exit", (code, signal) => + rejectTarget(new Error(`CLI exited before signal: ${code ?? signal}`)), + ); + }); + expect(child.kill("SIGTERM")).toBe(true); + const [code, signal] = await once(child, "exit"); + + expect(code).toBe(MIGRATE_EXIT_CODES.interrupted); + expect(signal).toBeNull(); + expect(await readFile(markerPath, "utf8")).toBe("disposed"); + }); +}); diff --git a/packages/core/tests/unit/cli/migrate.test.ts b/packages/core/tests/unit/cli/migrate.test.ts new file mode 100644 index 0000000000..faa93566a8 --- /dev/null +++ b/packages/core/tests/unit/cli/migrate.test.ts @@ -0,0 +1,468 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { MigrationSignal } from "../../../src/cli/commands/migrate.js"; +import { + loadProjectMigrationExecutor, + loadProjectMigrationIdentity, + MIGRATE_EXIT_CODES, + readMigrationManifestFile, + runMigrateCommand, + type MigrateCommandDependencies, +} from "../../../src/cli/commands/migrate.js"; +import { createCoreMigrationIdentity } from "../../../src/migrations/identity.js"; +import type { MigrationManifestV1 } from "../../../src/migrations/manifest.js"; +import type { MigrationExecutor } from "../../../src/migrations/protocol.js"; + +async function fixture( + options: { + pending?: string[]; + unknownApplied?: string[]; + executed?: string[]; + interactive?: boolean; + targetKind?: string; + } = {}, +) { + const identity = await createCoreMigrationIdentity("1.2.3", ["001_initial"]); + const manifest: MigrationManifestV1 = { + schemaVersion: 1, + emdashVersion: identity.emdashVersion, + migrationSet: { names: [...identity.names], fingerprint: identity.fingerprint }, + i18n: null, + database: { + type: "sqlite", + executorEntrypoint: "project-executor", + executorConfig: { url: "file:./data.db" }, + }, + }; + const target = Object.freeze({ + kind: options.targetKind ?? "sqlite", + label: "/project/data.db", + fingerprint: "a".repeat(64), + }); + const calls: string[] = []; + const execute = vi.fn(async () => { + calls.push("execute"); + return { + target, + knownApplied: [], + pending: options.pending ?? [], + unknownApplied: options.unknownApplied ?? [], + executed: options.executed ?? [], + }; + }); + const dispose = vi.fn(async () => undefined); + const executor: MigrationExecutor = { target, execute, dispose }; + const createExecutor = vi.fn(async () => { + calls.push("target"); + return executor; + }); + const stdout: string[] = []; + const stderr: string[] = []; + const signalHandlers = new Map Promise>(); + const dependencies: MigrateCommandDependencies = { + cwd: "/project/subdirectory", + env: {}, + interactive: options.interactive ?? false, + findProjectRoot: vi.fn(async () => { + calls.push("root"); + return "/project"; + }), + readManifest: vi.fn(async () => { + calls.push("manifest"); + return manifest; + }), + buildManifestFromConfig: vi.fn(async () => manifest), + loadProjectIdentity: vi.fn(async () => { + calls.push("identity"); + return identity; + }), + loadProjectExecutor: vi.fn(async () => { + calls.push("executor-module"); + return createExecutor; + }), + confirm: vi.fn(async () => true), + writeStdout: (value) => { + calls.push("stdout"); + stdout.push(value); + }, + writeStderr: (value) => { + calls.push("stderr"); + stderr.push(value); + }, + onSignal: (signal, handler) => { + signalHandlers.set(signal, handler); + return () => signalHandlers.delete(signal); + }, + cleanupTimeoutMs: 10, + }; + return { + calls, + createExecutor, + dependencies, + dispose, + execute, + manifest, + signalHandlers, + stderr, + stdout, + target, + }; +} + +describe("runMigrateCommand", () => { + it("validates project identity before loading the executor and prints the target before SQL", async () => { + const context = await fixture({ pending: ["001_initial"] }); + + const exitCode = await runMigrateCommand( + { check: true, database: "override.db" }, + context.dependencies, + ); + + expect(exitCode).toBe(MIGRATE_EXIT_CODES.pending); + expect(context.calls.slice(0, 5)).toEqual([ + "root", + "manifest", + "identity", + "executor-module", + "target", + ]); + expect(context.calls.indexOf("stderr")).toBeLessThan(context.calls.indexOf("execute")); + expect(context.execute).toHaveBeenCalledOnce(); + expect(context.execute).toHaveBeenCalledWith({ + action: "check", + i18n: null, + artifact: { + emdashVersion: "1.2.3", + migrationSetFingerprint: context.manifest.migrationSet.fingerprint, + }, + }); + expect(context.dependencies.loadProjectExecutor).toHaveBeenCalledWith( + "/project", + "project-executor", + ); + expect(context.dispose).toHaveBeenCalledOnce(); + }); + + it("requires the expected target fingerprint for noninteractive apply", async () => { + const missing = await fixture(); + const mismatch = await fixture(); + + await expect(runMigrateCommand({}, missing.dependencies)).resolves.toBe( + MIGRATE_EXIT_CODES.confirmation, + ); + await expect( + runMigrateCommand({ expectedTargetFingerprint: "b".repeat(64) }, mismatch.dependencies), + ).resolves.toBe(MIGRATE_EXIT_CODES.confirmation); + expect(missing.execute).not.toHaveBeenCalled(); + expect(mismatch.execute).not.toHaveBeenCalled(); + expect(missing.stderr.join("\n")).toContain("--expected-target-fingerprint"); + expect(mismatch.stderr.join("\n")).not.toContain("b".repeat(64)); + }); + + it("passes every target override to the project-local executor", async () => { + const context = await fixture({ pending: ["001_initial"] }); + const overrides = { + database: "custom.db", + databaseUrlEnv: "CUSTOM_DATABASE_URL", + d1: "database-id", + accountId: "account-id", + wranglerConfig: "wrangler.custom.jsonc", + wranglerEnv: "staging", + }; + + await runMigrateCommand({ check: true, ...overrides }, context.dependencies); + + expect(context.createExecutor).toHaveBeenCalledWith(context.manifest.database.executorConfig, { + projectRoot: "/project", + env: {}, + overrides, + }); + }); + + it("rejects a stale project identity before resolving an executor or target", async () => { + const context = await fixture(); + context.dependencies.loadProjectIdentity = vi.fn(async () => + createCoreMigrationIdentity("9.9.9", ["001_initial"]), + ); + + const exitCode = await runMigrateCommand({ status: true }, context.dependencies); + + expect(exitCode).toBe(MIGRATE_EXIT_CODES.error); + expect(context.dependencies.loadProjectExecutor).not.toHaveBeenCalled(); + expect(context.createExecutor).not.toHaveBeenCalled(); + }); + + it("prompts before interactive apply and reports executed migration names", async () => { + const context = await fixture({ interactive: true, executed: ["001_initial"] }); + + const exitCode = await runMigrateCommand({}, context.dependencies); + + expect(exitCode).toBe(MIGRATE_EXIT_CODES.success); + expect(context.dependencies.confirm).toHaveBeenCalledOnce(); + expect(context.execute).toHaveBeenCalledOnce(); + expect(context.stdout.join("\n")).toContain("001_initial"); + }); + + it("uses stable check and status exit-code behavior", async () => { + const pending = await fixture({ pending: ["001_initial"] }); + const unknown = await fixture({ + pending: ["001_initial"], + unknownApplied: ["999_foreign"], + }); + const status = await fixture({ + pending: ["001_initial"], + unknownApplied: ["999_foreign"], + }); + + await expect(runMigrateCommand({ check: true }, pending.dependencies)).resolves.toBe( + MIGRATE_EXIT_CODES.pending, + ); + await expect(runMigrateCommand({ check: true }, unknown.dependencies)).resolves.toBe( + MIGRATE_EXIT_CODES.unknownApplied, + ); + await expect(runMigrateCommand({ status: true }, status.dependencies)).resolves.toBe( + MIGRATE_EXIT_CODES.success, + ); + expect(status.stdout.join("\n")).toContain("Known applied"); + expect(status.stdout.join("\n")).toContain("Pending"); + expect(status.stdout.join("\n")).toContain("Unknown applied"); + }); + + it("emits one stable JSON report while writing the preflight target to stderr", async () => { + const context = await fixture({ pending: ["001_initial"] }); + + const exitCode = await runMigrateCommand({ status: true, json: true }, context.dependencies); + + expect(exitCode).toBe(MIGRATE_EXIT_CODES.success); + expect(context.stdout).toHaveLength(1); + expect(JSON.parse(context.stdout[0]!)).toEqual({ + target: context.target, + knownApplied: [], + pending: ["001_initial"], + unknownApplied: [], + executed: [], + }); + expect(context.stderr.join("\n")).toContain(context.target.fingerprint); + }); + + it("warns before D1 applies without warning for read-only or non-D1 operations", async () => { + const apply = await fixture({ pending: ["001_initial"], targetKind: "d1" }); + const check = await fixture({ pending: ["001_initial"], targetKind: "d1" }); + const status = await fixture({ pending: ["001_initial"], targetKind: "d1" }); + const sqlite = await fixture({ pending: ["001_initial"] }); + + await runMigrateCommand( + { + expectedTargetFingerprint: apply.target.fingerprint, + json: true, + }, + apply.dependencies, + ); + await runMigrateCommand({ check: true }, check.dependencies); + await runMigrateCommand({ status: true }, status.dependencies); + await runMigrateCommand( + { expectedTargetFingerprint: sqlite.target.fingerprint }, + sqlite.dependencies, + ); + + const warning = apply.stderr.find((line) => line.includes("serialized externally")); + expect(warning).toContain("Cloudflare account and database UUID"); + expect(apply.calls.lastIndexOf("stderr")).toBeLessThan(apply.calls.indexOf("execute")); + expect(apply.stdout).toHaveLength(1); + expect(() => JSON.parse(apply.stdout[0]!)).not.toThrow(); + expect(check.stderr.join("\n")).not.toContain("serialized externally"); + expect(status.stderr.join("\n")).not.toContain("serialized externally"); + expect(sqlite.stderr.join("\n")).not.toContain("serialized externally"); + }); + + it("disposes once on failure without leaking executor errors or environment secrets", async () => { + const context = await fixture(); + context.dependencies.env = { DATABASE_URL: "postgres://user:very-secret@example.com/db" }; + context.execute.mockRejectedValueOnce( + new Error("failed postgres://user:very-secret@example.com/db token=very-secret"), + ); + + const exitCode = await runMigrateCommand( + { expectedTargetFingerprint: context.target.fingerprint }, + context.dependencies, + ); + + expect(exitCode).toBe(MIGRATE_EXIT_CODES.error); + expect(context.execute).toHaveBeenCalledOnce(); + expect(context.dispose).toHaveBeenCalledOnce(); + expect(context.stderr.join("\n")).not.toContain("very-secret"); + expect(context.stderr.join("\n")).not.toContain("postgres://"); + }); + + it("preserves useful executor errors after redaction", async () => { + const context = await fixture(); + context.execute.mockRejectedValueOnce( + new Error("Cannot apply migrations with unknown applied migrations: 999_foreign"), + ); + + await runMigrateCommand( + { expectedTargetFingerprint: context.target.fingerprint }, + context.dependencies, + ); + + expect(context.stderr.join("\n")).toContain( + "Cannot apply migrations with unknown applied migrations: 999_foreign", + ); + }); + + it("fails on cleanup errors when there is no primary failure", async () => { + const context = await fixture({ pending: ["001_initial"] }); + context.dispose.mockRejectedValueOnce(new Error("cleanup secret")); + + const exitCode = await runMigrateCommand({ status: true }, context.dependencies); + + expect(exitCode).toBe(MIGRATE_EXIT_CODES.error); + expect(context.stderr.join("\n")).toContain("Migration executor cleanup failed"); + expect(context.stderr.join("\n")).not.toContain("cleanup secret"); + }); + + it("bounds signal cleanup and does not let cleanup replace the primary error", async () => { + const context = await fixture(); + let rejectExecute: ((error: Error) => void) | undefined; + context.execute.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectExecute = reject; + }), + ); + context.dispose.mockRejectedValueOnce(new Error("cleanup secret")); + + const run = runMigrateCommand( + { expectedTargetFingerprint: context.target.fingerprint }, + context.dependencies, + ); + await vi.waitFor(() => expect(context.execute).toHaveBeenCalledOnce()); + await context.signalHandlers.get("SIGTERM")?.(); + rejectExecute?.(new Error("primary secret")); + + await expect(run).resolves.toBe(MIGRATE_EXIT_CODES.interrupted); + expect(context.dispose).toHaveBeenCalledOnce(); + expect(context.stderr.join("\n")).not.toContain("primary secret"); + expect(context.stderr.join("\n")).not.toContain("cleanup secret"); + }); + + it("rejects conflicting discovery options before reading a manifest", async () => { + const context = await fixture(); + + const exitCode = await runMigrateCommand( + { manifest: "custom.json", fromConfig: true, config: "astro.config.mjs" }, + context.dependencies, + ); + + expect(exitCode).toBe(MIGRATE_EXIT_CODES.error); + expect(context.dependencies.readManifest).not.toHaveBeenCalled(); + expect(context.dependencies.buildManifestFromConfig).not.toHaveBeenCalled(); + }); + + it("rejects check and status together before reading a manifest", async () => { + const context = await fixture(); + + const exitCode = await runMigrateCommand({ check: true, status: true }, context.dependencies); + + expect(exitCode).toBe(MIGRATE_EXIT_CODES.error); + expect(context.dependencies.readManifest).not.toHaveBeenCalled(); + }); + + it("explains how to create or bypass a missing default manifest", async () => { + await expect( + readMigrationManifestFile("/missing/.emdash/migrations.json", true), + ).rejects.toThrow("Build the project or use --from-config"); + }); + + it("does not report other manifest read failures as a missing build", async () => { + const directory = await mkdtemp(join(tmpdir(), "emdash-manifest-read-error-")); + try { + await expect(readMigrationManifestFile(directory, true)).rejects.toThrow( + "Could not read migration manifest", + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); + + it("announces explicit trusted config evaluation", async () => { + const context = await fixture({ pending: ["001_initial"] }); + + await runMigrateCommand( + { fromConfig: true, config: "astro.config.mjs", status: true }, + context.dependencies, + ); + + expect(context.dependencies.buildManifestFromConfig).toHaveBeenCalledWith( + "/project", + "astro.config.mjs", + ); + expect(context.stderr.join("\n")).toContain("Using trusted evaluated Astro configuration"); + }); +}); + +describe("project-local migration module resolution", () => { + const tempDirectories: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })), + ); + }); + + async function writeModule(path: string, contents: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents); + } + + it("resolves identity and executor from pnpm-linked project dependencies", async () => { + const root = await mkdtemp(join(tmpdir(), "emdash-cli-project-resolution-")); + tempDirectories.push(root); + const modules = join(root, "node_modules"); + const emdashPackage = join(modules, ".pnpm", "emdash@project", "node_modules", "emdash"); + const identity = await createCoreMigrationIdentity("7.6.5-project", ["001_project"]); + await writeFile(join(root, "package.json"), '{"type":"module"}'); + await writeModule( + join(emdashPackage, "package.json"), + JSON.stringify({ + name: "emdash", + type: "module", + exports: { + "./migrations": "./migrations.js", + "./project-executor": "./executor.js", + }, + }), + ); + await writeModule( + join(emdashPackage, "migrations.js"), + `export async function getCoreMigrationIdentity() { return ${JSON.stringify(identity)}; }`, + ); + await writeModule( + join(emdashPackage, "executor.js"), + `export async function createMigrationExecutor(config, context) { + return { + target: { kind: "test", label: context.projectRoot + "/" + config.name, fingerprint: "${"c".repeat(64)}" }, + async execute() { throw new Error("not called"); } + }; + }`, + ); + await mkdir(modules, { recursive: true }); + await symlink(emdashPackage, join(modules, "emdash"), "dir"); + + await expect(loadProjectMigrationIdentity(root)).resolves.toEqual(identity); + const factory = await loadProjectMigrationExecutor(root, "emdash/project-executor"); + const executor = await factory( + { name: "project.db" }, + { projectRoot: root, env: {}, overrides: {} }, + ); + expect(executor.target).toEqual({ + kind: "test", + label: `${root}/project.db`, + fingerprint: "c".repeat(64), + }); + }); +}); From 34b57bffebac4792c5a2ad9f2ee73ccd756eb0d1 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 13 Aug 2026 09:42:05 +0100 Subject: [PATCH 2/3] fix(core): validate migration executor reports --- packages/core/src/cli/commands/migrate.ts | 17 +++++++-- packages/core/tests/unit/cli/migrate.test.ts | 37 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/core/src/cli/commands/migrate.ts b/packages/core/src/cli/commands/migrate.ts index ed57d7abc0..0692975302 100644 --- a/packages/core/src/cli/commands/migrate.ts +++ b/packages/core/src/cli/commands/migrate.ts @@ -75,7 +75,7 @@ class MigrateCommandError extends Error { } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; + return typeof value === "object" && value !== null && !Array.isArray(value); } function moduleExport(module: unknown, name: string): unknown { @@ -298,8 +298,21 @@ function safeReport(value: unknown, target: Readonly): Migratio if (!isRecord(value)) { throw new MigrateCommandError("The migration executor returned an invalid report."); } + const reportTarget = immutableTarget(value.target); + if ( + reportTarget.kind !== target.kind || + reportTarget.label !== target.label || + reportTarget.fingerprint !== target.fingerprint || + reportTarget.accountId !== target.accountId || + reportTarget.environment !== target.environment || + reportTarget.resourceId !== target.resourceId + ) { + throw new MigrateCommandError( + "The migration executor report target does not match the confirmed target.", + ); + } return { - target: { ...target }, + target: { ...reportTarget }, knownApplied: safeMigrationNames(value.knownApplied, "knownApplied"), pending: safeMigrationNames(value.pending, "pending"), unknownApplied: safeMigrationNames(value.unknownApplied, "unknownApplied"), diff --git a/packages/core/tests/unit/cli/migrate.test.ts b/packages/core/tests/unit/cli/migrate.test.ts index faa93566a8..dbff2d2d89 100644 --- a/packages/core/tests/unit/cli/migrate.test.ts +++ b/packages/core/tests/unit/cli/migrate.test.ts @@ -249,6 +249,43 @@ describe("runMigrateCommand", () => { expect(context.stderr.join("\n")).toContain(context.target.fingerprint); }); + it("rejects array-shaped executor reports", async () => { + const context = await fixture(); + const report = Object.assign([], { + target: context.target, + knownApplied: [], + pending: [], + unknownApplied: [], + executed: [], + }); + context.execute.mockResolvedValueOnce(report); + + const exitCode = await runMigrateCommand({ status: true, json: true }, context.dependencies); + + expect(exitCode).toBe(MIGRATE_EXIT_CODES.error); + expect(context.stdout).toEqual([]); + expect(context.stderr.join("\n")).toContain("invalid report"); + expect(context.dispose).toHaveBeenCalledOnce(); + }); + + it("rejects reports for a different migration target", async () => { + const context = await fixture(); + context.execute.mockResolvedValueOnce({ + target: { ...context.target, fingerprint: "b".repeat(64) }, + knownApplied: [], + pending: [], + unknownApplied: [], + executed: [], + }); + + const exitCode = await runMigrateCommand({ status: true, json: true }, context.dependencies); + + expect(exitCode).toBe(MIGRATE_EXIT_CODES.error); + expect(context.stdout).toEqual([]); + expect(context.stderr.join("\n")).toContain("report target does not match"); + expect(context.dispose).toHaveBeenCalledOnce(); + }); + it("warns before D1 applies without warning for read-only or non-D1 operations", async () => { const apply = await fixture({ pending: ["001_initial"], targetKind: "d1" }); const check = await fixture({ pending: ["001_initial"], targetKind: "d1" }); From 05bc7da14021b6396d63073527821f1af59aa242 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 13 Aug 2026 09:56:42 +0100 Subject: [PATCH 3/3] docs(core): remove stale CLI inventory --- packages/core/src/cli/index.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/packages/core/src/cli/index.ts b/packages/core/src/cli/index.ts index b7117b8546..87dac5ed5c 100644 --- a/packages/core/src/cli/index.ts +++ b/packages/core/src/cli/index.ts @@ -1,28 +1,5 @@ #!/usr/bin/env node -/** - * EmDash CLI - * - * Built with citty + clack (same stack as Nuxt CLI) - * - * Commands: - * - init: Bootstrap database from template config, or interactive setup - * - types: Generate TypeScript types from schema - * - dev: [DEPRECATED, hidden] Run dev server with a local SQLite database - * - seed: Apply a seed file to the database - * - export-seed: Export database schema and content as a seed file - * - secrets: Generate and inspect EmDash secrets (encryption keys, etc.) - * - auth: [DEPRECATED] Generate auth secret (use `secrets` instead) - * - login/logout/whoami: Session management - * - content: Create, read, update, delete content - * - schema: Manage collections and fields - * - media: Upload and manage media - * - search: Full-text search - * - taxonomy: Manage taxonomies and terms - * - menu: Manage navigation menus - * - plugin: Plugin management (init, bundle, validate, publish, login, logout) - */ - import { defineCommand, runMain } from "citty"; import { authCommand } from "./commands/auth.js";