UNPKG

alepha

Version:

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

1,499 lines 87.4 kB
import { $atom, $inject, $module, $state, Alepha, AlephaError, z } from "alepha"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { AlephaCliUtils, BuildCloudflareTask, PackageManagerUtils } from "alepha/cli"; import { Asker, EnvUtils, Runner } from "alepha/command"; import { $logger, ConsoleColorProvider } from "alepha/logger"; import { FileSystemProvider, ShellProvider } from "alepha/system"; import { S3mini } from "s3mini"; import { DateTimeProvider } from "alepha/datetime"; import { homedir, platform } from "node:os"; //#region ../../src/cli/platform-lib/atoms/platformOptions.ts /** * Platform deployment configuration atom. * * Filled from the `platform` section of `alepha.config.ts`. * Read by `PlatformCommand` to resolve environments and adapters. */ const platformOptions = $atom({ name: "alepha.cli.platform.options", description: "Platform deployment configuration", schema: z.object({ /** * Project name override. Defaults to root package.json "name". */ name: z.text().optional(), /** * Default environment when --env is omitted. * * @default "production" */ default: z.text().optional(), /** * Multi-tenancy mode — controls whether `--tenant <slug>` is * accepted/required and how it shapes resource names + the domain. * * - `none` (default): single instance. `--tenant` is rejected. * - `required`: every deploy needs `--tenant`; resources are named * `<tenant>-<project>-<env>` and the host becomes * `<tenant>.<domain>`. Omitting it errors. * - `optional`: a base instance (no `--tenant`) plus per-tenant * instances coexist. * * @default "none" */ tenancy: z.enum([ "none", "optional", "required" ]).optional(), /** * Secret store configuration for syncing .env secrets * to external providers (e.g. GitHub Actions environments). */ secrets: z.object({ /** * Explicit override of the worker secret-key allowlist. * * By default the deploy `secrets` step uses the build manifest's * `env` list (every key the app declares via `$env`, captured at * build time) as the allowlist, resolving each value from * `.env.<env>[.local]` first, then `process.env`. This lets CI * deliver secrets via the job environment (no `.env` file on the * runner) while only ever pushing declared keys — ambient runner * vars (PATH, GITHUB_*, …) can never leak. * * Set `keys` to override that auto-detected list (e.g. to narrow it, * or to add a key read via `process.env` rather than `$env`). When * neither this nor a manifest is present, the `.env.<env>` file is * itself the allowlist (legacy fallback). */ keys: z.array(z.text()).optional(), /** * Secret store backend. */ store: z.enum(["github"]).optional(), /** * Pattern for resolving environment names in the store. * Placeholders: {project}, {env}. * * @default "{project}-{env}" */ environmentPattern: z.text().optional() }).optional(), /** * Named environments with their adapter and configuration. */ environments: z.record(z.text({ description: "Environment name (e.g. 'production', 'staging', 'preview'). Used in resource naming and selected via --env." }), z.object({ adapter: z.enum(["cloudflare", "vercel"]), /** * Custom domain for the deployed worker (e.g. "api.example.com"). * * On Cloudflare this is attached as a custom-domain route. * Omit to use the adapter's default `*.workers.dev` / preview URL. * * Wildcards are supported for multi-tenant SaaS apps: * `"*.club.alepha.dev"` routes every subdomain to the worker. * Wildcard patterns require `zone` to be set, and the wildcard DNS * record must already exist (proxied) in the Cloudflare zone. */ domain: z.text().optional(), /** * Cloudflare zone name (e.g. "alepha.dev") that owns `domain`. * * Required when `domain` contains a wildcard (`*`). For a plain * host, setting `zone` switches the binding from a Custom Domain to * a zone *Route* (`domain/*`) — needed when another Worker holds a * wildcard route covering the host (routes win by specificity, * while a Custom Domain would lose to the wildcard route). */ zone: z.text().optional(), /** * Worker-to-worker service bindings, e.g. * `[{ binding: "CLUB", service: "club-staging" }]`. * * Exposed on the runtime `env` (Alepha store key `cloudflare.env`). * Use a binding to fetch() a sibling Worker on the same zone — * plain subrequests to a host served by a same-zone Worker route * bypass the route and 522. */ services: z.array(z.object({ binding: z.text(), service: z.text() })).optional(), /** * Cloudflare data jurisdiction for R2 buckets and D1 databases. * - "eu": data stays within the EU * - "fedramp": FedRAMP-authorized regions * * Omit for the default (global) jurisdiction. */ jurisdiction: z.enum(["eu", "fedramp"]).optional(), /** * Cloudflare account ID to deploy into. * * Falls back to `CLOUDFLARE_ACCOUNT_ID` env var, then to the * token's account when the token is scoped to exactly one. * Required when the token has access to multiple accounts. */ accountId: z.text().optional() })) }).optional() }); //#endregion //#region ../../src/cli/platform-lib/providers/PlatformCacheProvider.ts /** * Caches cloud provider login state to avoid slow auth checks. * * Stored in node_modules/.alepha/platform.json (gitignored, project-scoped). * TTL: 4 hours. */ var PlatformCacheProvider = class PlatformCacheProvider { static TTL_MS = 14400 * 1e3; fs = $inject(FileSystemProvider); dateTime = $inject(DateTimeProvider); cachePath(root) { return this.fs.join(root, "node_modules", ".alepha", "platform.json"); } async isLoginFresh(root, adapter) { const entry = (await this.readCache(root))[adapter]; if (!entry) return false; return this.dateTime.nowMillis() - entry.lastLoginCheck < PlatformCacheProvider.TTL_MS; } async getAccountId(root, adapter) { return (await this.readCache(root))[adapter]?.accountId; } async recordLogin(root, adapter, accountId) { const cache = await this.readCache(root); cache[adapter] = { lastLoginCheck: this.dateTime.nowMillis(), accountId }; await this.writeCache(root, cache); } async readCache(root) { const path = this.cachePath(root); try { return await this.fs.readJsonFile(path); } catch { return {}; } } async writeCache(root, cache) { const path = this.cachePath(root); const lastSlash = path.lastIndexOf("/"); const dir = lastSlash > 0 ? path.slice(0, lastSlash) : path; await this.fs.mkdir(dir, { recursive: true }).catch(() => null); await this.fs.writeFile(path, JSON.stringify(cache, null, 2)); } }; //#endregion //#region ../../src/cli/platform-lib/schemas/cloudflare.ts const cloudflareAccountSchema = z.object({ id: z.string(), name: z.string() }); const cloudflareD1Schema = z.object({ uuid: z.string(), name: z.string() }); const cloudflareKVSchema = z.object({ id: z.string(), title: z.string() }); const cloudflareR2Schema = z.object({ name: z.string(), creation_date: z.string().optional() }); const cloudflareR2ListSchema = z.object({ buckets: z.array(cloudflareR2Schema) }); const cloudflareQueueSchema = z.object({ queue_id: z.string(), queue_name: z.string() }); const cloudflareQueueConsumerSchema = z.object({ consumer_id: z.string(), service: z.string(), environment: z.string().optional() }); const cloudflareHyperdriveOriginSchema = z.object({ host: z.string() }); const cloudflareHyperdriveSchema = z.object({ id: z.string(), name: z.string(), origin: cloudflareHyperdriveOriginSchema }); const cloudflareWorkerSchema = z.object({ id: z.string(), created_on: z.string(), modified_on: z.string() }); const cloudflareDeploymentVersionSchema = z.object({ version_id: z.string(), percentage: z.number() }); const cloudflareDeploymentSchema = z.object({ id: z.string(), versions: z.array(cloudflareDeploymentVersionSchema), created_on: z.string() }); const cloudflareDeploymentListSchema = z.object({ deployments: z.array(cloudflareDeploymentSchema) }); const cloudflareVersionSchema = z.object({ id: z.string(), metadata: z.object({ created_on: z.string() }), annotations: z.record(z.string(), z.string()).optional() }); const cloudflareVersionListSchema = z.object({ items: z.array(cloudflareVersionSchema) }); const cloudflareSecretSchema = z.object({ name: z.string(), type: z.string() }); const createD1BodySchema = z.object({ name: z.string(), primary_location_hint: z.string().optional(), jurisdiction: z.string().optional() }); const createKVBodySchema = z.object({ title: z.string() }); const createR2BodySchema = z.object({ name: z.string() }); const cloudflareR2TokenSchema = z.object({ id: z.string(), accessKeyId: z.string(), secretAccessKey: z.string() }); const createR2TokenBodySchema = z.object({ name: z.string(), policies: z.array(z.object({ effect: z.string(), permissions: z.array(z.string()), buckets: z.array(z.string()).optional() })) }); const createQueueBodySchema = z.object({ queue_name: z.string() }); const createHyperdriveOriginSchema = z.object({ scheme: z.string(), host: z.string(), port: z.number(), database: z.string(), user: z.string(), password: z.string() }); const createHyperdriveBodySchema = z.object({ name: z.string(), origin: createHyperdriveOriginSchema }); const putSecretBodySchema = z.object({ name: z.string(), text: z.string(), type: z.string() }); const cloudflareApiErrorSchema = z.object({ code: z.number(), message: z.string() }); //#endregion //#region ../../src/cli/platform-lib/services/WranglerApi.ts /** * Wraps wrangler CLI commands that are kept as shell-outs. * * Only used for operations where wrangler provides value * beyond a raw API call: OAuth login, worker deploy (bundling/upload), * D1 migrations, and secret bulk push. */ var WranglerApi = class { log = $logger(); shell = $inject(ShellProvider); utils = $inject(AlephaCliUtils); pm = $inject(PackageManagerUtils); async runShell(command, options = {}) { const output = await this.shell.run(command, options); if (options.capture) this.log.info(output); return output; } /** * Ensure wrangler is installed in the project. */ async ensureInstalled(root) { await this.pm.ensureDependency(root, "wrangler", { dev: true, exec: async (cmd, opts) => { await this.utils.exec(cmd, opts); } }); } /** * Check if the user is authenticated. Returns the whoami output. */ async whoami() { return await this.runShell("wrangler whoami", { resolve: true, capture: true }); } /** * Open the browser-based OAuth login flow. */ async login() { await this.runShell("wrangler login", { resolve: true }); } /** * Get the current auth token from wrangler (auto-refreshes if expired). */ async getAuthToken() { const output = await this.shell.run("wrangler auth token --json", { resolve: true, capture: true }); return JSON.parse(output).token; } /** * Deploy a worker via wrangler (handles bundling and upload). * * Returns the workers.dev URL if found in the output. */ async deploy(workerName, configPath, root) { return (await this.runShell(`wrangler deploy --name=${workerName} --no-bundle --config=${configPath}`, { resolve: true, capture: true, root })).match(/https:\/\/[^\s]*\.workers\.dev/)?.[0]; } /** * Apply D1 migrations remotely. */ async d1MigrationsApply(dbName, configPath, root) { await this.runShell(`wrangler d1 migrations apply ${dbName} --remote --config=${configPath}`, { resolve: true, env: { CI: "1" }, root }); } }; //#endregion //#region ../../src/cli/platform-lib/services/CloudflareApi.ts /** * Thin wrapper over the Cloudflare REST API. * * Uses `wrangler auth token` to obtain credentials, * then calls `fetch()` directly for all CRUD operations. */ var CloudflareApi = class CloudflareApi { static BASE = "https://api.cloudflare.com/client/v4"; log = $logger(); alepha = $inject(Alepha); wrangler = $inject(WranglerApi); token; accountId; jurisdiction; /** * Set the Cloudflare data jurisdiction for R2 and D1 resources. * * R2 buckets and D1 databases created under a jurisdiction live in a * separate namespace — every R2 API call (list/create/delete) must include * the `cf-r2-jurisdiction` header, and D1 create must include the field * in the request body. Omit / pass `undefined` for the default (global). */ setJurisdiction(jurisdiction) { this.jurisdiction = jurisdiction; } /** * Override the Cloudflare account ID (from platform config). * * When unset, `resolveAccountId` falls back to `CLOUDFLARE_ACCOUNT_ID` env * var or the token's single account. */ setAccountId(accountId) { this.accountId = accountId; } /** * Obtain the current auth token from wrangler. */ async resolveToken() { if (this.token) return this.token; this.token = await this.wrangler.getAuthToken(); return this.token; } /** * Resolve the Cloudflare account ID. * * Calls /accounts and picks the first one. Cached after first call. */ async resolveAccountId() { if (this.accountId) return this.accountId; const fromEnv = process.env.CLOUDFLARE_ACCOUNT_ID; if (fromEnv) { this.accountId = fromEnv; return this.accountId; } const res = await this.fetch("/accounts", { schema: z.array(cloudflareAccountSchema) }); if (res.length === 0) throw new AlephaError("No Cloudflare accounts found for this token."); if (res.length > 1) { const list = res.map((a) => ` - ${a.id} ${a.name}`).join("\n"); throw new AlephaError(`Cloudflare token has access to ${res.length} accounts; set \`CLOUDFLARE_ACCOUNT_ID\` or the \`accountId\` field in your platform config to pick one:\n${list}`); } this.accountId = res[0].id; return this.accountId; } async listD1() { const accountId = await this.resolveAccountId(); return await this.paginate(`/accounts/${accountId}/d1/database`, cloudflareD1Schema); } async createD1(name, location = "weur") { const accountId = await this.resolveAccountId(); const body = { name }; if (this.jurisdiction) body.jurisdiction = this.jurisdiction; else body.primary_location_hint = location; return await this.fetch(`/accounts/${accountId}/d1/database`, { method: "POST", body, bodySchema: createD1BodySchema, schema: cloudflareD1Schema }); } async deleteD1(databaseId) { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/d1/database/${databaseId}`, { method: "DELETE" }); } async listKV() { const accountId = await this.resolveAccountId(); return await this.paginate(`/accounts/${accountId}/storage/kv/namespaces`, cloudflareKVSchema, 100); } async createKV(title) { const accountId = await this.resolveAccountId(); return await this.fetch(`/accounts/${accountId}/storage/kv/namespaces`, { method: "POST", body: { title }, bodySchema: createKVBodySchema, schema: cloudflareKVSchema }); } async deleteKV(namespaceId) { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`, { method: "DELETE" }); } async listR2() { const accountId = await this.resolveAccountId(); return await this.paginateCursor(`/accounts/${accountId}/r2/buckets`, "buckets", cloudflareR2Schema); } async createR2(name) { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/r2/buckets`, { method: "POST", body: { name }, bodySchema: createR2BodySchema }); } async deleteR2(name) { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/r2/buckets/${name}`, { method: "DELETE" }); } /** * Mint a bucket-scoped R2 API token (S3 access key + secret) using the * current bearer token. Used by teardown to wipe a bucket over the S3 * protocol without requiring users to pre-create R2 access keys. * * The returned token should be revoked with `deleteR2Token` as soon as the * wipe is done. */ async createR2Token(name, bucket) { const accountId = await this.resolveAccountId(); return await this.fetch(`/accounts/${accountId}/r2/api-tokens`, { method: "POST", body: { name, policies: [{ effect: "allow", permissions: ["admin-read-write"], buckets: [bucket] }] }, bodySchema: createR2TokenBodySchema, schema: cloudflareR2TokenSchema }); } async deleteR2Token(tokenId) { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/r2/api-tokens/${tokenId}`, { method: "DELETE" }); } async listQueues() { const accountId = await this.resolveAccountId(); return await this.paginate(`/accounts/${accountId}/queues`, cloudflareQueueSchema); } async createQueue(name) { const accountId = await this.resolveAccountId(); return await this.fetch(`/accounts/${accountId}/queues`, { method: "POST", body: { queue_name: name }, bodySchema: createQueueBodySchema, schema: cloudflareQueueSchema }); } async deleteQueue(queueId) { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/queues/${queueId}`, { method: "DELETE" }); } async listQueueConsumers(queueId) { const accountId = await this.resolveAccountId(); return await this.paginate(`/accounts/${accountId}/queues/${queueId}/consumers`, cloudflareQueueConsumerSchema); } async deleteQueueConsumer(queueId, consumerService) { const accountId = await this.resolveAccountId(); const consumer = (await this.listQueueConsumers(queueId)).find((c) => c.service === consumerService); if (!consumer) return; await this.fetch(`/accounts/${accountId}/queues/${queueId}/consumers/${consumer.consumer_id}`, { method: "DELETE" }); } async listHyperdrive() { const accountId = await this.resolveAccountId(); return await this.paginate(`/accounts/${accountId}/hyperdrive/configs`, cloudflareHyperdriveSchema); } async createHyperdrive(name, connectionString) { const accountId = await this.resolveAccountId(); return await this.fetch(`/accounts/${accountId}/hyperdrive/configs`, { method: "POST", body: { name, origin: this.parseConnectionString(connectionString) }, bodySchema: createHyperdriveBodySchema, schema: cloudflareHyperdriveSchema }); } async deleteHyperdrive(configId) { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/hyperdrive/configs/${configId}`, { method: "DELETE" }); } async getWorker(scriptName) { const accountId = await this.resolveAccountId(); try { return await this.fetch(`/accounts/${accountId}/workers/scripts/${scriptName}`, { schema: cloudflareWorkerSchema }); } catch { return; } } async deleteWorker(scriptName) { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/workers/scripts/${scriptName}`, { method: "DELETE", query: { force: "true" } }); } async listDeployments(scriptName) { const accountId = await this.resolveAccountId(); return (await this.fetch(`/accounts/${accountId}/workers/scripts/${scriptName}/deployments`, { schema: cloudflareDeploymentListSchema, query: { per_page: "100" } })).deployments; } async listVersions(scriptName) { const accountId = await this.resolveAccountId(); return await this.paginateCursor(`/accounts/${accountId}/workers/scripts/${scriptName}/versions`, "items", cloudflareVersionSchema); } async listSecrets(scriptName) { const accountId = await this.resolveAccountId(); return await this.fetch(`/accounts/${accountId}/workers/scripts/${scriptName}/secrets`, { schema: z.array(cloudflareSecretSchema) }); } async putSecret(scriptName, name, value) { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/workers/scripts/${scriptName}/secrets`, { method: "PUT", body: { name, text: value, type: "secret_text" }, bodySchema: putSecretBodySchema }); } /** * Fetch the current worker bindings via the script-settings endpoint. * Used to merge new secrets into the existing binding set in one PATCH * (avoids the per-secret `putSecret` calls, each of which creates a * Cloudflare deployment — pushing 7 secrets meant 7 deployment rows). * * Secret bindings come back with `name` + `type` but no `text` (they're * write-only on Cloudflare's side); to preserve them across a PATCH we * forward each one as `{ type, name }` and Cloudflare keeps the stored * value. */ async getWorkerSettings(scriptName) { const accountId = await this.resolveAccountId(); return await this.fetch(`/accounts/${accountId}/workers/scripts/${scriptName}/settings`); } /** * Replace the worker's binding set in one call (= one Cloudflare * deployment, regardless of how many secrets are being updated). * * The endpoint expects multipart FormData with a `settings` field whose * value is a JSON-encoded `{ bindings: [...] }` — the `fetch` helper * above is JSON-only, so this one bypasses it and calls `globalThis.fetch` * directly. Mirrors what `wrangler secret bulk` does internally. */ async patchWorkerBindings(scriptName, bindings) { const accountId = await this.resolveAccountId(); const token = await this.resolveToken(); const url = `${CloudflareApi.BASE}/accounts/${accountId}/workers/scripts/${scriptName}/settings`; const form = new FormData(); form.set("settings", JSON.stringify({ bindings })); const response = await globalThis.fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${token}` }, body: form }); const json = await response.json().catch(() => null); if (!response.ok || !json?.success) { const messages = json?.errors?.map((e) => e.message).join(", "); throw new AlephaError(`Cloudflare API error (PATCH /workers/scripts/${scriptName}/settings): ${messages ?? response.statusText}`); } } async fetch(path, options = {}) { const token = await this.resolveToken(); const { method = "GET", body, query } = options; let url = `${CloudflareApi.BASE}${path}`; if (query) { const params = new URLSearchParams(query); url += `?${params.toString()}`; } const headers = { Authorization: `Bearer ${token}` }; if (this.jurisdiction && /\/r2\//.test(path)) headers["cf-r2-jurisdiction"] = this.jurisdiction; const init = { method, headers }; if (body) { headers["Content-Type"] = "application/json"; const validated = options.bodySchema ? this.alepha.codec.validate(options.bodySchema, body) : body; init.body = JSON.stringify(validated); } const json = await (await globalThis.fetch(url, init)).json(); if (!json.success) throw new AlephaError(`Cloudflare API error (${method} ${path}): ${json.errors.map((e) => e.message).join(", ")}`); if (options.schema) return this.alepha.codec.validate(options.schema, json.result); return json.result; } /** * Paginate a page-based list endpoint (`result_info.total_pages`). * * Cloudflare defaults to `per_page=20`; we push it to 1000 (max on most * list endpoints) and loop if more pages exist. Each page is validated * against the item schema. */ async paginate(path, itemSchema, perPage = 1e3) { const results = []; let page = 1; while (true) { const token = await this.resolveToken(); const url = `${CloudflareApi.BASE}${path}?per_page=${perPage}&page=${page}`; const headers = { Authorization: `Bearer ${token}` }; if (this.jurisdiction && /\/r2\//.test(path)) headers["cf-r2-jurisdiction"] = this.jurisdiction; const json = await (await globalThis.fetch(url, { method: "GET", headers })).json(); if (!json.success) throw new AlephaError(`Cloudflare API error (GET ${path}): ${json.errors.map((e) => e.message).join(", ")}`); const validated = this.alepha.codec.validate(z.array(itemSchema), json.result); results.push(...validated); const totalPages = json.result_info?.total_pages; if (!totalPages || page >= totalPages || validated.length === 0) break; page++; } return results; } /** * Paginate a cursor-based list endpoint where `result` is an object * containing both the items array and a `cursor` field (R2 buckets, * Workers versions). Returns the flattened item array. */ async paginateCursor(path, itemsKey, itemSchema, perPage = 1e3) { const results = []; let cursor; while (true) { const query = { per_page: String(perPage) }; if (cursor) query.cursor = cursor; const res = await this.fetch(path, { query }); const items = res[itemsKey] ?? []; const validated = this.alepha.codec.validate(z.array(itemSchema), items); results.push(...validated); const nextCursor = res.cursor; if (!nextCursor || validated.length === 0) break; cursor = nextCursor; } return results; } /** * Parse a postgres:// connection string into Hyperdrive origin fields. */ parseConnectionString(connectionString) { const url = new URL(connectionString); return { scheme: "postgres", host: url.hostname, port: Number(url.port) || 5432, database: url.pathname.slice(1), user: decodeURIComponent(url.username), password: decodeURIComponent(url.password) }; } }; //#endregion //#region ../../src/cli/platform-lib/services/NamingService.ts /** * Validate a `--tenant` value against the app's declared `tenancy` and * return the effective tenant (or `undefined` for a base/non-tenanted * deploy). Throws on any matrix violation so a misconfigured deploy fails * fast instead of landing on the wrong resource names / host. */ function resolveTenant(tenancy, tenant) { const mode = tenancy ?? "none"; if (tenant !== void 0 && !/^[a-z0-9][a-z0-9-]*$/.test(tenant)) throw new AlephaError(`Invalid --tenant "${tenant}": must be a slug (lowercase alphanumeric + dashes, e.g. "b14").`); if (mode === "none") { if (tenant !== void 0) throw new AlephaError(`This app is not tenanted (tenancy: "none") but --tenant "${tenant}" was given. Set tenancy: "optional" | "required" in platform() to deploy per-tenant.`); return; } if (mode === "required" && tenant === void 0) throw new AlephaError(`This app requires a tenant (tenancy: "required"). Pass --tenant <slug>.`); return tenant; } /** * Resolve the host a deploy is served on. * * - `override` (V2 custom domains, e.g. `reservation.club-b14.fr`) wins * outright when supplied — not wired to a flag today, but the single * seam a future `--domain` / Rocket `config.hostname` plugs into. * - else a tenant becomes the leftmost DNS label: `b14` + `alepha.club` * → `b14.alepha.club`. * - else the base domain is used as-is. */ function tenantDomain(base, tenant, override) { if (override) return override; if (!base) return base; return tenant ? `${tenant}.${base}` : base; } /** * Generates deterministic resource names for cloud deployments. * * Pattern: `<tenant>-<project>-<env>` (tenant segment omitted when the * deploy isn't tenanted). * * All segments are slugified (lowercase, alphanumeric + dashes, max 63 * chars). One app per workspace — see `alepha platform`. */ var NamingService = class { forContext(project, env, tenant) { return new NamingContext([ tenant, project, env ].filter((segment) => !!segment).map((segment) => this.slugify(segment)).join("-")); } slugify(name) { return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63); } }; var NamingContext = class { prefix; constructor(prefix) { this.prefix = prefix; } worker() { return this.prefix; } d1() { return this.prefix; } hyperdrive() { return this.prefix; } r2() { return this.prefix; } kv() { return this.prefix; } queue() { return this.prefix; } }; //#endregion //#region ../../src/cli/platform-lib/adapters/PlatformAdapter.ts /** * Abstract platform adapter. * * Each cloud provider (Cloudflare, AKS, docker-compose) implements this. * The PlatformOrchestrator calls these methods in the correct order. */ var PlatformAdapter = class { /** * Create/ensure cloud resources exist (DB, buckets, queues). * Not all adapters provision -- AKS defers to Helm. */ async provision(_ctx, _run) {} /** * Run database migrations. */ async migrate(_ctx, _run) {} /** * Export the deployed database to a local file — the remote → local dev * snapshot workflow. Adapter/dialect specific; the default refuses. */ async exportDb(_ctx, _run, _options = {}) { throw new AlephaError(`Database export is not supported by the '${this.constructor.name}' adapter.`); } /** * Push runtime secrets to the deployed worker(s). * * Reads secrets from `.env.{env}` files (parsed, not from process.env), * filters out vars already handled by bindings (DATABASE_URL, R2, etc.), * and pushes the rest via the platform's secret management. */ async secrets(_ctx, _run) {} }; //#endregion //#region ../../src/cli/platform-lib/adapters/CloudflareAdapter.ts /** * Cloudflare Workers adapter. * * Uses the Cloudflare REST API (via CloudflareApi) for resource provisioning * and teardown, and wrangler CLI (via WranglerApi) for login, deploy, * D1 migrations, and secret bulk push. */ var CloudflareAdapter = class CloudflareAdapter extends PlatformAdapter { log = $logger(); fs = $inject(FileSystemProvider); shell = $inject(ShellProvider); cache = $inject(PlatformCacheProvider); alepha = $inject(Alepha); envUtils = $inject(EnvUtils); api = $inject(CloudflareApi); wrangler = $inject(WranglerApi); runner = $inject(Runner); buildTask = $inject(BuildCloudflareTask); options = $state(platformOptions); provisionedD1Id; provisionedHyperdriveId; provisionedKVIds = /* @__PURE__ */ new Map(); /** * Check if the user's DATABASE_URL points to an external Postgres database. * If so, we use Hyperdrive instead of D1. * * Reads from `.env.{env}` first, falls back to `process.env`. */ async isPostgres(ctx) { return !!((await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}`])).DATABASE_URL ?? process.env.DATABASE_URL)?.startsWith("postgres:"); } /** * Propagate the environment's data-jurisdiction setting to the API client. * * Must be invoked at the top of every entry point (authenticate, build, * deploy, secrets, provision, migrate, inspect, teardown) because * CloudflareApi is a singleton reused across env invocations. */ configureApi(ctx) { this.api.setJurisdiction(ctx.envConfig.jurisdiction); this.api.setAccountId(ctx.envConfig.accountId); } async runShell(command, options = {}) { const output = await this.shell.run(command, options); if (options.capture) this.log.info(output); return output; } async authenticate(ctx, run) { this.configureApi(ctx); await run({ name: "authenticate", handler: async () => { await this.wrangler.ensureInstalled(ctx.root); let needsLogin = false; try { await this.wrangler.getAuthToken(); } catch { needsLogin = true; } if (needsLogin) await this.wrangler.login(); if (await this.cache.isLoginFresh(ctx.root, "cloudflare")) return; try { const accountId = await this.api.resolveAccountId(); await this.cache.recordLogin(ctx.root, "cloudflare", accountId); } catch { await this.cache.recordLogin(ctx.root, "cloudflare"); } } }); } async build(ctx, run) { this.configureApi(ctx); const appDir = ctx.root; const env = {}; if (ctx.resources.hasDatabase) { if (this.provisionedHyperdriveId) { env.HYPERDRIVE_ID = this.provisionedHyperdriveId; const pgSchema = (await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}`])).POSTGRES_SCHEMA ?? process.env.POSTGRES_SCHEMA; if (pgSchema) env.POSTGRES_SCHEMA = pgSchema; } else if (this.provisionedD1Id) env.DATABASE_URL = `d1://${ctx.naming.d1()}:${this.provisionedD1Id}`; } if (ctx.resources.hasBucket) env.R2_BUCKET_NAME = ctx.naming.r2(); if (ctx.resources.hasKV) { const kvName = ctx.naming.kv(); env.CLOUDFLARE_KV_NAME = kvName; const kvId = this.provisionedKVIds.get(kvName); if (kvId) env.CLOUDFLARE_KV_ID = kvId; } if (ctx.resources.hasQueue) env.CLOUDFLARE_QUEUE_NAME = ctx.naming.queue(); const host = tenantDomain(ctx.envConfig.domain, ctx.tenant); if (host) { env.CLOUDFLARE_DOMAIN = host; if (ctx.envConfig.zone) env.CLOUDFLARE_ZONE = ctx.envConfig.zone; } if (ctx.envConfig.jurisdiction) env.CLOUDFLARE_JURISDICTION = ctx.envConfig.jurisdiction; if (ctx.envConfig.services?.length) env.CLOUDFLARE_SERVICES = JSON.stringify(ctx.envConfig.services); if (ctx.prebuilt) { await run({ name: "alepha build -t cloudflare --prebuilt (in-process)", handler: async () => { await this.runBuildInProcess(appDir, env); } }); return; } const cmd = "alepha build -t cloudflare"; await run({ name: cmd, handler: async () => { await this.runShell(cmd, { root: appDir, env }); } }); } /** * Library-embed of `alepha build -t cloudflare --prebuilt`. Loads the * pre-built `dist/manifest.json`, sets the per-tenant env vars on * `process.env` for the duration of the call (the task's enhance* * methods read them directly), then runs `BuildCloudflareTask` * against a synthetic context. * * `ctx.alepha` is intentionally null — in manifest mode the task * reads resources/crons/containers from `ctx.manifest` and never * dereferences `ctx.alepha`. Same for `entry` and `hasClient`: * prebuilt mode skips the bundle tasks; only the wrangler.jsonc / * worker-entrypoint emission runs. */ async runBuildInProcess(root, env) { const manifestPath = join(root, "dist", "manifest.json"); let manifest; try { manifest = JSON.parse(await readFile(manifestPath, "utf-8")); } catch (err) { throw new AlephaError(`Cannot read ${manifestPath}: ${err.message}. Prebuilt deploys require dist/manifest.json (emitted by \`alepha build -t cloudflare\`).`); } const ctx = { alepha: null, options: { target: "cloudflare", output: { dist: "dist", public: "public" } }, run: this.runner.run, root, entry: { root, server: "" }, hasClient: false, manifest, platformOptions: null, flags: { prebuilt: true } }; const previous = {}; for (const [k, v] of Object.entries(env)) { previous[k] = process.env[k]; process.env[k] = v; } try { await this.buildTask.run(ctx); } finally { for (const [k, prev] of Object.entries(previous)) if (prev === void 0) delete process.env[k]; else process.env[k] = prev; } } async deploy(ctx, run) { this.configureApi(ctx); const workerName = ctx.naming.worker(); const distDir = this.fs.join(ctx.root, "dist"); let url; await run({ name: `deploy worker ${ctx.project}`, handler: async () => { url = await this.wrangler.deploy(workerName, `${distDir}/wrangler.jsonc`, ctx.root); } }); return url; } /** * Vars that are handled by wrangler bindings or build config. * These should not be pushed as secrets. */ static EXCLUDED_SECRET_KEYS = /* @__PURE__ */ new Set([ "DATABASE_URL", "R2_BUCKET_NAME", "CLOUDFLARE_DOMAIN", "CLOUDFLARE_ZONE", "CLOUDFLARE_JURISDICTION", "HYPERDRIVE_ID", "POSTGRES_SCHEMA", "NODE_ENV", "LOG_LEVEL", "LOG_FORMAT", "SERVER_HOST", "SERVER_PORT", "TRUST_PROXY", "REACT_SSR_ENABLED", "DATABASE_SYNC", "DEBUG" ]); /** * Read the build manifest's `env` list (every key the app declares via * `$env`) from `dist/manifest.json`. Used as the default worker-secret * allowlist. Returns `undefined` when the manifest is absent or predates * the `env` field, so the caller falls back to the `.env` file keys. */ async readManifestEnvKeys(root) { try { const manifest = await this.fs.readJsonFile(this.fs.join(root, "dist", "manifest.json")); return Array.isArray(manifest.env) ? manifest.env : void 0; } catch { return; } } async secrets(ctx, run) { this.configureApi(ctx); const envVars = await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}`]); const declaredKeys = this.options?.secrets?.keys; const manifestKeys = await this.readManifestEnvKeys(ctx.root); const localKeys = Object.keys(await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}.local`])); const keys = declaredKeys ?? Array.from(/* @__PURE__ */ new Set([...manifestKeys ?? Object.keys(envVars), ...localKeys])); const secrets = {}; for (const key of keys) { if (CloudflareAdapter.EXCLUDED_SECRET_KEYS.has(key)) continue; if (key.startsWith("VITE_")) continue; const value = envVars[key] ?? process.env[key]; if (!value) continue; secrets[key] = value; } if (!secrets.PUBLIC_URL) { const url = this.publicUrl(ctx); if (url) secrets.PUBLIC_URL = url; } if (Object.keys(secrets).length === 0) return; const hash = computeSecretsHash(secrets); { const workerName = ctx.naming.worker(); await run({ name: `push secrets to ${workerName} (bulk)`, handler: async () => { const existingBindings = (await this.api.getWorkerSettings(workerName)).bindings ?? []; if (existingBindings.find((b) => b.type === "plain_text" && b.name === CloudflareAdapter.SECRETS_HASH_BINDING)?.text === hash) { this.log.info(`Secrets for ${workerName} unchanged (hash ${hash.slice(0, 8)}…), skipping push.`); return; } const overwriting = new Set(Object.keys(secrets)); const inherit = existingBindings.filter((b) => !(b.type === "plain_text" && b.name === CloudflareAdapter.SECRETS_HASH_BINDING) && (b.type !== "secret_text" || !overwriting.has(b.name))).map((b) => ({ type: b.type, name: b.name })); const upsert = Object.entries(secrets).map(([name, text]) => ({ type: "secret_text", name, text })); await this.api.patchWorkerBindings(workerName, [ ...inherit, ...upsert, { type: "plain_text", name: CloudflareAdapter.SECRETS_HASH_BINDING, text: hash } ]); } }); } } /** * Public base URL for this deploy, derived from the configured domain * (honoring tenant subdomains). Returns undefined when no domain is set or * the host is a wildcard — there's no single resolvable origin to point at. */ publicUrl(ctx) { const host = tenantDomain(ctx.envConfig.domain, ctx.tenant); if (!host || host.includes("*")) return; return `https://${host}`; } /** * Plain-text binding used to fingerprint the deployed secret set so the * next `up` can skip the PATCH when nothing has changed. */ static SECRETS_HASH_BINDING = "ALEPHA_SECRETS_HASH"; async provision(ctx, run) { this.configureApi(ctx); const needsDB = ctx.resources.hasDatabase; const needsBucket = ctx.resources.hasBucket; const postgres = needsDB && await this.isPostgres(ctx); const tasks = []; if (needsDB) if (postgres) { const hdName = ctx.naming.hyperdrive(); const dbUrl = (await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}`])).DATABASE_URL ?? process.env.DATABASE_URL; tasks.push({ name: `provision hyperdrive (${hdName})`, handler: async () => { this.provisionedHyperdriveId = await this.ensureHyperdrive(hdName, dbUrl); } }); } else { const dbName = ctx.naming.d1(); tasks.push({ name: `provision d1 (${dbName})`, handler: async () => { this.provisionedD1Id = await this.ensureD1(dbName); } }); } if (needsBucket) { const bucketName = ctx.naming.r2(); tasks.push({ name: `provision r2 (${bucketName})`, handler: async () => { await this.ensureR2(bucketName); } }); } if (ctx.resources.hasKV) { const kvName = ctx.naming.kv(); tasks.push({ name: `provision kv (${kvName})`, handler: async () => { this.provisionedKVIds.set(kvName, await this.ensureKV(kvName)); } }); } if (ctx.resources.hasQueue) { const queueName = ctx.naming.queue(); tasks.push({ name: `provision queue (${queueName})`, handler: async () => { await this.ensureQueue(queueName); } }); } await run(tasks); } async migrate(ctx, run) { this.configureApi(ctx); if (!ctx.resources.hasDatabase) return; if (await this.isPostgres(ctx)) await this.migratePostgres(ctx, run); else await this.migrateD1(ctx, run); } async exportDb(ctx, run, options = {}) { this.configureApi(ctx); if (!ctx.resources.hasDatabase) throw new AlephaError("No database detected for this app — nothing to export."); if (await this.isPostgres(ctx)) throw new AlephaError("Database export currently supports Cloudflare D1 only — Postgres/Hyperdrive export (pg_dump) is not implemented yet."); const dbName = ctx.naming.d1(); const tmpDir = this.fs.join(ctx.root, "node_modules", ".alepha"); const sqlPath = this.fs.join(tmpDir, `${dbName}.sql`); const dbPath = options.output ?? this.fs.join(tmpDir, "sqlite.db"); await this.fs.mkdir(tmpDir, { recursive: true }); await run(`wrangler d1 export ${dbName} --remote --output=${sqlPath}`, { alias: `export D1 ${dbName} → ${sqlPath}` }); await this.fs.rm(dbPath, { force: true }); await run(`sh -c "sqlite3 '${dbPath}' < '${sqlPath}'"`, { alias: `import dump → ${dbPath}` }); if (!options.keepSql) await this.fs.rm(sqlPath, { force: true }); } async migrateD1(ctx, run) { const dbName = ctx.naming.d1(); await run({ name: "migrate d1", handler: async () => { const migrationsDir = this.fs.join(ctx.root, "migrations", "sqlite"); const env = { DATABASE_URL: this.provisionedD1Id ? `d1://${dbName}:${this.provisionedD1Id}` : `d1://${dbName}` }; if (!ctx.prebuilt) if (await this.fs.exists(migrationsDir)) await this.runShell(`alepha db migrations check --mode ${ctx.env}`, { resolve: true, env }); else await this.runShell(`alepha db migrations create --mode ${ctx.env}`, { resolve: true, env }); const distMigrations = this.fs.join(ctx.root, "dist", "migrations"); await this.fs.cp(migrationsDir, distMigrations); await this.wrangler.d1MigrationsApply(dbName, "dist/wrangler.jsonc", ctx.root); await this.fs.rm(distMigrations, { recursive: true }); } }); } async migratePostgres(ctx, run) { if (ctx.prebuilt) throw new AlephaError("Postgres migrations are not yet supported in prebuilt mode. Use the `alepha platform up` CLI for now."); await run({ name: "migrate postgres", handler: async () => { const envVars = await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}`]); const env = { DATABASE_URL: envVars.DATABASE_URL ?? process.env.DATABASE_URL }; if (envVars.POSTGRES_SCHEMA ?? process.env.POSTGRES_SCHEMA) env.POSTGRES_SCHEMA = envVars.POSTGRES_SCHEMA ?? process.env.POSTGRES_SCHEMA; await this.runShell(`alepha db migrations apply --mode ${ctx.env}`, { resolve: true, env }); } }); } async inspect(ctx, run) { this.configureApi(ctx); const state = { workers: [], databases: [], buckets: [], kvNamespaces: [], queues: [], secrets: [] }; const tasks = []; { const name = ctx.naming.worker(); tasks.push({ name: `inspect worker (${name})`, handler: async () => { try { const deployment = await this.getActiveDeployment(name); if (deployment) state.workers.push({ name, exists: true, version: deployment.versionId, tag: deployment.tag, createdAt: deployment.createdAt }); else state.workers.push({ name, exists: false }); } catch { state.workers.push({ name, exists: false }); } } }); } if (ctx.resources.hasDatabase) if (await this.isPostgres(ctx)) { const hdName = ctx.naming.hyperdrive(); tasks.push({ name: `inspect hyperdrive (${hdName})`, handler: async () => { const existing = (await this.api.listHyperdrive()).find((c) => c.name === hdName); state.databases.push({ name: hdName, exists: !!existing, id: existing?.id, detail: existing?.origin.host }); } }); } else { const dbName = ctx.naming.d1(); tasks.push({ name: `inspect d1 (${dbName})`, handler: async () => { const existing = (await this.api.listD1()).find((db) => db.name === dbName); state.databases.push({ name: dbName, exists: !!existing, id: existing?.uuid }); } }); } if (ctx.resources.hasBucket) { const bucketName = ctx.naming.r2(); tasks.push({ name: `inspect r2 (${bucketName})`, handler: async () => { const existing = (await this.api.listR2()).find((b) => b.name === bucketName); state.buckets.push({ name: bucketName, exists: !!existing, id: existing?.creation_date }); } }); } if (ctx.resources.hasKV) { const kvName = ctx.naming.kv(); tasks.push({ name: `inspect kv (${kvName})`, handler: async () => { const existing = (await this.api.listKV()).find((ns) => ns.title === kvName); state.kvNamespaces.push({ name: kvName, exists: !!existing, id: existing?.id }); } }); } if (ctx.resources.hasQueue) { const queueName = ctx.naming.queue(); tasks.push({ name: `inspect queue (${queueName})`, handler: async () => { const existing = (await this.api.listQueues()).find((q) => q.queue_name === queueName); state.queues.push({ name: queueName, exists: !!existing, id: existing?.queue_id }); } }); } const envVars = await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}`]); const expectedSecrets = Object.keys(envVars).filter((key) => envVars[key] && !CloudflareAdapter.EXCLUDED_SECRET_KEYS.has(key) && !key.startsWith("VITE_")); if (expectedSecrets.length > 0) { const workerName = ctx.naming.worker(); tasks.push({ name: "inspect secrets", handler: async () => { try { const deployed = await this.api.listSecrets(workerName); const deployedNames = new Set(deployed.map((s) => s.name)); for (const key of expectedSecrets) state.secrets.push({ name: key, deployed: deployedNames.has(key) }); } catch { for (const key of expectedSecrets) state.secrets.push({ name: key, deployed: false }); } } }); } await run(tasks); return state; } async teardown(ctx, run) { this.configureApi(ctx); if (ctx.resources.hasQueue) { const workerName = ctx.naming.worker(); const queueName = ctx.naming.queue(); await run({ name: `unbind queue consumer ${queueName}`, handler: async () => { try { const queue = (await this.api.listQueues()).find((q) => q.queue_name === queueName); if (queue) await this.api.deleteQueueConsumer(queue.queue_id, workerName); } catch (error) { this.log.warn(`Failed to unbind queue consumer: ${String(error.message || "")}`); } } }); } { const name = ctx.naming.worker(); await run({ name: `delete worker ${name}`, handler: async () => { try { await this.api.deleteWorker(name); } catch (error) { this.log.warn(`Failed to delete worker ${name}: ${String(error.message || "")}`); } } }); } if (ctx.resources.hasQueue) { const name = ctx.naming.queue(); await run({ name: `delete queue ${name}`, handler: async () => { try { const queue = (await this.api.listQueues()).find((q) => q.queue_name === name); if (!queue) { this.log.debug(`Queue ${name} not found — skipping.`); return; } await this.api.deleteQueue(queue.queue_id); } catch (error) { this.log.warn(`Failed to delete queue ${name}: ${String(error.message || "")}`); } } }); } if (ctx.resources.hasKV) { const name = ctx.naming.kv(); await run({ name: `delete kv ${name}`, handler: async () => { try { const existing = (await this.api.listKV()).find((ns) => ns.title === name); if (!existing) { this.log.debug(`KV namespace ${name} not found — skipping.`); return; } await this.api.deleteKV(existing.id); } catch (error) { this.log.warn(`Failed to delete kv ${name}: ${String(error.message || "")}`); } } }); } if (ctx.resources.hasBucket) { const name = ctx.naming.r2(); await run({ name: `delete r2 ${name}`, handler: async () => { try { await this.deleteR2Bucket(name, ctx); } catch (error) { const msg = String(error.message || ""); if (this.isMissingBucketError(msg)) this.log.debug(`Bucket ${name} not found — skipping.`); else this.log.warn(`Failed to delete r2 ${name}: ${msg}`); } } }); } if (ctx.resources.hasDatabase) if (await this.isPostgres(ctx)) { const name = ctx.naming.hyperdrive(); await run({ name: `delete hyperdrive ${name}`, handler: async () => { try { const existing = (await this.api.listHyperdrive()).find((c) => c.name === name); if (!existing) { this.log.debug(`Hyperdrive ${name} not found — skipping.`); return; } await this.api.deleteHyperdrive(existing.id); } catch (error) { this.log.warn(`Failed to delete hyperdrive ${name}: ${String(error.message || "")}`); } } }); } else { const name = ctx.naming.d1(); await run({ name: `delete d1 ${name}`, handler: async () => { try { const existing = (await this.api.listD1()).find((db) => db.name === name); if (!existing) { this.log.debug(`D1 database ${name} not found — skipping.`); return; } await this.api.deleteD1(existing.uuid); } catch (error) { this.log.warn(`Failed to delete d1 ${name}: ${String(error.message || "")}`); } } }); } } async ensureD1(name) { const existing = (await this.api.listD1()).find((db) => db.name === name); if (existing) return existing.uuid; return