alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,499 lines • 87.4 kB
JavaScript
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