UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

861 lines (767 loc) 27.9 kB
import { $inject, $state, AlephaError, z } from "alepha"; import { type AppEntry, AppEntryProvider, ViteBuildProvider } from "alepha/cli"; import { CloudflareAdapter, type DetectedResources, NamingService, PlatformInspector, PlatformOrchestrator, type PlatformPlanOutput, type PlatformStatusOutput, platformOptions, type ResolvedPlatformConfig, resolveTenant, VercelAdapter, } from "alepha/cli/platform-lib"; import { $command, EnvUtils } from "alepha/command"; import { $logger, ConsoleColorProvider } from "alepha/logger"; import { SecretsCommand } from "./SecretsCommand.ts"; export class PlatformCommand { protected readonly log = $logger(); protected readonly options = $state(platformOptions); protected readonly orchestrator = $inject(PlatformOrchestrator); protected readonly inspector = $inject(PlatformInspector); protected readonly naming = $inject(NamingService); protected readonly boot = $inject(AppEntryProvider); protected readonly viteBuild = $inject(ViteBuildProvider); protected readonly color = $inject(ConsoleColorProvider); protected readonly envUtils = $inject(EnvUtils); protected readonly secretsCommand = $inject(SecretsCommand); /** * Common flags for env targeting. */ protected readonly envFlags = z.object({ env: z .text({ aliases: ["e"], description: "Target environment", }) .optional(), tenant: z .text({ description: "Tenant slug (apps with tenancy: optional | required). Names resources <tenant>-<project>-<env> and serves <tenant>.<domain>.", }) .optional(), verbose: z .boolean() .meta({ aliases: ["v"] }) .describe("Verbose output") .optional(), json: z.boolean().describe("Output as JSON").optional(), }); // ----------------------------------------------------------------------- // alepha p plan // ----------------------------------------------------------------------- protected readonly plan = $command({ name: "plan", description: "Show project topology and resource names", flags: this.envFlags, handler: async ({ flags, root }) => { const config = await this.inspector.resolveConfig(root); const env = flags.env ?? config.defaultEnv; const envConfig = config.environments[env]; const adapterName = envConfig?.adapter ?? "cloudflare"; const apps = await this.resolveApps( root, config, this.isServerless(adapterName), ); const tenant = resolveTenant(config.tenancy, flags.tenant); const namingCtx = this.naming.forContext(config.project, env, tenant); // --- Data collection --- const hasDB = apps.some((a) => a.resources.hasDatabase); const hasBucket = apps.some((a) => a.resources.hasBucket); const envVars = await this.envUtils.parseEnv(root, [`.env.${env}`]); const resources: Array<{ label: string; value: string }> = []; const deployLabel = adapterName === "vercel" ? "Project" : "Worker"; resources.push({ label: deployLabel, value: namingCtx.worker() }); if (adapterName === "cloudflare") { if (hasDB) { const dbUrl = envVars.DATABASE_URL ?? process.env.DATABASE_URL; if (dbUrl?.startsWith("postgres:")) { resources.push({ label: "Hyperdrive", value: namingCtx.hyperdrive(), }); } else { resources.push({ label: "D1", value: namingCtx.d1() }); } } if (hasBucket) { resources.push({ label: "R2", value: namingCtx.r2() }); } for (const app of apps) { if (app.resources.hasKV) { resources.push({ label: "KV", value: namingCtx.kv() }); } if (app.resources.hasQueue) { resources.push({ label: "Queue", value: namingCtx.queue() }); } } } const excludedKeys = adapterName === "vercel" ? VercelAdapter.EXCLUDED_SECRET_KEYS : CloudflareAdapter.EXCLUDED_SECRET_KEYS; const secretCount = Object.entries(envVars).filter( ([key, value]) => value && !excludedKeys.has(key) && !key.startsWith("VITE_"), ).length; // --- JSON output --- if (flags.json) { const environments: Record< string, { adapter: string; domain?: string } > = {}; for (const [key, val] of Object.entries(config.environments)) { environments[key] = { adapter: val.adapter, ...(val.domain ? { domain: val.domain } : {}), }; } const output: PlatformPlanOutput = { project: config.project, env, mode: "standalone", apps: apps.map((a) => ({ name: a.name, path: a.path, resources: a.resources, })), environments, resources, secretCount, }; process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); return; } // --- Tree output --- const c = this.color; process.stdout.write( `\n\u{1F4E6} ${c.set("WHITE_BOLD", config.project)} ${c.set("GREY_DARK", "\u2014")} ${c.set("CYAN", env)}\n\n`, ); process.stdout.write(` ${c.set("GREY_LIGHT", "Mode:")} standalone\n`); process.stdout.write(`\n ${c.set("GREY_LIGHT", "Environments:")}\n`); const envKeys = Object.keys(config.environments); for (const [i, envKey] of envKeys.entries()) { const envConfig = config.environments[envKey]; const prefix = i === envKeys.length - 1 ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"; const domain = envConfig.domain ? ` ${c.set("GREY_DARK", envConfig.domain)}` : ""; const marker = envKey === env ? ` ${c.set("GREEN", "\u25C0")}` : ""; process.stdout.write( ` ${c.set("GREY_DARK", prefix)} ${c.set("CYAN", envKey.padEnd(10))} ${c.set("GREY_LIGHT", envConfig.adapter)}${domain}${marker}\n`, ); } process.stdout.write(`\n ${c.set("GREY_LIGHT", "Resources:")}\n`); if (secretCount > 0) { resources.push({ label: "Secrets", value: `${secretCount} from .env.${env}`, }); } for (const [i, res] of resources.entries()) { const isLast = i === resources.length - 1; const branch = isLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"; process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("GREY_LIGHT", res.label.padEnd(11))} ${c.set("CYAN", res.value)}\n`, ); } process.stdout.write("\n"); }, }); // ----------------------------------------------------------------------- // alepha p up // ----------------------------------------------------------------------- protected readonly up = $command({ name: "up", mode: "production", description: "Build, migrate, and deploy", flags: z.object({ ...this.envFlags.properties, prebuilt: z .boolean() .describe( "Pre-built mode. Skips the Vite bundle steps; only regenerates the target deploy config (wrangler.jsonc) so it reflects current bindings and per-tenant overrides. Use when `dist/` is already produced upstream (e.g. inside Alepha Rocket).", ) .optional(), }), handler: async ({ flags, root, run }) => { process.env.NODE_ENV = "production"; const config = await this.inspector.resolveConfig(root); const env = flags.env ?? config.defaultEnv; const adapter = config.environments[env]?.adapter ?? "cloudflare"; const apps = await this.resolveApps( root, config, this.isServerless(adapter), { prebuilt: flags.prebuilt }, ); const result = await this.orchestrator.up({ root, env, entry: apps[0].entry, resources: apps[0].resources, run, prebuilt: flags.prebuilt, tenant: flags.tenant, }); if (flags.json) { process.stdout.write( `${JSON.stringify( { status: "succeeded", project: config.project, env, urls: result.urls, domain: result.domain, }, null, 2, )}\n`, ); } else { this.orchestrator.printUpSummary(result); } }, }); // ----------------------------------------------------------------------- // alepha p down // ----------------------------------------------------------------------- protected readonly down = $command({ name: "down", description: "Tear down an environment", flags: z.object({ ...this.envFlags.properties, yes: z .boolean() .meta({ aliases: ["y"] }) .describe( "Skip the interactive confirmation. Required for non-interactive callers (CI, Alepha Rocket). The caller is responsible for not invoking this accidentally — there's no second chance.", ) .optional(), }), handler: async ({ flags, root, run, ask }) => { if (!flags.env) { throw new AlephaError( "--env is required for teardown. This command deletes resources.", ); } const config = await this.inspector.resolveConfig(root); const adapter = config.environments[flags.env]?.adapter ?? "cloudflare"; const apps = await this.resolveApps( root, config, this.isServerless(adapter), ); const completed = await this.orchestrator.down({ root, env: flags.env, entry: apps[0].entry, resources: apps[0].resources, run, tenant: flags.tenant, confirm: async (prompt) => { if (flags.yes) { return flags.env as string; } ask.intro("Confirm teardown"); const value = await ask(prompt); ask.outro(""); return value; }, }); if (flags.json) { process.stdout.write( `${JSON.stringify( { status: completed ? "succeeded" : "aborted", project: config.project, env: flags.env, }, null, 2, )}\n`, ); } }, }); // ----------------------------------------------------------------------- // alepha p status // ----------------------------------------------------------------------- protected readonly status = $command({ name: "status", aliases: ["s"], description: "Show deployed state", flags: this.envFlags, handler: async ({ flags, root, run }) => { const config = await this.inspector.resolveConfig(root); const env = flags.env ?? config.defaultEnv; const adapter = config.environments[env]?.adapter ?? "cloudflare"; const apps = await this.resolveApps( root, config, this.isServerless(adapter), ); const { state } = await this.orchestrator.status({ root, env, entry: apps[0].entry, resources: apps[0].resources, run, tenant: flags.tenant, }); // --- JSON output --- if (flags.json) { run.end(); const output: PlatformStatusOutput = { project: config.project, env, adapter: config.environments[env].adapter, ...state, }; process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); return; } // --- Tree output --- run.end(); const c = this.color; process.stdout.write( `\n\u{1F4E6} ${c.set("WHITE_BOLD", config.project)} ${c.set("GREY_DARK", "\u2014")} ${c.set("CYAN", env)} ${c.set("GREY_DARK", `(${config.environments[env].adapter})`)}\n\n`, ); const hasDB = state.databases.length > 0; const hasBuckets = state.buckets.length > 0; process.stdout.write(` ${c.set("GREY_LIGHT", "Workers:")}\n`); for (const [i, w] of state.workers.entries()) { const isLast = i === state.workers.length - 1; const branch = isLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"; if (w.exists) { const versionShort = w.version?.slice(0, 8) ?? "unknown"; const tag = w.tag ? ` ${c.set("GREY_DARK", `(${w.tag})`)}` : ""; const date = w.createdAt ? ` ${c.set("GREY_DARK", "\u2014")} ${c.set("GREY_DARK", new Date(w.createdAt).toLocaleString())}` : ""; process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", w.name)} ${c.set("GREEN", "\u2713")} ${c.set("GREY_LIGHT", versionShort)}${tag}${date}\n`, ); } else { process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", w.name)} ${c.set("RED", "\u2717")} ${c.set("RED", "not deployed")}\n`, ); } } if (hasDB) { const envVars = await this.envUtils.parseEnv(root, [`.env.${env}`]); const dbUrl = envVars.DATABASE_URL ?? process.env.DATABASE_URL; const dbLabel = dbUrl?.startsWith("postgres:") ? "Hyperdrive:" : "Database:"; process.stdout.write(`\n ${c.set("GREY_LIGHT", dbLabel)}\n`); for (const [i, db] of state.databases.entries()) { const isLast = i === state.databases.length - 1; const branch = isLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"; if (db.exists) { const id = db.id ? ` ${c.set("GREY_LIGHT", db.id.slice(0, 8))}` : ""; const detail = db.detail ? ` ${c.set("GREY_DARK", "\u2014")} ${c.set("GREY_DARK", db.detail.slice(0, 40))}` : ""; process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", db.name)} ${c.set("GREEN", "\u2713")}${id}${detail}\n`, ); } else { process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", db.name)} ${c.set("RED", "\u2717")} ${c.set("RED", "not provisioned")}\n`, ); } } } if (hasBuckets) { process.stdout.write(`\n ${c.set("GREY_LIGHT", "Buckets:")}\n`); for (const [i, b] of state.buckets.entries()) { const isLast = i === state.buckets.length - 1; const branch = isLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"; if (b.exists) { process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", b.name)} ${c.set("GREEN", "\u2713")}\n`, ); } else { process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", b.name)} ${c.set("RED", "\u2717")} ${c.set("RED", "not provisioned")}\n`, ); } } } if (state.kvNamespaces.length > 0) { process.stdout.write(`\n ${c.set("GREY_LIGHT", "KV:")}\n`); for (const [i, kv] of state.kvNamespaces.entries()) { const isLast = i === state.kvNamespaces.length - 1; const branch = isLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"; if (kv.exists) { const id = kv.id ? ` ${c.set("GREY_LIGHT", kv.id.slice(0, 8))}` : ""; process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", kv.name)} ${c.set("GREEN", "\u2713")}${id}\n`, ); } else { process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", kv.name)} ${c.set("RED", "\u2717")} ${c.set("RED", "not provisioned")}\n`, ); } } } if (state.queues.length > 0) { process.stdout.write(`\n ${c.set("GREY_LIGHT", "Queues:")}\n`); for (const [i, q] of state.queues.entries()) { const isLast = i === state.queues.length - 1; const branch = isLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"; if (q.exists) { const id = q.id ? ` ${c.set("GREY_LIGHT", q.id.slice(0, 8))}` : ""; process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", q.name)} ${c.set("GREEN", "\u2713")}${id}\n`, ); } else { process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", q.name)} ${c.set("RED", "\u2717")} ${c.set("RED", "not provisioned")}\n`, ); } } } if (state.secrets.length > 0) { process.stdout.write(`\n ${c.set("GREY_LIGHT", "Secrets:")}\n`); for (const [i, s] of state.secrets.entries()) { const isLast = i === state.secrets.length - 1; const branch = isLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"; const icon = s.deployed ? c.set("GREEN", "\u2713") : c.set("RED", "\u2717"); process.stdout.write( ` ${c.set("GREY_DARK", branch)} ${c.set("CYAN", s.name)} ${icon}\n`, ); } } process.stdout.write("\n"); }, }); // ----------------------------------------------------------------------- // Granular commands // ----------------------------------------------------------------------- protected readonly build = $command({ name: "build", mode: "production", description: "Build all apps locally", flags: this.envFlags, handler: async ({ flags, root, run }) => { process.env.NODE_ENV = "production"; const config = await this.inspector.resolveConfig(root); const env = flags.env ?? config.defaultEnv; const envConfig = config.environments[env]; const adapter = this.orchestrator.resolveAdapter(envConfig.adapter); const apps = await this.resolveApps( root, config, this.isServerless(envConfig.adapter), ); const tenant = resolveTenant(config.tenancy, flags.tenant); const namingCtx = this.naming.forContext(config.project, env, tenant); const ctx = { project: config.project, env, envConfig, entry: apps[0].entry, resources: apps[0].resources, root, naming: namingCtx, tenant, }; await adapter.build(ctx, run); }, }); protected readonly deploy = $command({ name: "deploy", description: "Deploy apps to cloud", flags: this.envFlags, handler: async ({ flags, root, run }) => { const config = await this.inspector.resolveConfig(root); const env = flags.env ?? config.defaultEnv; const envConfig = config.environments[env]; const adapter = this.orchestrator.resolveAdapter(envConfig.adapter); const apps = await this.resolveApps( root, config, this.isServerless(envConfig.adapter), ); const tenant = resolveTenant(config.tenancy, flags.tenant); const namingCtx = this.naming.forContext(config.project, env, tenant); const ctx = { project: config.project, env, envConfig, entry: apps[0].entry, resources: apps[0].resources, root, naming: namingCtx, tenant, }; await adapter.authenticate(ctx, run); await adapter.deploy(ctx, run); }, }); protected readonly migrate = $command({ name: "migrate", description: "Run database migrations", flags: this.envFlags, handler: async ({ flags, root, run }) => { const config = await this.inspector.resolveConfig(root); const env = flags.env ?? config.defaultEnv; const envConfig = config.environments[env]; const adapter = this.orchestrator.resolveAdapter(envConfig.adapter); const apps = await this.resolveApps( root, config, this.isServerless(envConfig.adapter), ); const tenant = resolveTenant(config.tenancy, flags.tenant); const namingCtx = this.naming.forContext(config.project, env, tenant); const ctx = { project: config.project, env, envConfig, entry: apps[0].entry, resources: apps[0].resources, root, naming: namingCtx, tenant, }; await adapter.authenticate(ctx, run); await adapter.migrate(ctx, run); if (flags.json) { process.stdout.write( `${JSON.stringify( { status: "succeeded", project: config.project, env }, null, 2, )}\n`, ); } }, }); protected readonly dbExport = $command({ name: "export", description: "Export the deployed database to a local snapshot (remote → local dev DB).", flags: z.object({ ...this.envFlags.properties, output: z .text({ description: "Destination SQLite file. Defaults to the dev DB path (node_modules/.alepha/sqlite.db).", }) .optional(), keepSql: z .boolean() .describe("Keep the intermediate .sql dump file.") .optional(), }), handler: async ({ flags, root, run }) => { const config = await this.inspector.resolveConfig(root); const env = flags.env ?? config.defaultEnv; const envConfig = config.environments[env]; const adapter = this.orchestrator.resolveAdapter(envConfig.adapter); const app = await this.resolveApp( root, config, this.isServerless(envConfig.adapter), ); const tenant = resolveTenant(config.tenancy, flags.tenant); const namingCtx = this.naming.forContext(config.project, env, tenant); const ctx = { project: config.project, env, envConfig, entry: app.entry, resources: app.resources, root, naming: namingCtx, tenant, }; await adapter.authenticate(ctx, run); await adapter.exportDb(ctx, run, { output: flags.output, keepSql: flags.keepSql, }); }, }); /** * `db` subgroup — operations against the *deployed* database (export, * migrate). They live under `platform` (not core `alepha db`) because * they need the env config, tenancy, adapter, and resource naming. */ protected readonly db = $command({ name: "db", description: "Deployed-database operations (export, migrate).", children: [this.dbExport, this.migrate], handler: async ({ help, root }) => { await this.inspector.resolveConfig(root); help(); }, }); // ----------------------------------------------------------------------- // Parent command // ----------------------------------------------------------------------- public readonly platform = $command({ name: "platform", aliases: ["p"], description: "Cloud deployment orchestrator", children: [ this.plan, this.up, this.down, this.status, this.build, this.deploy, this.db, this.secretsCommand.secrets, ], handler: async ({ help, root }) => { await this.inspector.resolveConfig(root); help(); }, }); // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- /** * Resolve app definitions. * * For standalone: returns a single app from the root. * For monorepo: resolves each app path, introspects for resources. * * NOTE: Resource detection (hasDatabase, hasBucket, etc.) requires * ViteBuildProvider.init() per app. This is expensive -- only done * for up/down/status, not for plan. */ protected async resolveApp( root: string, _config: ResolvedPlatformConfig, isServerless: boolean, options: { prebuilt?: boolean } = {}, ): Promise<{ entry: AppEntry; resources: DetectedResources }> { // Prebuilt + manifest fast-path: read `dist/manifest.json` produced // by the original `alepha build` instead of re-booting the workspace // via Vite. Lets external orchestrators (Alepha Rocket) avoid the // workspace's runtime `npm install` — the app source is never // imported here, so missing deps (react-dom, etc.) don't matter. if (options.prebuilt) { const manifest = await this.readManifest(root); if (manifest) { return { entry: { root, server: "" }, resources: manifest.resources, }; } // No manifest — fall through to introspection. Useful for older // artifacts that pre-date the manifest emission. } const entry = await this.boot.getAppEntry(root); if (isServerless) { process.env.ALEPHA_SERVERLESS = "true"; } const appAlepha = await this.viteBuild.init({ entry }); delete process.env.ALEPHA_SERVERLESS; const resources = this.detectResources(appAlepha); return { entry, resources }; } /** * @deprecated single-app projects; use `resolveApp` directly. * Kept temporarily so existing commands can be migrated one at a time. */ protected async resolveApps( root: string, config: ResolvedPlatformConfig, isServerless: boolean, options: { prebuilt?: boolean } = {}, ): Promise< Array<{ name: string; path: string; entry: AppEntry; resources: DetectedResources; }> > { const { entry, resources } = await this.resolveApp( root, config, isServerless, options, ); return [{ name: config.project, path: "", entry, resources }]; } protected isServerless(adapter: string): boolean { return adapter === "vercel" || adapter === "cloudflare"; } /** * Read `dist/manifest.json` if present. Returns `null` when the file * doesn't exist or isn't parseable — caller falls back to the * Vite-introspection path. */ protected async readManifest(root: string): Promise<{ version: number; project: string; resources: DetectedResources; } | null> { try { const fs = await import("node:fs/promises"); const path = await import("node:path"); const raw = await fs.readFile( path.join(root, "dist", "manifest.json"), "utf-8", ); return JSON.parse(raw); } catch { return null; } } protected detectResources(alepha: any): DetectedResources { let hasDatabase = false; let hasBucket = false; let hasKV = false; let hasQueue = false; let hasCron = false; try { const repo = alepha.inject("RepositoryProvider"); hasDatabase = repo.getRepositories().length > 0; } catch {} try { const buckets = alepha.primitives("$bucket"); hasBucket = buckets.length > 0; } catch {} try { // Provision KV only when the user actually wants it: a `$cache` declared // *without* an explicit `provider` falls back to the runtime default // (CloudflareKVProvider on workerd). Any explicit choice — `"memory"`, // `DatabaseCacheProvider`, a Redis provider, or a custom one — opts out // of the platform default and therefore should not trigger KV // provisioning. See platform.ts hasKV docs and `$cache.options.provider`. hasKV = alepha .primitives("cache") .filter((it: any) => it.options?.provider == null).length > 0; } catch {} try { hasQueue = alepha.primitives("queue").length > 0; } catch {} try { const cron = alepha.inject("CronProvider"); hasCron = cron.getCronJobs().length > 0; } catch {} return { hasDatabase, hasBucket, hasKV, hasQueue, hasCron }; } }