diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..05384c2d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Test fixtures are hashed byte-for-byte by the deploy tests; CRLF checkout +# on Windows would change the bytes and break the content-addressed hashes. +packages/cli/tests/fixtures/** text=auto eol=lf diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 10f85ab0..39ac5ef0 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -79,6 +79,7 @@ Read these when working on the relevant area: - **[Adding or modifying CLI commands](commands.md)** - Factory pattern, `runCommand()`, `runTask()`, `CLIContext`, theming, `chalk` ban - **[Making API calls](api-patterns.md)** - HTTP clients, Zod snake_case-to-camelCase transforms, `ApiError.fromHttpError()` - **[Working with resources](resources.md)** - `Resource` interface, adding new resources, site module, unified deploy +- **[Full-stack deployments](deployments.md)** - Workers-based deploys, wrangler config, asset manifest hashing, promote/rollback, deployment logs - **[Plugins](plugins.md)** - Plugin config, namespaces, entity extension rules, function namespacing, pull/deploy behavior - **[Error handling](error-handling.md)** - Error hierarchy, throwing patterns, error codes, `CLIExitError`, `process.exit` ban - **[Writing tests](testing.md)** - Testkit, Given/When/Then pattern, API mocks, fixtures, test overrides diff --git a/docs/deployments.md b/docs/deployments.md new file mode 100644 index 00000000..5ccc1cae --- /dev/null +++ b/docs/deployments.md @@ -0,0 +1,54 @@ +# Full-Stack Deployments + +**Keywords:** deployments, full-stack, Cloudflare Workers, wrangler, no_bundle, asset manifest, hash, buckets, finalize, promote, rollback, deployment logs, .assetsignore, .wrangler/deploy/config.json + +Full-stack deployments host framework builds (React Router 7, TanStack Start, Astro 6, vinext — anything built with `@cloudflare/vite-plugin`) as Workers on Base44. The core module is `src/core/deployments/` (`wrangler-config.ts`, `manifest.ts`, `modules.ts`, `upload.ts`, `api.ts`, `deploy.ts`). + +## Artifact Detection + +`detectFullStackArtifact(projectRoot)` checks, in order: + +1. `.wrangler/deploy/config.json` — redirect file emitted by `@cloudflare/vite-plugin`; its `configPath` points at the generated `wrangler.json`, **relative to the redirect file's directory** +2. Root `wrangler.jsonc` / `wrangler.json` — hand-authored config (JSON5-parsed) +3. Root `wrangler.toml` — **unsupported**, fails with a clear error + +The resolved config must have `no_bundle: true`; otherwise the deploy fails with "this framework's output requires bundling; not yet supported". Any non-empty binding (`kv_namespaces`, `d1_databases`, `r2_buckets`, `durable_objects.bindings`, `services`, `queues.*`, `hyperdrive`, `analytics_engine_datasets`, `vectorize`) fails fast; `vars` are allowed and passed through in the create payload's `config.vars`. + +Framework quirks observed in the wild: extra redirect-file fields (`auxiliaryWorkers`, Astro 6's `prerenderWorkerConfigPath`) are ignored for now, and a generated config with no `nodejs_compat` compatibility flag (Astro 6 can emit empty `compatibility_flags`) produces a warning — the flag is not injected, since the fix belongs in the framework's adapter settings. + +## API Contract (app-scoped, via `getAppClient()`) + +1. `POST deployments` — JSON body: `target` (`preview`/`production`), `framework`, `config` (`main`, `compatibility_date`, `compatibility_flags`, `assets_config`, `vars`), `modules` (`[{name, size, type}]`), `asset_manifest` (`{"/path": {hash, size}}`). Returns `deployment_id`, `script_name`, `immutable_url`, and `asset_upload` (`{jwt, buckets, upload_url}` or `null` when there are no assets; empty `buckets` means the jwt is already the completion token). +2. **Asset bucket upload** — for each bucket, `POST` multipart/form-data **directly** to `asset_upload.upload_url` with `Authorization: Bearer ` and `?base64=true`; each field: name = file hash, value = base64 file bytes, contentType = the file's real MIME type. Buckets upload with concurrency 3; each bucket retries up to 3 times with exponential backoff. The final response (HTTP 201) returns `{"result": {"jwt": ""}}`. A 401 after retries surfaces "upload session expired — rerun deploy" (re-running re-creates the deployment; already-stored hashes won't be in the new buckets). +3. `POST deployments/{id}/finalize` — multipart: field `payload` = JSON `{"completion_jwt": string|null}` plus one file field per module (name = module path, contentType `application/javascript+module` for esm / `application/source-map` for `.map`). Returns `status`, `deployment_id`, `urls` (`[{url, kind: "immutable"|"production"}]`). +4. `GET deployments?limit=20` / `GET deployments/{id}` — list/get, rows: `deployment_id`, `status` (`ready`/`failed`/`in_progress`), `target`, `created_at`, `urls`, `framework`. +5. `POST deployments/{id}/promote`, `POST deployments/rollback` — return `{deployment_id, urls}`. +6. `GET deployments/{id}/logs?since=&until=&limit=&order=` and `GET deployments/logs?alias=production&...` — `{logs: [{time, level, message}]}` (same row shape as function logs). + +## Asset Manifest & Hashing + +`hash = first 32 hex chars of sha256(utf8(app_id) || raw file bytes)` — see `hashAsset()` in `src/core/deployments/manifest.ts`. The app-id salt is a cache-poisoning defense: a tenant can only produce hash collisions with its own files. + +The assets directory (from `assets.directory`, relative to the config dir) is walked recursively. `.assetsignore` at the assets root is honored (minimal gitignore-style matching: exact names, `*`/`**` globs, directory patterns; no negation). `.assetsignore` itself, `wrangler.json`, and `.dev.vars` are always skipped. Files over 25 MiB fail with a per-file error; total file count is capped at 100,000. Manifest keys are `/`-prefixed forward-slash paths. + +## Module Collection + +Entry = `main` from the wrangler config. With `no_bundle: true`, every file under the config dir matching the `rules` globs is included, excluding `wrangler.json` and `.dev.vars`, preserving relative paths as module names. `.map` files next to modules (or all of them when `upload_source_maps` is set) are included as `sourcemap`. Total module payload is capped at 40 MB. + +## Command UX + +- **`base44 deploy [--prod] [--build] [--prebuilt]`** — after the resource pushes: when `site.buildCommand` is configured, install/build runs **only when `--build` is passed or no build artifact exists yet** (`--prebuilt` always skips it). Then, if a full-stack artifact is detected it replaces the legacy site tar.gz upload; otherwise behavior is unchanged. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" → summary rows `Preview: ` and (with `--prod`) `Production: `. Under `--json`, stdout is a single `{deploymentId, target, urls}` document. +- **`base44 deployments list` / `base44 deployments get `** — recent deployments / one deployment. +- **`base44 promote `** — point production at an existing deployment. +- **`base44 rollback [-y]`** — roll production back to the previous deployment; confirmation prompt, `-y` required in non-interactive mode. +- **`base44 logs --deployment `** — deployment logs; supports `--since --until --limit --order --follow` (`--follow` polls every 2s); `--function`, `--level`, and `--env` don't apply. + +## Testing + +`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; fills `upload_url` to point at the test server), `mockAssetUpload` (serves the upload endpoint, captures multipart fields in `assetUploadRequests`, responds 201 with the completion jwt), `mockDeploymentFinalize` (captures fields in `finalizeRequests`), `mockDeploymentsList`, `mockDeploymentGet`, `mockPromote`, `mockRollback`, `mockDeploymentLogs`. Fixture: `tests/fixtures/fullstack-project/` (redirect file + `build/server` worker + `build/client` assets with `.assetsignore`). Unit tests live in `tests/core/deployments-*.spec.ts`. + +## Rules (Deployments-Specific) + +- **Never re-derive the asset hash** — always go through `hashAsset()` so the app-id salt stays consistent +- **Bucket uploads go to `upload_url` directly** (absolute URL, bearer jwt) — not through `base44Client` +- **Legacy behavior stays identical** when no full-stack artifact exists — the tar.gz site path must not change diff --git a/docs/resources.md b/docs/resources.md index 8cf65b73..fd934770 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -103,7 +103,7 @@ What it deploys (in order): 2. Functions (via `functionResource.push()`) 3. Agents (via `agentResource.push()`) 4. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs -5. Site (if `site.outputDirectory` is configured) +5. Site — a full-stack Workers artifact when one is detected (see [deployments.md](deployments.md)), otherwise the legacy tar.gz upload (if `site.outputDirectory` is configured). The deploy command passes `site: false` to `deployAll()` and handles this step itself. ```bash base44 deploy # With confirmation prompt diff --git a/docs/testing.md b/docs/testing.md index 3a81485a..a03984f1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -72,6 +72,7 @@ tests/ ├── duplicate-function-names/ # Error: duplicate function names ├── with-zero-config-functions/ # Full project: zero-config + path-named functions (CLI integration) ├── with-site/ # Project with site config + ├── fullstack-project/ # Full-stack Workers artifact (.wrangler redirect + build output) ├── full-project/ # All resources combined ├── no-app-config/ # Unlinked project (no .app.jsonc) └── invalid-*/ # Error case fixtures @@ -298,6 +299,30 @@ t.api.mockFunctionLogs("my-function", [ t.api.mockFunctionLogsError("my-function", { status: 500, body: { error: "Server error" } }); ``` +### Deployment (Full-Stack) Mocks + +See [deployments.md](deployments.md) for the API contract. Requests are captured for assertions: `t.api.deploymentCreateRequests` (JSON bodies), `t.api.assetUploadRequests` and `t.api.finalizeRequests` (parsed multipart fields). + +```typescript +t.api.mockDeploymentCreate({ + deployment_id: "dep-1", + script_name: "script-1", + immutable_url: "https://dep-1.app.base44.app", + asset_upload: { jwt: "upload-jwt", buckets: [[""]] }, // upload_url auto-points at the test server +}); +t.api.mockAssetUpload("completion-jwt"); // serves the upload_url endpoint, responds 201 +t.api.mockDeploymentFinalize({ status: "ready", deployment_id: "dep-1", urls: [...] }); +t.api.mockDeploymentsList({ deployments: [...] }); +t.api.mockDeploymentGet("dep-1", { deployment_id: "dep-1", ... }); +t.api.mockPromote({ deployment_id: "dep-1", urls: [...] }); +t.api.mockRollback({ deployment_id: "dep-0", urls: [...] }); +t.api.mockRollbackError({ status: 400, body: { message: "No previous production deployment" } }); +t.api.mockDeploymentLogs("dep-1", [{ time: "...", level: "info", message: "..." }]); +t.api.mockDeploymentLogs("production", [...]); // alias route +t.api.mockDeploymentLogsError("dep-1", { status: 500, body: { error: "Server error" } }); +t.api.mockAssetUploadError({ status: 500, body: { error: "Server error" } }); +``` + ### Custom Route Mock For advanced scenarios (e.g. stateful responses across retries): diff --git a/packages/cli/src/cli/commands/deployments/get.ts b/packages/cli/src/cli/commands/deployments/get.ts new file mode 100644 index 00000000..ab897e7e --- /dev/null +++ b/packages/cli/src/cli/commands/deployments/get.ts @@ -0,0 +1,41 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { getDeployment } from "@/core/deployments/index.js"; +import { + formatDeploymentLine, + logDeploymentUrls, + toJsonStdout, +} from "./shared.js"; + +async function getDeploymentAction( + { log, runTask, jsonMode }: CLIContext, + deploymentId: string, +): Promise { + const deployment = await runTask( + "Fetching deployment...", + async () => await getDeployment(deploymentId), + { errorMessage: "Failed to fetch deployment" }, + ); + + if (jsonMode) { + return { + outroMessage: `Deployment ${deployment.deploymentId} is ${deployment.status}`, + stdout: toJsonStdout(deployment), + }; + } + + log.message(formatDeploymentLine(deployment)); + logDeploymentUrls(deployment.urls, log); + + return { + outroMessage: `Deployment ${deployment.deploymentId} is ${deployment.status}`, + }; +} + +export function getDeploymentsGetCommand(): Command { + return new Base44Command("get") + .description("Show a full-stack deployment") + .argument("", "Deployment ID") + .action(getDeploymentAction); +} diff --git a/packages/cli/src/cli/commands/deployments/index.ts b/packages/cli/src/cli/commands/deployments/index.ts new file mode 100644 index 00000000..900ec1dd --- /dev/null +++ b/packages/cli/src/cli/commands/deployments/index.ts @@ -0,0 +1,10 @@ +import { Command } from "commander"; +import { getDeploymentsGetCommand } from "./get.js"; +import { getDeploymentsListCommand } from "./list.js"; + +export function getDeploymentsCommand(): Command { + return new Command("deployments") + .description("Manage full-stack deployments") + .addCommand(getDeploymentsListCommand()) + .addCommand(getDeploymentsGetCommand()); +} diff --git a/packages/cli/src/cli/commands/deployments/list.ts b/packages/cli/src/cli/commands/deployments/list.ts new file mode 100644 index 00000000..b16819cc --- /dev/null +++ b/packages/cli/src/cli/commands/deployments/list.ts @@ -0,0 +1,42 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { listDeployments } from "@/core/deployments/index.js"; +import { formatDeploymentLine, toJsonStdout } from "./shared.js"; + +async function listDeploymentsAction({ + log, + runTask, + jsonMode, +}: CLIContext): Promise { + const deployments = await runTask( + "Fetching deployments...", + async () => await listDeployments(), + { errorMessage: "Failed to fetch deployments" }, + ); + + if (jsonMode) { + return { + outroMessage: `${deployments.length} deployments`, + stdout: toJsonStdout({ deployments }), + }; + } + + if (deployments.length === 0) { + return { outroMessage: "No deployments found" }; + } + + for (const deployment of deployments) { + log.message(formatDeploymentLine(deployment)); + } + + return { + outroMessage: `${deployments.length} deployment${deployments.length !== 1 ? "s" : ""}`, + }; +} + +export function getDeploymentsListCommand(): Command { + return new Base44Command("list") + .description("List recent full-stack deployments") + .action(listDeploymentsAction); +} diff --git a/packages/cli/src/cli/commands/deployments/promote.ts b/packages/cli/src/cli/commands/deployments/promote.ts new file mode 100644 index 00000000..ce5d8698 --- /dev/null +++ b/packages/cli/src/cli/commands/deployments/promote.ts @@ -0,0 +1,30 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { promoteDeployment } from "@/core/deployments/index.js"; +import { logDeploymentUrls, toJsonStdout } from "./shared.js"; + +async function promoteAction( + { log, runTask, jsonMode }: CLIContext, + deploymentId: string, +): Promise { + const result = await runTask( + `Promoting deployment ${deploymentId} to production...`, + async () => await promoteDeployment(deploymentId), + { errorMessage: "Failed to promote deployment" }, + ); + + logDeploymentUrls(result.urls, log); + + return { + outroMessage: `Deployment ${result.deploymentId} promoted to production`, + stdout: jsonMode ? toJsonStdout(result) : undefined, + }; +} + +export function getPromoteCommand(): Command { + return new Base44Command("promote") + .description("Promote a full-stack deployment to production") + .argument("", "Deployment ID to promote") + .action(promoteAction); +} diff --git a/packages/cli/src/cli/commands/deployments/rollback.ts b/packages/cli/src/cli/commands/deployments/rollback.ts new file mode 100644 index 00000000..bcc38efa --- /dev/null +++ b/packages/cli/src/cli/commands/deployments/rollback.ts @@ -0,0 +1,49 @@ +import { confirm, isCancel } from "@clack/prompts"; +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { rollbackDeployment } from "@/core/deployments/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { logDeploymentUrls, toJsonStdout } from "./shared.js"; + +interface RollbackOptions { + yes?: boolean; +} + +async function rollbackAction( + { log, runTask, jsonMode, isNonInteractive }: CLIContext, + options: RollbackOptions, +): Promise { + if (isNonInteractive && !options.yes) { + throw new InvalidInputError("--yes is required in non-interactive mode"); + } + + if (!options.yes) { + const shouldRollback = await confirm({ + message: "Roll back production to the previous deployment?", + }); + if (isCancel(shouldRollback) || !shouldRollback) { + return { outroMessage: "Rollback cancelled" }; + } + } + + const result = await runTask( + "Rolling back production...", + async () => await rollbackDeployment(), + { errorMessage: "Failed to roll back" }, + ); + + logDeploymentUrls(result.urls, log); + + return { + outroMessage: `Production rolled back to deployment ${result.deploymentId}`, + stdout: jsonMode ? toJsonStdout(result) : undefined, + }; +} + +export function getRollbackCommand(): Command { + return new Base44Command("rollback") + .description("Roll back production to the previous full-stack deployment") + .option("-y, --yes", "Skip confirmation prompt") + .action(rollbackAction); +} diff --git a/packages/cli/src/cli/commands/deployments/shared.ts b/packages/cli/src/cli/commands/deployments/shared.ts new file mode 100644 index 00000000..3d1756df --- /dev/null +++ b/packages/cli/src/cli/commands/deployments/shared.ts @@ -0,0 +1,20 @@ +import type { Logger } from "@base44-cli/logger"; +import { theme } from "@/cli/utils/index.js"; +import type { Deployment, DeploymentUrl } from "@/core/deployments/index.js"; + +export function toJsonStdout(result: unknown): string { + return `${JSON.stringify(result, null, 2)}\n`; +} + +export function logDeploymentUrls(urls: DeploymentUrl[], log: Logger): void { + for (const { url, kind } of urls) { + const label = kind === "production" ? "Production" : "Preview"; + log.message(`${theme.styles.header(label)}: ${theme.colors.links(url)}`); + } +} + +export function formatDeploymentLine(deployment: Deployment): string { + const url = deployment.urls[0]?.url ?? ""; + const framework = deployment.framework ? ` ${deployment.framework}` : ""; + return `${deployment.deploymentId} ${deployment.status.padEnd(11)} ${deployment.target.padEnd(10)} ${deployment.createdAt}${framework} ${theme.colors.links(url)}`; +} diff --git a/packages/cli/src/cli/commands/domains/add.ts b/packages/cli/src/cli/commands/domains/add.ts new file mode 100644 index 00000000..218deba4 --- /dev/null +++ b/packages/cli/src/cli/commands/domains/add.ts @@ -0,0 +1,66 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import type { Domain } from "@/core/domains/index.js"; +import { addDomain, waitForDomainActive } from "@/core/domains/index.js"; +import { domainStatusText, logDomainSetup, toJsonStdout } from "./shared.js"; + +interface AddOptions { + wait?: boolean; +} + +function waitMessage(domain: Domain | undefined): string { + if (!domain) return "Waiting for domain to appear..."; + return `Waiting for domain to become active (${domainStatusText(domain)})...`; +} + +async function addDomainAction( + { log, runTask, jsonMode }: CLIContext, + hostname: string, + options: AddOptions, +): Promise { + let domain = await runTask( + `Connecting ${hostname}...`, + async () => await addDomain(hostname), + { errorMessage: "Failed to connect domain" }, + ); + + if (options.wait && !domain.active) { + domain = await runTask( + waitMessage(domain), + async (updateMessage) => + await waitForDomainActive(hostname, { + onTick: (d) => updateMessage(waitMessage(d)), + }), + { + successMessage: `${hostname} is active`, + errorMessage: "Domain did not become active", + }, + ); + } + + if (jsonMode) { + return { + outroMessage: `Domain ${hostname} is ${domainStatusText(domain)}`, + stdout: toJsonStdout(domain), + }; + } + + logDomainSetup(domain, log); + return { + outroMessage: domain.active + ? `${hostname} is active` + : `${hostname} connected — add the CNAME record above to finish`, + }; +} + +export function getDomainsAddCommand(): Command { + return new Base44Command("add") + .description("Connect a custom domain to this app") + .argument("", "Domain to connect, e.g. app.example.com") + .option( + "--wait", + "Poll until the domain and its TLS certificate are active", + ) + .action(addDomainAction); +} diff --git a/packages/cli/src/cli/commands/domains/index.ts b/packages/cli/src/cli/commands/domains/index.ts new file mode 100644 index 00000000..a12f9460 --- /dev/null +++ b/packages/cli/src/cli/commands/domains/index.ts @@ -0,0 +1,12 @@ +import { Command } from "commander"; +import { getDomainsAddCommand } from "./add.js"; +import { getDomainsListCommand } from "./list.js"; +import { getDomainsRemoveCommand } from "./remove.js"; + +export function getDomainsCommand(): Command { + return new Command("domains") + .description("Manage custom domains for full-stack apps") + .addCommand(getDomainsAddCommand()) + .addCommand(getDomainsListCommand()) + .addCommand(getDomainsRemoveCommand()); +} diff --git a/packages/cli/src/cli/commands/domains/list.ts b/packages/cli/src/cli/commands/domains/list.ts new file mode 100644 index 00000000..c59707a9 --- /dev/null +++ b/packages/cli/src/cli/commands/domains/list.ts @@ -0,0 +1,42 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { listDomains } from "@/core/domains/index.js"; +import { formatDomainLine, toJsonStdout } from "./shared.js"; + +async function listDomainsAction({ + log, + runTask, + jsonMode, +}: CLIContext): Promise { + const domains = await runTask( + "Fetching domains...", + async () => await listDomains(), + { errorMessage: "Failed to fetch domains" }, + ); + + if (jsonMode) { + return { + outroMessage: `${domains.length} domains`, + stdout: toJsonStdout({ domains }), + }; + } + + if (domains.length === 0) { + return { outroMessage: "No custom domains found" }; + } + + for (const domain of domains) { + log.message(formatDomainLine(domain)); + } + + return { + outroMessage: `${domains.length} domain${domains.length !== 1 ? "s" : ""}`, + }; +} + +export function getDomainsListCommand(): Command { + return new Base44Command("list") + .description("List custom domains connected to this app") + .action(listDomainsAction); +} diff --git a/packages/cli/src/cli/commands/domains/remove.ts b/packages/cli/src/cli/commands/domains/remove.ts new file mode 100644 index 00000000..ca2f6341 --- /dev/null +++ b/packages/cli/src/cli/commands/domains/remove.ts @@ -0,0 +1,49 @@ +import { confirm, isCancel } from "@clack/prompts"; +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { removeDomain } from "@/core/domains/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { toJsonStdout } from "./shared.js"; + +interface RemoveOptions { + yes?: boolean; +} + +async function removeDomainAction( + { runTask, jsonMode, isNonInteractive }: CLIContext, + hostname: string, + options: RemoveOptions, +): Promise { + if (isNonInteractive && !options.yes) { + throw new InvalidInputError("--yes is required in non-interactive mode"); + } + + if (!options.yes) { + const shouldRemove = await confirm({ + message: `Disconnect ${hostname} from this app?`, + }); + if (isCancel(shouldRemove) || !shouldRemove) { + return { outroMessage: "Removal cancelled" }; + } + } + + const result = await runTask( + `Removing ${hostname}...`, + async () => await removeDomain(hostname), + { errorMessage: "Failed to remove domain" }, + ); + + return { + outroMessage: `Disconnected ${hostname}`, + stdout: jsonMode ? toJsonStdout(result) : undefined, + }; +} + +export function getDomainsRemoveCommand(): Command { + return new Base44Command("remove") + .description("Disconnect a custom domain from this app") + .argument("", "Domain to disconnect") + .option("-y, --yes", "Skip confirmation prompt") + .action(removeDomainAction); +} diff --git a/packages/cli/src/cli/commands/domains/shared.ts b/packages/cli/src/cli/commands/domains/shared.ts new file mode 100644 index 00000000..392a2876 --- /dev/null +++ b/packages/cli/src/cli/commands/domains/shared.ts @@ -0,0 +1,45 @@ +import type { Logger } from "@base44-cli/logger"; +import { theme } from "@/cli/utils/index.js"; +import type { Domain } from "@/core/domains/index.js"; + +export function toJsonStdout(result: unknown): string { + return `${JSON.stringify(result, null, 2)}\n`; +} + +/** "pending (SSL: pending_validation)" — one-line status summary. */ +export function domainStatusText(domain: Domain): string { + const status = domain.status ?? "unknown"; + const ssl = domain.sslStatus ?? "unknown"; + return domain.active ? "active" : `${status} (SSL: ${ssl})`; +} + +/** A padded single-line row for the `domains list` table. */ +export function formatDomainLine(domain: Domain): string { + const status = ( + domain.active ? "active" : (domain.status ?? "unknown") + ).padEnd(12); + const ssl = `ssl:${domain.sslStatus ?? "unknown"}`.padEnd(22); + return `${domain.hostname.padEnd(32)} ${status} ${ssl} → ${theme.colors.links(domain.cnameTarget)}`; +} + +/** + * Print the exact DNS record the user must add plus the current status. TLS is + * issued automatically by Cloudflare once the CNAME resolves. + */ +export function logDomainSetup(domain: Domain, log: Logger): void { + log.message(`${theme.styles.header("Add this DNS record")}:`); + log.message( + ` CNAME ${domain.hostname} ${theme.styles.dim("→")} ${theme.colors.links(domain.cnameTarget)}`, + ); + log.message(`${theme.styles.header("Status")}: ${domainStatusText(domain)}`); + if (domain.pendingDeployment) { + log.warn( + "This app has no production deployment yet — the domain will start serving once you deploy and promote to production.", + ); + } + log.message( + theme.styles.dim( + "TLS certificate is issued automatically once the CNAME resolves.", + ), + ); +} diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index ad07fa79..86b99441 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -1,6 +1,8 @@ +import { resolve } from "node:path"; import type { Logger } from "@base44-cli/logger"; import { confirm, isCancel } from "@clack/prompts"; import type { Command } from "commander"; +import { execa } from "execa"; import { filterPendingOAuth, promptOAuthFlows, @@ -13,41 +15,64 @@ import { getDashboardUrl, theme, } from "@/cli/utils/index.js"; +import type { + FullStackArtifact, + FullStackDeployResult, +} from "@/core/deployments/index.js"; +import { + deployFullStack, + detectFullStackArtifact, +} from "@/core/deployments/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { deployAll, hasResourcesToDeploy, readProjectConfig, } from "@/core/project/index.js"; +import type { ProjectData } from "@/core/project/types.js"; import type { ConnectorSyncResult, StripeSyncResult, } from "@/core/resources/connector/index.js"; +import { deploySite } from "@/core/site/index.js"; +import { pathExists } from "@/core/utils/fs.js"; interface DeployOptions { yes?: boolean; projectRoot?: string; + prod?: boolean; + build?: boolean; + prebuilt?: boolean; } export async function deployAction( - { isNonInteractive, log }: CLIContext, + ctx: CLIContext, options: DeployOptions = {}, ): Promise { + const { isNonInteractive, log } = ctx; if (isNonInteractive && !options.yes) { throw new InvalidInputError("--yes is required in non-interactive mode"); } + if (options.build && options.prebuilt) { + throw new InvalidInputError("--build and --prebuilt cannot be combined."); + } const projectData = await readProjectConfig(options.projectRoot); + const { project, entities, functions, agents, connectors, authConfig } = + projectData; + + // Best-effort pre-build detection (for the summary and the no-resources + // check); re-detected after the optional build step below. + let fullStackArtifact = await detectFullStackArtifact(project.root); + const isFullStackCandidate = + fullStackArtifact !== null || Boolean(project.site?.buildCommand); - if (!hasResourcesToDeploy(projectData)) { + if (!hasResourcesToDeploy(projectData) && !isFullStackCandidate) { return { outroMessage: "No resources found to deploy", }; } - const { project, entities, functions, agents, connectors, authConfig } = - projectData; - // Build summary of what will be deployed const summaryLines: string[] = []; if (entities.length > 0) { @@ -73,7 +98,11 @@ export async function deployAction( if (authConfig.length > 0) { summaryLines.push(" - Auth config"); } - if (project.site?.outputDirectory) { + if (fullStackArtifact) { + summaryLines.push( + ` - Full-stack app (${options.prod ? "production" : "preview"})`, + ); + } else if (project.site?.outputDirectory) { summaryLines.push(` - Site from ${project.site.outputDirectory}`); } @@ -94,11 +123,13 @@ export async function deployAction( log.info(`Deploying:\n${summaryLines.join("\n")}`); } - // Deploy resources with per-function progress + // Deploy resources with per-function progress. The site (legacy or + // full-stack) is handled below, after the optional build step. let functionCompleted = 0; const functionTotal = functions.length; const result = await deployAll(projectData, { + site: false, onFunctionStart: (names) => { const label = names.length === 1 ? names[0] : `${names.length} functions`; log.step( @@ -113,6 +144,21 @@ export async function deployAction( }, }); + // Full-stack detection: build first when configured (only when --build is + // passed or no artifact exists yet), then prefer the Workers deployments + // path over the legacy site tar.gz upload. + await buildSiteIfNeeded(ctx, projectData, options, fullStackArtifact); + fullStackArtifact = await detectFullStackArtifact(project.root); + + let appUrl: string | undefined; + let fullStackResult: FullStackDeployResult | undefined; + if (fullStackArtifact) { + fullStackResult = await runFullStackDeploy(ctx, project.root, options); + } else if (project.site?.outputDirectory) { + const outputDir = resolve(project.root, project.site.outputDirectory); + ({ appUrl } = await deploySite(outputDir)); + } + // Handle connector-specific post-deploy flows const connectorResults = result.connectorResults ?? []; await handleOAuthConnectors(connectorResults, isNonInteractive, options, log); @@ -124,13 +170,136 @@ export async function deployAction( log.message( `${theme.styles.header("Dashboard")}: ${theme.colors.links(getDashboardUrl())}`, ); - if (result.appUrl) { + if (appUrl) { log.message( - `${theme.styles.header("App URL")}: ${theme.colors.links(result.appUrl)}`, + `${theme.styles.header("App URL")}: ${theme.colors.links(appUrl)}`, ); } + if (fullStackResult) { + printFullStackSummary(fullStackResult, log); + } - return { outroMessage: "App deployed successfully" }; + return { + outroMessage: "App deployed successfully", + stdout: + ctx.jsonMode && fullStackResult + ? `${JSON.stringify( + { + deploymentId: fullStackResult.deploymentId, + target: fullStackResult.target, + urls: fullStackResult.urls, + }, + null, + 2, + )}\n` + : undefined, + }; +} + +/** + * Run the configured install/build commands when a site build is configured. + * Runs only when --build is passed or no build artifact exists yet + * (a full-stack artifact or the configured site output directory); + * --prebuilt always skips it. + */ +async function buildSiteIfNeeded( + { runTask }: CLIContext, + { project }: ProjectData, + options: DeployOptions, + fullStackArtifact: FullStackArtifact | null, +): Promise { + const site = project.site; + if (!site?.buildCommand || options.prebuilt) return; + + const artifactPresent = + fullStackArtifact !== null || + (site.outputDirectory + ? await pathExists(resolve(project.root, site.outputDirectory)) + : false); + + if (artifactPresent && !options.build) return; + + const { installCommand, buildCommand } = site; + await runTask( + installCommand ? "Installing dependencies..." : "Building project...", + async (updateMessage) => { + if (installCommand) { + await execa({ cwd: project.root, shell: true })`${installCommand}`; + updateMessage("Building project..."); + } + await execa({ cwd: project.root, shell: true })`${buildCommand}`; + }, + { + successMessage: "Project built successfully", + errorMessage: "Failed to build project", + }, + ); +} + +async function runFullStackDeploy( + { runTask, log }: CLIContext, + projectRoot: string, + options: DeployOptions, +): Promise { + const progressLines: string[] = []; + const warnings: string[] = []; + const deployment = await runTask( + "Deploying full-stack app...", + async (updateMessage) => + await deployFullStack({ + projectRoot, + target: options.prod ? "production" : "preview", + progress: { + onWarning: (message) => { + warnings.push(message); + }, + onAssets: ({ totalAssets, newAssets }) => { + const line = `Found ${totalAssets} static assets (${newAssets} new)`; + progressLines.push(line); + updateMessage(line); + }, + onAssetUpload: ({ uploadedFiles, totalFiles }) => { + updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} assets`); + }, + onWorker: ({ moduleCount }) => { + updateMessage(`Deploying worker (${moduleCount} modules)…`); + }, + }, + }), + { + successMessage: theme.colors.base44Orange("Full-stack app deployed"), + errorMessage: "Full-stack deploy failed", + }, + ); + + for (const line of progressLines) { + log.message(theme.styles.dim(line)); + } + for (const warning of warnings) { + log.warn(warning); + } + + return deployment; +} + +function printFullStackSummary( + deployment: FullStackDeployResult, + log: Logger, +): void { + const immutableUrl = deployment.urls.find((u) => u.kind === "immutable")?.url; + const productionUrl = deployment.urls.find( + (u) => u.kind === "production", + )?.url; + if (immutableUrl) { + log.message( + `${theme.styles.header("Preview")}: ${theme.colors.links(immutableUrl)}`, + ); + } + if (productionUrl) { + log.message( + `${theme.styles.header("Production")}: ${theme.colors.links(productionUrl)}`, + ); + } } export function getDeployCommand(): Command { @@ -139,6 +308,15 @@ export function getDeployCommand(): Command { "Deploy all project resources (entities, functions, agents, connectors, and site)", ) .option("-y, --yes", "Skip confirmation prompt") + .option("--prod", "Deploy the full-stack app to production") + .option( + "--build", + "Run the configured install/build commands before deploying, even when a build artifact already exists", + ) + .option( + "--prebuilt", + "Skip the build step and deploy the existing artifact", + ) .action(deployAction); } diff --git a/packages/cli/src/cli/commands/project/logs.ts b/packages/cli/src/cli/commands/project/logs.ts index 7712fd3d..74454429 100644 --- a/packages/cli/src/cli/commands/project/logs.ts +++ b/packages/cli/src/cli/commands/project/logs.ts @@ -3,6 +3,8 @@ import { Option } from "commander"; import ms, { type StringValue } from "ms"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; +import type { DeploymentLogFilters } from "@/core/deployments/index.js"; +import { fetchDeploymentLogs } from "@/core/deployments/index.js"; import { ApiError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; import type { @@ -20,6 +22,7 @@ import { interface LogsOptions { function?: string; + deployment?: string; since?: string; until?: string; level?: string; @@ -258,11 +261,120 @@ function validateLimit(limit: string | undefined): void { } } +function parseDeploymentFilters(options: LogsOptions): DeploymentLogFilters { + const filters: DeploymentLogFilters = {}; + if (options.since) filters.since = options.since; + if (options.until) filters.until = options.until; + if (options.limit) filters.limit = Number.parseInt(options.limit, 10); + if (options.order) { + filters.order = options.order.toLowerCase() as "asc" | "desc"; + } + return filters; +} + +async function fetchDeploymentEntries( + deployment: string, + options: LogsOptions, +): Promise { + const logs = await fetchDeploymentLogs( + deployment, + parseDeploymentFilters(options), + ); + return logs.map((entry) => ({ ...entry, source: deployment })); +} + +async function followDeploymentLogs( + deployment: string, + options: LogsOptions, + jsonMode: boolean, +): Promise { + let state: FollowState = { lastTime: "", boundaryKeys: new Set() }; + let first = true; + + while (true) { + const pollOptions = first ? options : { ...options, since: state.lastTime }; + const entries = await fetchDeploymentEntries(deployment, pollOptions); + const { fresh, nextState } = selectNewEntries(entries, state); + state = nextState; + fresh.sort((a, b) => a.time.localeCompare(b.time)); + for (const entry of fresh) writeFollowLine(entry, jsonMode); + first = false; + await new Promise((resolve) => setTimeout(resolve, 2000)); + } +} + +async function deploymentLogsAction( + ctx: CLIContext, + deployment: string, + options: LogsOptions, +): Promise { + if (options.function) { + throw new InvalidInputError( + "--function cannot be combined with --deployment.", + ); + } + if (options.level) { + throw new InvalidInputError("--level is not supported with --deployment."); + } + if (options.env) { + throw new InvalidInputError( + "--env is not supported with --deployment (pass a deployment id or 'production').", + ); + } + + if (options.follow) { + options.order = "asc"; // tail reads oldest -> newest + return followDeploymentLogs(deployment, options, ctx.jsonMode); + } + + let entries = await fetchDeploymentEntries(deployment, options); + + const limit = options.limit ? Number.parseInt(options.limit, 10) : undefined; + if (limit !== undefined && entries.length > limit) { + entries = entries.slice(0, limit); + } + + const logsOutput = ctx.jsonMode + ? `${JSON.stringify(entries, null, 2)}\n` + : formatDeploymentLogs(entries); + + return { + outroMessage: ctx.jsonMode ? undefined : "Fetched logs", + stdout: logsOutput, + }; +} + +function formatDeploymentLogs(entries: LogEntry[]): string { + if (entries.length === 0) { + return "No deployment logs found matching the filters.\n"; + } + const header = `Showing ${entries.length} deployment log entries\n`; + return [header, ...entries.map(formatEntry)].join("\n"); +} + async function logsAction( ctx: CLIContext, options: LogsOptions, ): Promise { validateLimit(options.limit); + + if (options.follow) { + if (options.until) { + throw new InvalidInputError( + "--until cannot be combined with --follow (a stream has no end).", + ); + } + if (options.order) { + throw new InvalidInputError( + "--order cannot be combined with --follow (a live tail always streams oldest to newest).", + ); + } + } + + if (options.deployment) { + return deploymentLogsAction(ctx, options.deployment, options); + } + const specifiedFunctions = parseFunctionNames(options.function); const localProjectRoot = ctx.app?.projectRoot; @@ -282,16 +394,6 @@ async function logsAction( } if (options.follow) { - if (options.until) { - throw new InvalidInputError( - "--until cannot be combined with --follow (a stream has no end).", - ); - } - if (options.order) { - throw new InvalidInputError( - "--order cannot be combined with --follow (a live tail always streams oldest to newest).", - ); - } options.order = "asc"; // tail reads oldest -> newest return followLogs( functionNames, @@ -327,11 +429,15 @@ async function logsAction( export function getLogsCommand(): Command { return new Base44Command("logs") - .description("Fetch function logs for this app") + .description("Fetch function or full-stack deployment logs for this app") .option( "--function ", "Filter by function name(s), comma-separated. If omitted, fetches logs for all project functions", ) + .option( + "--deployment ", + "Fetch full-stack deployment logs instead of function logs. Pass a deployment ID or 'production' for the current production deployment", + ) .option( "--since ", "Show logs from this time. ISO datetime or relative shorthand (e.g. 1h, 30m, 2d)", diff --git a/packages/cli/src/cli/commands/slug/index.ts b/packages/cli/src/cli/commands/slug/index.ts new file mode 100644 index 00000000..00187a18 --- /dev/null +++ b/packages/cli/src/cli/commands/slug/index.ts @@ -0,0 +1,14 @@ +import type { Command } from "commander"; +import { Base44Command } from "@/cli/utils/index.js"; +import { getSlugResetCommand } from "./reset.js"; +import { getSlugSetCommand } from "./set.js"; +import { showSlugAction } from "./show.js"; + +export function getSlugCommand(): Command { + return new Base44Command("slug") + .description("Show or change the app's URL slug (its public subdomain)") + .allowExcessArguments(false) + .action(showSlugAction) + .addCommand(getSlugSetCommand()) + .addCommand(getSlugResetCommand()); +} diff --git a/packages/cli/src/cli/commands/slug/reset.ts b/packages/cli/src/cli/commands/slug/reset.ts new file mode 100644 index 00000000..c69e21b7 --- /dev/null +++ b/packages/cli/src/cli/commands/slug/reset.ts @@ -0,0 +1,40 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; +import { getSiteUrl } from "@/core/project/index.js"; +import { updateSlug } from "@/core/slug/index.js"; +import { logAppUrl, toJsonStdout } from "./shared.js"; + +async function resetSlugAction({ + log, + runTask, + jsonMode, +}: CLIContext): Promise { + const result = await runTask( + "Resetting slug...", + async () => { + const updated = await updateSlug(null); + return { slug: updated.slug, url: await getSiteUrl() }; + }, + { errorMessage: "Failed to reset slug" }, + ); + + if (jsonMode) { + return { + outroMessage: `Slug reset to ${result.slug}`, + stdout: toJsonStdout(result), + }; + } + + log.message( + `${theme.styles.header("Slug")}: ${theme.styles.bold(result.slug ?? "")}`, + ); + logAppUrl(result.url, log); + return { outroMessage: `Slug reset to ${result.slug}` }; +} + +export function getSlugResetCommand(): Command { + return new Base44Command("reset") + .description("Reset the slug to an auto-generated one") + .action(resetSlugAction); +} diff --git a/packages/cli/src/cli/commands/slug/set.ts b/packages/cli/src/cli/commands/slug/set.ts new file mode 100644 index 00000000..eb3c5a5c --- /dev/null +++ b/packages/cli/src/cli/commands/slug/set.ts @@ -0,0 +1,44 @@ +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; +import { getSiteUrl } from "@/core/project/index.js"; +import { getSlug, updateSlug } from "@/core/slug/index.js"; +import { logAppUrl, toJsonStdout } from "./shared.js"; + +async function setSlugAction( + { log, runTask, jsonMode }: CLIContext, + slug: string, +): Promise { + const result = await runTask( + `Setting slug to ${slug}...`, + async () => { + const { slug: previousSlug } = await getSlug(); + const updated = await updateSlug(slug); + return { previousSlug, slug: updated.slug, url: await getSiteUrl() }; + }, + { errorMessage: "Failed to update slug" }, + ); + + if (jsonMode) { + return { + outroMessage: `Slug set to ${result.slug}`, + stdout: toJsonStdout(result), + }; + } + + log.message( + `${theme.styles.header("Slug")}: ${result.previousSlug ?? "(none)"} ${theme.styles.dim("→")} ${theme.styles.bold(result.slug ?? "")}`, + ); + logAppUrl(result.url, log); + return { outroMessage: `Slug set to ${result.slug}` }; +} + +export function getSlugSetCommand(): Command { + return new Base44Command("set") + .description("Set a custom slug for this app") + .argument( + "", + "New slug, e.g. my-app (3-50 chars: lowercase letters, numbers, hyphens)", + ) + .action(setSlugAction); +} diff --git a/packages/cli/src/cli/commands/slug/shared.ts b/packages/cli/src/cli/commands/slug/shared.ts new file mode 100644 index 00000000..11ed51a4 --- /dev/null +++ b/packages/cli/src/cli/commands/slug/shared.ts @@ -0,0 +1,11 @@ +import type { Logger } from "@base44-cli/logger"; +import { theme } from "@/cli/utils/index.js"; + +export function toJsonStdout(result: unknown): string { + return `${JSON.stringify(result, null, 2)}\n`; +} + +/** "URL: https://my-app.base44.app" — the slug-derived production URL line. */ +export function logAppUrl(url: string, log: Logger): void { + log.message(`${theme.styles.header("URL")}: ${theme.colors.links(url)}`); +} diff --git a/packages/cli/src/cli/commands/slug/show.ts b/packages/cli/src/cli/commands/slug/show.ts new file mode 100644 index 00000000..debc4ed4 --- /dev/null +++ b/packages/cli/src/cli/commands/slug/show.ts @@ -0,0 +1,40 @@ +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { theme } from "@/cli/utils/index.js"; +import { getSiteUrl } from "@/core/project/index.js"; +import { getSlug } from "@/core/slug/index.js"; +import { logAppUrl, toJsonStdout } from "./shared.js"; + +export async function showSlugAction({ + log, + runTask, + jsonMode, +}: CLIContext): Promise { + const { slug, url } = await runTask( + "Fetching slug...", + async () => { + const { slug } = await getSlug(); + return { slug, url: slug ? await getSiteUrl() : null }; + }, + { errorMessage: "Failed to fetch slug" }, + ); + + if (jsonMode) { + return { + outroMessage: slug ? `Slug: ${slug}` : "This app has no slug yet", + stdout: toJsonStdout({ slug, url }), + }; + } + + if (!slug) { + return { + outroMessage: + "This app has no slug yet — set one with 'base44 slug set '", + }; + } + + log.message(`${theme.styles.header("Slug")}: ${theme.styles.bold(slug)}`); + if (url) { + logAppUrl(url, log); + } + return { outroMessage: `Slug: ${slug}` }; +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index cfa1a45b..45ca3049 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -6,6 +6,10 @@ import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; +import { getDeploymentsCommand } from "@/cli/commands/deployments/index.js"; +import { getPromoteCommand } from "@/cli/commands/deployments/promote.js"; +import { getRollbackCommand } from "@/cli/commands/deployments/rollback.js"; +import { getDomainsCommand } from "@/cli/commands/domains/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; import { getFunctionsCommand } from "@/cli/commands/functions/index.js"; import { getCreateCommand } from "@/cli/commands/project/create.js"; @@ -16,6 +20,7 @@ import { getScaffoldCommand } from "@/cli/commands/project/scaffold.js"; import { getSandboxCommand } from "@/cli/commands/sandbox/index.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; +import { getSlugCommand } from "@/cli/commands/slug/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; import { Base44Command } from "@/cli/utils/index.js"; import { BASE44_APP_ID_ENV_VAR } from "@/core/consts.js"; @@ -82,6 +87,17 @@ export function createProgram(context: CLIContext): Command { // Register functions commands program.addCommand(getFunctionsCommand()); + // Register full-stack deployment commands + program.addCommand(getDeploymentsCommand()); + program.addCommand(getPromoteCommand()); + program.addCommand(getRollbackCommand()); + + // Register custom domain commands + program.addCommand(getDomainsCommand()); + + // Register slug commands + program.addCommand(getSlugCommand()); + // Register secrets commands program.addCommand(getSecretsCommand()); diff --git a/packages/cli/src/core/deployments/api.ts b/packages/cli/src/core/deployments/api.ts new file mode 100644 index 00000000..490f49de --- /dev/null +++ b/packages/cli/src/core/deployments/api.ts @@ -0,0 +1,236 @@ +import { readFile } from "node:fs/promises"; +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import type { + CreateDeploymentRequest, + CreateDeploymentResponse, + Deployment, + DeploymentLogFilters, + DeploymentLogsResponse, + FinalizeDeploymentResponse, + ModuleType, + PromoteResponse, + WorkerModule, +} from "./schema.js"; +import { + CreateDeploymentResponseSchema, + DeploymentLogsResponseSchema, + DeploymentsListResponseSchema, + FinalizeDeploymentResponseSchema, + GetDeploymentResponseSchema, + PromoteResponseSchema, +} from "./schema.js"; + +const MODULE_CONTENT_TYPES: Record = { + esm: "application/javascript+module", + sourcemap: "application/source-map", + wasm: "application/wasm", + text: "text/plain", + data: "application/octet-stream", +}; + +export async function createDeployment( + request: CreateDeploymentRequest, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post("deployments", { + json: request, + timeout: 120_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "creating deployment"); + } + + const result = CreateDeploymentResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +export async function finalizeDeployment( + deploymentId: string, + completionJwt: string | null, + modules: WorkerModule[], +): Promise { + const appClient = getAppClient(); + + const formData = new FormData(); + formData.append("payload", JSON.stringify({ completion_jwt: completionJwt })); + + for (const module of modules) { + const content = await readFile(module.absolutePath); + formData.append( + module.name, + new File([new Uint8Array(content)], module.name, { + type: MODULE_CONTENT_TYPES[module.type], + }), + ); + } + + let response: KyResponse; + try { + response = await appClient.post( + `deployments/${encodeURIComponent(deploymentId)}/finalize`, + { body: formData, timeout: 180_000 }, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "finalizing deployment"); + } + + const result = FinalizeDeploymentResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +export async function listDeployments(limit = 20): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.get("deployments", { + searchParams: { limit: String(limit) }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "listing deployments"); + } + + const result = DeploymentsListResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +export async function getDeployment(deploymentId: string): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.get( + `deployments/${encodeURIComponent(deploymentId)}`, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching deployment"); + } + + const result = GetDeploymentResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +export async function promoteDeployment( + deploymentId: string, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post( + `deployments/${encodeURIComponent(deploymentId)}/promote`, + { timeout: 120_000 }, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "promoting deployment"); + } + + const result = PromoteResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +export async function rollbackDeployment(): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post("deployments/rollback", { + timeout: 120_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "rolling back deployment"); + } + + const result = PromoteResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +function buildLogsSearchParams(filters: DeploymentLogFilters): URLSearchParams { + const params = new URLSearchParams(); + if (filters.since) params.set("since", filters.since); + if (filters.until) params.set("until", filters.until); + if (filters.limit !== undefined) params.set("limit", String(filters.limit)); + if (filters.order) params.set("order", filters.order); + return params; +} + +/** + * Fetch runtime logs for a deployment. `target` is either a deployment id or + * the alias "production" (routed to the alias endpoint). + */ +export async function fetchDeploymentLogs( + target: string, + filters: DeploymentLogFilters = {}, +): Promise { + const appClient = getAppClient(); + const searchParams = buildLogsSearchParams(filters); + + let path: string; + if (target === "production") { + searchParams.set("alias", "production"); + path = "deployments/logs"; + } else { + path = `deployments/${encodeURIComponent(target)}/logs`; + } + + let response: KyResponse; + try { + response = await appClient.get(path, { searchParams }); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching deployment logs"); + } + + const result = DeploymentLogsResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid deployment logs response from server", + result.error, + ); + } + return result.data; +} diff --git a/packages/cli/src/core/deployments/deploy.ts b/packages/cli/src/core/deployments/deploy.ts new file mode 100644 index 00000000..be411dac --- /dev/null +++ b/packages/cli/src/core/deployments/deploy.ts @@ -0,0 +1,151 @@ +import { join } from "node:path"; +import { getAppContext } from "@/core/project/app-config.js"; +import { pathExists, readJsonFile } from "@/core/utils/fs.js"; +import { createDeployment, finalizeDeployment } from "./api.js"; +import { buildAssetManifest } from "./manifest.js"; +import { collectModules } from "./modules.js"; +import type { + AssetManifestResult, + DeploymentTarget, + DeploymentUrl, +} from "./schema.js"; +import type { AssetUploadProgress } from "./upload.js"; +import { uploadAssetBuckets } from "./upload.js"; +import { resolveWranglerConfig } from "./wrangler-config.js"; + +interface FullStackDeployProgress { + /** Fired for non-fatal issues worth surfacing to the user. */ + onWarning?: (message: string) => void; + /** Fired after the deployment is created: total assets and how many need uploading. */ + onAssets?: (info: { totalAssets: number; newAssets: number }) => void; + /** Fired after each bucket upload completes. */ + onAssetUpload?: (progress: AssetUploadProgress) => void; + /** Fired before the worker modules are uploaded (finalize). */ + onWorker?: (info: { moduleCount: number }) => void; +} + +export interface FullStackDeployResult { + deploymentId: string; + target: DeploymentTarget; + urls: DeploymentUrl[]; +} + +/** Known framework markers in package.json dependencies. */ +const FRAMEWORK_MARKERS: [string, string][] = [ + ["@react-router/dev", "react-router"], + ["react-router", "react-router"], + ["@tanstack/react-start", "tanstack-start"], + ["astro", "astro"], + ["vinext", "vinext"], +]; + +async function detectFramework(projectRoot: string): Promise { + const packageJsonPath = join(projectRoot, "package.json"); + if (!(await pathExists(packageJsonPath))) return null; + + let parsed: unknown; + try { + parsed = await readJsonFile(packageJsonPath); + } catch { + return null; + } + + const pkg = parsed as { + dependencies?: Record; + devDependencies?: Record; + }; + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + for (const [marker, framework] of FRAMEWORK_MARKERS) { + if (deps[marker]) return framework; + } + return null; +} + +/** + * Deploy a full-stack (Cloudflare Workers) build artifact: + * resolve the wrangler config, collect worker modules and static assets, + * create the deployment, upload the requested asset buckets, then finalize + * with the worker modules. + */ +export async function deployFullStack(options: { + projectRoot: string; + target: DeploymentTarget; + progress?: FullStackDeployProgress; +}): Promise { + const { projectRoot, target, progress } = options; + + const config = await resolveWranglerConfig(projectRoot); + + // Some frameworks (e.g. Astro 6) emit a wrangler.json without any + // compatibility flags; server code using Node built-ins would then fail at + // runtime. Warn instead of injecting the flag — the config is generated, so + // the fix belongs in the framework/adapter settings. + if (!config.compatibilityFlags.includes("nodejs_compat")) { + progress?.onWarning?.( + "The wrangler config has no 'nodejs_compat' compatibility flag; Node.js built-ins will be unavailable at runtime. Enable it in your framework's Cloudflare adapter settings if your server code needs Node APIs.", + ); + } + + const modules = await collectModules(config); + + let assets: AssetManifestResult = { manifest: {}, filesByHash: new Map() }; + if (config.assetsDirectory && (await pathExists(config.assetsDirectory))) { + assets = await buildAssetManifest( + config.assetsDirectory, + getAppContext().id, + ); + } + + const created = await createDeployment({ + target, + framework: await detectFramework(projectRoot), + config: { + main: config.main, + compatibility_date: config.compatibilityDate, + compatibility_flags: config.compatibilityFlags, + assets_config: config.assetsConfig + ? { + html_handling: config.assetsConfig.htmlHandling, + not_found_handling: config.assetsConfig.notFoundHandling, + run_worker_first: config.assetsConfig.runWorkerFirst, + headers: config.assetsConfig.headers, + redirects: config.assetsConfig.redirects, + } + : null, + vars: config.vars, + }, + modules: modules.map(({ name, size, type }) => ({ name, size, type })), + asset_manifest: assets.manifest, + }); + + const totalAssets = Object.keys(assets.manifest).length; + const newAssets = created.assetUpload + ? new Set(created.assetUpload.buckets.flat()).size + : 0; + progress?.onAssets?.({ totalAssets, newAssets }); + + // No asset_upload means the deployment has no assets at all; empty buckets + // mean everything is already stored and the session JWT is the completion + // token as-is. + let completionJwt: string | null = null; + if (created.assetUpload) { + completionJwt = await uploadAssetBuckets( + created.assetUpload, + assets.filesByHash, + progress?.onAssetUpload, + ); + } + + progress?.onWorker?.({ moduleCount: modules.length }); + const finalized = await finalizeDeployment( + created.deploymentId, + completionJwt, + modules, + ); + + return { + deploymentId: finalized.deploymentId, + target, + urls: finalized.urls, + }; +} diff --git a/packages/cli/src/core/deployments/index.ts b/packages/cli/src/core/deployments/index.ts new file mode 100644 index 00000000..94aad04c --- /dev/null +++ b/packages/cli/src/core/deployments/index.ts @@ -0,0 +1,7 @@ +export * from "./api.js"; +export * from "./deploy.js"; +export * from "./manifest.js"; +export * from "./modules.js"; +export * from "./schema.js"; +export * from "./upload.js"; +export * from "./wrangler-config.js"; diff --git a/packages/cli/src/core/deployments/manifest.ts b/packages/cli/src/core/deployments/manifest.ts new file mode 100644 index 00000000..06ed0745 --- /dev/null +++ b/packages/cli/src/core/deployments/manifest.ts @@ -0,0 +1,225 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { InvalidInputError } from "@/core/errors.js"; +import { pathExists, readTextFile } from "@/core/utils/fs.js"; +import type { + AssetFile, + AssetManifestEntry, + AssetManifestResult, +} from "./schema.js"; + +const MAX_ASSET_SIZE_BYTES = 25 * 1024 * 1024; // 25 MiB +const MAX_ASSET_COUNT = 100_000; + +const ASSETS_IGNORE_FILE = ".assetsignore"; + +/** Files never uploaded as assets, regardless of .assetsignore. */ +const ALWAYS_SKIPPED_FILES = new Set([ + ASSETS_IGNORE_FILE, + "wrangler.json", + ".dev.vars", +]); + +const MIME_TYPES: Record = { + ".html": "text/html", + ".htm": "text/html", + ".css": "text/css", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".json": "application/json", + ".map": "application/json", + ".txt": "text/plain", + ".xml": "application/xml", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".avif": "image/avif", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".eot": "application/vnd.ms-fontobject", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".pdf": "application/pdf", + ".wasm": "application/wasm", + ".webmanifest": "application/manifest+json", +}; + +function getMimeType(filePath: string): string { + return ( + MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream" + ); +} + +/** + * Content-addressed asset hash: first 32 hex chars of + * sha256(utf8(app_id) || raw file bytes). Salting with the app id means a + * tenant can only produce hash collisions with their own files, so a + * malicious upload cannot poison another app's asset cache. + */ +export function hashAsset(appId: string, content: Buffer): string { + return createHash("sha256") + .update(Buffer.from(appId, "utf8")) + .update(content) + .digest("hex") + .slice(0, 32); +} + +type IgnoreMatcher = (relativePath: string, isDirectory: boolean) => boolean; + +function globToRegExp(glob: string): RegExp { + let source = ""; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]; + if (char === "*") { + if (glob[i + 1] === "*") { + source += ".*"; + i++; + } else { + source += "[^/]*"; + } + } else if (char === "?") { + source += "[^/]"; + } else { + source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + } + } + return new RegExp(`^${source}$`); +} + +/** + * Minimal gitignore-style matcher for .assetsignore. Supports exact names, + * `*`/`**` globs, directory patterns (trailing `/`), and root-anchored + * patterns (containing `/`). Negation (`!`) is not supported. + */ +function createIgnoreMatcher(lines: string[]): IgnoreMatcher { + const rules = lines + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")) + .map((line) => { + const isDirOnly = line.endsWith("/"); + let pattern = isDirOnly ? line.slice(0, -1) : line; + const anchored = pattern.includes("/"); + pattern = pattern.replace(/^\//, ""); + return { regex: globToRegExp(pattern), anchored, isDirOnly }; + }); + + return (relativePath, isDirectory) => { + const segments = relativePath.split("/"); + return rules.some((rule) => { + if (rule.anchored) { + if (rule.isDirOnly ? isDirectory : true) { + if (rule.regex.test(relativePath)) return true; + } + // A directory pattern also ignores everything under the directory; + // matching directories are pruned during the walk. + return false; + } + // Unanchored: match the basename (and for dir-only rules, any segment — + // but directories are pruned during the walk, so files only need their + // own basename checked). + const basename = segments[segments.length - 1]; + if (rule.isDirOnly && !isDirectory) return false; + return rule.regex.test(basename); + }); + }; +} + +async function loadIgnoreMatcher(assetsDir: string): Promise { + const ignorePath = join(assetsDir, ASSETS_IGNORE_FILE); + if (!(await pathExists(ignorePath))) { + return () => false; + } + const content = await readTextFile(ignorePath); + return createIgnoreMatcher(content.split(/\r?\n/)); +} + +/** + * Walk the assets directory and build the deployment asset manifest. + * Honors `.assetsignore` at the assets root, always skips `.assetsignore`, + * `wrangler.json`, and `.dev.vars`, rejects files larger than 25 MiB, and + * caps the total file count at 100,000. + */ +export async function buildAssetManifest( + assetsDir: string, + appId: string, +): Promise { + const isIgnored = await loadIgnoreMatcher(assetsDir); + const manifest: Record = {}; + const filesByHash = new Map(); + + const relativeFilePaths = await collectFilePaths(assetsDir, "", isIgnored); + + if (relativeFilePaths.length > MAX_ASSET_COUNT) { + throw new InvalidInputError( + `Too many static assets: found ${relativeFilePaths.length}, the limit is ${MAX_ASSET_COUNT} files.`, + ); + } + + for (const relativePath of relativeFilePaths.sort()) { + const absolutePath = join(assetsDir, ...relativePath.split("/")); + const { size } = await stat(absolutePath); + if (size > MAX_ASSET_SIZE_BYTES) { + throw new InvalidInputError( + `Static asset "${relativePath}" is ${size} bytes, which exceeds the 25 MiB per-file limit.`, + ); + } + + const content = await readFile(absolutePath); + const hash = hashAsset(appId, content); + + manifest[`/${relativePath}`] = { hash, size }; + if (!filesByHash.has(hash)) { + filesByHash.set(hash, { + absolutePath, + hash, + size, + contentType: getMimeType(absolutePath), + }); + } + } + + return { manifest, filesByHash }; +} + +async function collectFilePaths( + dir: string, + relativeDir: string, + isIgnored: IgnoreMatcher, +): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const results: string[] = []; + + for (const entry of entries) { + const relativePath = relativeDir + ? `${relativeDir}/${entry.name}` + : entry.name; + + if (entry.isDirectory()) { + if (isIgnored(relativePath, true)) continue; + results.push( + ...(await collectFilePaths( + join(dir, entry.name), + relativePath, + isIgnored, + )), + ); + continue; + } + + if (!entry.isFile()) continue; + if (ALWAYS_SKIPPED_FILES.has(entry.name)) continue; + if (isIgnored(relativePath, false)) continue; + + results.push(relativePath); + } + + return results; +} diff --git a/packages/cli/src/core/deployments/modules.ts b/packages/cli/src/core/deployments/modules.ts new file mode 100644 index 00000000..d7e4c2ae --- /dev/null +++ b/packages/cli/src/core/deployments/modules.ts @@ -0,0 +1,138 @@ +import { stat } from "node:fs/promises"; +import { relative, resolve, sep } from "node:path"; +import { globby } from "globby"; +import { InvalidInputError } from "@/core/errors.js"; +import { pathExists } from "@/core/utils/fs.js"; +import type { ModuleType, WorkerModule } from "./schema.js"; +import type { ResolvedWranglerConfig } from "./wrangler-config.js"; + +const MAX_TOTAL_MODULE_BYTES = 40 * 1024 * 1024; // 40 MB + +/** Files never collected as worker modules. */ +const MODULE_IGNORE = ["wrangler.json", ".dev.vars"]; + +/** Wrangler rule type → deployments API module type. */ +const RULE_TYPE_TO_MODULE_TYPE: Record = { + ESModule: "esm", + CompiledWasm: "wasm", + Text: "text", + Data: "data", +}; + +function toPosix(path: string): string { + return path.split(sep).join("/"); +} + +/** + * Collect the worker modules for an unbundled (no_bundle) build: the entry + * module plus every file under the config dir matching the config's rules + * globs, preserving relative paths as module names. `.map` files next to + * collected modules (or all of them when `upload_source_maps` is set) are + * included as sourcemaps. Total payload is capped at 40 MB. + */ +export async function collectModules( + config: ResolvedWranglerConfig, +): Promise { + const entryPath = resolve(config.configDir, config.main); + if (!(await pathExists(entryPath))) { + throw new InvalidInputError( + `Worker entry module does not exist: ${entryPath} (from "main" in ${config.configPath})`, + { + hints: [{ message: "Rebuild the project to regenerate the artifact" }], + }, + ); + } + + const modulesByName = new Map(); + const entryName = toPosix(relative(config.configDir, entryPath)); + modulesByName.set(entryName, { + name: entryName, + absolutePath: entryPath, + size: 0, + type: "esm", + }); + + const ignore = [...MODULE_IGNORE]; + if (config.assetsDirectory?.startsWith(config.configDir + sep)) { + ignore.push( + `${toPosix(relative(config.configDir, config.assetsDirectory))}/**`, + ); + } + + for (const rule of config.rules) { + const type = RULE_TYPE_TO_MODULE_TYPE[rule.type]; + if (!type) { + throw new InvalidInputError( + `Unsupported module rule type "${rule.type}" in ${config.configPath}. Supported: ${Object.keys(RULE_TYPE_TO_MODULE_TYPE).join(", ")}.`, + ); + } + + const matches = await globby(rule.globs, { + cwd: config.configDir, + onlyFiles: true, + dot: true, + ignore, + }); + + for (const match of matches.sort()) { + if (!modulesByName.has(match)) { + modulesByName.set(match, { + name: match, + absolutePath: resolve(config.configDir, match), + size: 0, + type, + }); + } + } + } + + // Source maps: all of them when upload_source_maps is set, otherwise only + // the ones sitting next to a collected module. + if (config.uploadSourceMaps) { + const maps = await globby("**/*.map", { + cwd: config.configDir, + onlyFiles: true, + dot: true, + ignore, + }); + for (const map of maps.sort()) { + addSourcemap(modulesByName, config.configDir, map); + } + } else { + for (const name of [...modulesByName.keys()]) { + const mapName = `${name}.map`; + if (await pathExists(resolve(config.configDir, mapName))) { + addSourcemap(modulesByName, config.configDir, mapName); + } + } + } + + const modules = [...modulesByName.values()]; + let totalBytes = 0; + for (const module of modules) { + module.size = (await stat(module.absolutePath)).size; + totalBytes += module.size; + } + + if (totalBytes > MAX_TOTAL_MODULE_BYTES) { + throw new InvalidInputError( + `Worker modules total ${totalBytes} bytes, which exceeds the 40 MB limit for Base44 full-stack deploys.`, + ); + } + + return modules; +} + +function addSourcemap( + modulesByName: Map, + configDir: string, + name: string, +): void { + if (modulesByName.has(name)) return; + modulesByName.set(name, { + name, + absolutePath: resolve(configDir, name), + size: 0, + type: "sourcemap", + }); +} diff --git a/packages/cli/src/core/deployments/schema.ts b/packages/cli/src/core/deployments/schema.ts new file mode 100644 index 00000000..cd94d515 --- /dev/null +++ b/packages/cli/src/core/deployments/schema.ts @@ -0,0 +1,187 @@ +import { z } from "zod"; +import { FunctionLogsResponseSchema } from "@/core/resources/function/schema.js"; + +// ─── SHARED ────────────────────────────────────────────────── + +export const DeploymentTargetSchema = z.enum(["preview", "production"]); + +export type DeploymentTarget = z.infer; + +const DeploymentUrlSchema = z.object({ + url: z.string(), + kind: z.enum(["immutable", "production"]), +}); + +export type DeploymentUrl = z.infer; + +/** Worker module types accepted by the deployments API. */ +export type ModuleType = "esm" | "sourcemap" | "wasm" | "text" | "data"; + +/** A collected worker module (bytes are read lazily at finalize time). */ +export interface WorkerModule { + /** Module name: path relative to the wrangler config dir (forward slashes). */ + name: string; + /** Absolute path on disk. */ + absolutePath: string; + size: number; + type: ModuleType; +} + +/** Manifest entry keyed by URL-ish path ("/index.html"). */ +export interface AssetManifestEntry { + hash: string; + size: number; +} + +/** A static asset discovered in the assets directory, keyed by hash. */ +export interface AssetFile { + /** Absolute path on disk. */ + absolutePath: string; + hash: string; + size: number; + contentType: string; +} + +export interface AssetManifestResult { + /** URL path → { hash, size }, ready for the create-deployment payload. */ + manifest: Record; + /** Hash → file info, used to serve upload buckets. */ + filesByHash: Map; +} + +/** Request payload for POST deployments (sent as snake_case JSON). */ +export interface CreateDeploymentRequest { + target: DeploymentTarget; + framework: string | null; + config: { + main: string; + compatibility_date: string | null; + compatibility_flags: string[]; + assets_config: { + html_handling?: string; + not_found_handling?: string; + run_worker_first?: boolean | string[]; + headers?: string; + redirects?: string; + } | null; + vars: Record; + }; + modules: { name: string; size: number; type: ModuleType }[]; + asset_manifest: Record; +} + +// ─── RESPONSES ─────────────────────────────────────────────── + +const AssetUploadSessionSchema = z + .object({ + jwt: z.string(), + buckets: z.array(z.array(z.string())), + upload_url: z.string(), + }) + .transform((data) => ({ + jwt: data.jwt, + buckets: data.buckets, + uploadUrl: data.upload_url, + })); + +export type AssetUploadSession = z.infer; + +export const CreateDeploymentResponseSchema = z + .object({ + deployment_id: z.string(), + script_name: z.string(), + immutable_url: z.string(), + asset_upload: AssetUploadSessionSchema.nullable(), + }) + .transform((data) => ({ + deploymentId: data.deployment_id, + scriptName: data.script_name, + immutableUrl: data.immutable_url, + assetUpload: data.asset_upload, + })); + +export type CreateDeploymentResponse = z.infer< + typeof CreateDeploymentResponseSchema +>; + +/** + * Response of a bucket upload request. Only the final response (HTTP 201) + * carries the completion token, so everything is optional here. + */ +export const AssetUploadResponseSchema = z.looseObject({ + result: z + .looseObject({ jwt: z.string().nullable().optional() }) + .nullable() + .optional(), +}); + +export const FinalizeDeploymentResponseSchema = z + .object({ + status: z.string(), + deployment_id: z.string(), + urls: z.array(DeploymentUrlSchema), + }) + .transform((data) => ({ + status: data.status, + deploymentId: data.deployment_id, + urls: data.urls, + })); + +export type FinalizeDeploymentResponse = z.infer< + typeof FinalizeDeploymentResponseSchema +>; + +const DeploymentSchema = z + .object({ + deployment_id: z.string(), + status: z.enum(["ready", "failed", "in_progress"]), + target: DeploymentTargetSchema, + created_at: z.string(), + urls: z.array(DeploymentUrlSchema), + framework: z.string().nullable().optional(), + }) + .transform((data) => ({ + deploymentId: data.deployment_id, + status: data.status, + target: data.target, + createdAt: data.created_at, + urls: data.urls, + framework: data.framework ?? null, + })); + +export type Deployment = z.infer; + +export const DeploymentsListResponseSchema = z + .object({ deployments: z.array(DeploymentSchema) }) + .transform((data) => data.deployments); + +export const GetDeploymentResponseSchema = DeploymentSchema; + +export const PromoteResponseSchema = z + .object({ + deployment_id: z.string(), + urls: z.array(DeploymentUrlSchema), + }) + .transform((data) => ({ + deploymentId: data.deployment_id, + urls: data.urls, + })); + +/** Rollback returns the same shape as promote. */ +export type PromoteResponse = z.infer; + +// Same row shape as function logs ({ time, level, message }). +export const DeploymentLogsResponseSchema = z + .object({ logs: FunctionLogsResponseSchema }) + .transform((data) => data.logs); + +export type DeploymentLogsResponse = z.infer< + typeof DeploymentLogsResponseSchema +>; + +export interface DeploymentLogFilters { + since?: string; + until?: string; + limit?: number; + order?: "asc" | "desc"; +} diff --git a/packages/cli/src/core/deployments/upload.ts b/packages/cli/src/core/deployments/upload.ts new file mode 100644 index 00000000..06f2a210 --- /dev/null +++ b/packages/cli/src/core/deployments/upload.ts @@ -0,0 +1,139 @@ +import { readFile } from "node:fs/promises"; +import type { KyResponse } from "ky"; +import ky, { HTTPError } from "ky"; +import { ApiError, InternalError } from "@/core/errors.js"; +import type { AssetFile, AssetUploadSession } from "./schema.js"; +import { AssetUploadResponseSchema } from "./schema.js"; + +const BUCKET_CONCURRENCY = 3; +const MAX_ATTEMPTS_PER_BUCKET = 3; +const RETRY_BASE_DELAY_MS = 500; + +export interface AssetUploadProgress { + uploadedFiles: number; + totalFiles: number; + completedBuckets: number; + totalBuckets: number; +} + +/** + * Upload asset buckets directly to the upload URL returned by the + * create-deployment call. Buckets are uploaded with concurrency 3; each + * bucket is retried up to 3 times with exponential backoff. The final + * response (HTTP 201) carries the completion JWT required to finalize. + * When `buckets` is empty the session JWT is already the completion token. + */ +export async function uploadAssetBuckets( + session: AssetUploadSession, + filesByHash: Map, + onProgress?: (progress: AssetUploadProgress) => void, +): Promise { + const buckets = session.buckets; + if (buckets.length === 0) { + return session.jwt; + } + + const totalFiles = buckets.reduce((sum, bucket) => sum + bucket.length, 0); + let uploadedFiles = 0; + let completedBuckets = 0; + let completionJwt: string | null = null; + + let nextBucket = 0; + const worker = async (): Promise => { + while (nextBucket < buckets.length) { + const bucket = buckets[nextBucket++]; + const jwt = await uploadBucketWithRetry(session, bucket, filesByHash); + if (jwt) { + completionJwt = jwt; + } + uploadedFiles += bucket.length; + completedBuckets++; + onProgress?.({ + uploadedFiles, + totalFiles, + completedBuckets, + totalBuckets: buckets.length, + }); + } + }; + + await Promise.all( + Array.from( + { length: Math.min(BUCKET_CONCURRENCY, buckets.length) }, + worker, + ), + ); + + if (!completionJwt) { + throw new ApiError( + "Asset upload finished but the server did not return a completion token.", + ); + } + + return completionJwt; +} + +async function uploadBucketWithRetry( + session: AssetUploadSession, + bucket: string[], + filesByHash: Map, +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_BUCKET; attempt++) { + if (attempt > 0) { + await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1)); + } + try { + return await uploadBucket(session, bucket, filesByHash); + } catch (error) { + lastError = error; + } + } + + if (lastError instanceof HTTPError && lastError.response.status === 401) { + throw new ApiError( + "Asset upload session expired — rerun deploy. Already-uploaded assets are skipped on the next attempt.", + { statusCode: 401, cause: lastError }, + ); + } + throw await ApiError.fromHttpError(lastError, "uploading static assets"); +} + +async function uploadBucket( + session: AssetUploadSession, + bucket: string[], + filesByHash: Map, +): Promise { + const formData = new FormData(); + + for (const hash of bucket) { + const file = filesByHash.get(hash); + if (!file) { + throw new InternalError( + `Server requested upload of unknown asset hash: ${hash}`, + ); + } + const content = await readFile(file.absolutePath); + formData.append( + hash, + new File([content.toString("base64")], hash, { type: file.contentType }), + ); + } + + const response: KyResponse = await ky.post(session.uploadUrl, { + searchParams: { base64: "true" }, + headers: { Authorization: `Bearer ${session.jwt}` }, + body: formData, + timeout: 120_000, + retry: 0, + }); + + const parsed = AssetUploadResponseSchema.safeParse(await response.json()); + const jwt = parsed.success ? parsed.data.result?.jwt : null; + return jwt || null; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/cli/src/core/deployments/wrangler-config.ts b/packages/cli/src/core/deployments/wrangler-config.ts new file mode 100644 index 00000000..1daa0655 --- /dev/null +++ b/packages/cli/src/core/deployments/wrangler-config.ts @@ -0,0 +1,263 @@ +import { dirname, join, resolve } from "node:path"; +import { z } from "zod"; +import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js"; +import { pathExists, readJsonFile } from "@/core/utils/fs.js"; + +/** Redirect file emitted by @cloudflare/vite-plugin builds, at project root. */ +const WRANGLER_REDIRECT_PATH = join(".wrangler", "deploy", "config.json"); + +// Loose: extra fields emitted by framework adapters (auxiliaryWorkers, +// Astro 6's prerenderWorkerConfigPath, ...) are ignored for now. +const RedirectConfigSchema = z.looseObject({ + configPath: z.string().min(1), +}); + +const BindingArraySchema = z.array(z.unknown()).optional(); + +const WranglerConfigSchema = z.looseObject({ + name: z.string().optional(), + main: z.string().min(1, "wrangler config is missing a 'main' entry module"), + no_bundle: z.boolean().optional(), + rules: z + .array(z.looseObject({ type: z.string(), globs: z.array(z.string()) })) + .optional(), + assets: z + .looseObject({ + directory: z.string().optional(), + html_handling: z.string().optional(), + not_found_handling: z.string().optional(), + run_worker_first: z.union([z.boolean(), z.array(z.string())]).optional(), + headers: z.string().optional(), + redirects: z.string().optional(), + }) + .optional(), + compatibility_date: z.string().optional(), + compatibility_flags: z.array(z.string()).optional(), + vars: z.record(z.string(), z.unknown()).optional(), + upload_source_maps: z.boolean().optional(), + kv_namespaces: BindingArraySchema, + d1_databases: BindingArraySchema, + r2_buckets: BindingArraySchema, + durable_objects: z.looseObject({ bindings: BindingArraySchema }).optional(), + services: BindingArraySchema, + queues: z + .looseObject({ + producers: BindingArraySchema, + consumers: BindingArraySchema, + }) + .optional(), + hyperdrive: BindingArraySchema, + analytics_engine_datasets: BindingArraySchema, + vectorize: BindingArraySchema, +}); + +type WranglerConfig = z.infer; + +export interface WranglerModuleRule { + type: string; + globs: string[]; +} + +export interface ResolvedAssetsConfig { + htmlHandling?: string; + notFoundHandling?: string; + runWorkerFirst?: boolean | string[]; + headers?: string; + redirects?: string; +} + +export interface ResolvedWranglerConfig { + /** Absolute path of the wrangler.json that was used. */ + configPath: string; + /** Absolute directory containing the wrangler config; module paths are relative to it. */ + configDir: string; + name: string | null; + /** Entry module path, relative to configDir (as written in the config). */ + main: string; + /** Absolute path of the static assets directory, or null when no assets. */ + assetsDirectory: string | null; + assetsConfig: ResolvedAssetsConfig | null; + compatibilityDate: string | null; + compatibilityFlags: string[]; + vars: Record; + rules: WranglerModuleRule[]; + uploadSourceMaps: boolean; +} + +export type FullStackArtifactSource = "redirect" | "root-config" | "toml"; + +export interface FullStackArtifact { + source: FullStackArtifactSource; + /** Absolute path of the redirect file or wrangler config that was found. */ + path: string; +} + +/** + * Detect a full-stack (Cloudflare Workers) build artifact in the project. + * Checks, in order: the vite-plugin redirect file, a hand-authored root + * wrangler.jsonc/wrangler.json, and an unsupported wrangler.toml. + * Returns null when the project has no full-stack artifact. + */ +export async function detectFullStackArtifact( + projectRoot: string, +): Promise { + const redirectPath = join(projectRoot, WRANGLER_REDIRECT_PATH); + if (await pathExists(redirectPath)) { + return { source: "redirect", path: redirectPath }; + } + + for (const name of ["wrangler.jsonc", "wrangler.json"]) { + const configPath = join(projectRoot, name); + if (await pathExists(configPath)) { + return { source: "root-config", path: configPath }; + } + } + + const tomlPath = join(projectRoot, "wrangler.toml"); + if (await pathExists(tomlPath)) { + return { source: "toml", path: tomlPath }; + } + + return null; +} + +/** + * Resolve and validate the wrangler config for a full-stack deploy. + * Throws with a clear message when the artifact is unsupported + * (wrangler.toml, bundling required, or unsupported bindings). + */ +export async function resolveWranglerConfig( + projectRoot: string, +): Promise { + const artifact = await detectFullStackArtifact(projectRoot); + + if (!artifact) { + throw new InvalidInputError( + "No full-stack build artifact found. Expected a .wrangler/deploy/config.json redirect file or a root wrangler.jsonc.", + { + hints: [{ message: "Run your framework's build command first" }], + }, + ); + } + + if (artifact.source === "toml") { + throw new InvalidInputError( + "wrangler.toml is not supported for Base44 full-stack deploys. Convert your config to wrangler.jsonc.", + ); + } + + const configPath = + artifact.source === "redirect" + ? await resolveRedirectedConfigPath(artifact.path) + : artifact.path; + + const parsed = await readJsonFile(configPath); + const result = WranglerConfigSchema.safeParse(parsed); + if (!result.success) { + throw new ConfigInvalidError( + `Invalid wrangler config: ${z.prettifyError(result.error)}`, + configPath, + ); + } + + const config = result.data; + + if (config.no_bundle !== true) { + throw new InvalidInputError( + "This framework's output requires bundling; not yet supported. Base44 full-stack deploys only support pre-bundled Workers output (no_bundle: true).", + ); + } + + assertNoUnsupportedBindings(config, configPath); + + const configDir = dirname(configPath); + const assetsDirectory = config.assets?.directory + ? resolve(configDir, config.assets.directory) + : null; + + return { + configPath, + configDir, + name: config.name ?? null, + main: config.main, + assetsDirectory, + assetsConfig: config.assets ? toResolvedAssetsConfig(config.assets) : null, + compatibilityDate: config.compatibility_date ?? null, + compatibilityFlags: config.compatibility_flags ?? [], + vars: config.vars ?? {}, + rules: (config.rules ?? []).map((rule) => ({ + type: rule.type, + globs: rule.globs, + })), + uploadSourceMaps: config.upload_source_maps ?? false, + }; +} + +async function resolveRedirectedConfigPath( + redirectPath: string, +): Promise { + const parsed = await readJsonFile(redirectPath); + const result = RedirectConfigSchema.safeParse(parsed); + if (!result.success) { + throw new ConfigInvalidError( + `Invalid deploy redirect file: ${z.prettifyError(result.error)}`, + redirectPath, + ); + } + + // configPath is relative to the redirect file's directory (wrangler semantics). + const configPath = resolve(dirname(redirectPath), result.data.configPath); + if (!(await pathExists(configPath))) { + throw new ConfigInvalidError( + `Wrangler config referenced by ${redirectPath} does not exist: ${configPath}`, + redirectPath, + { + hints: [{ message: "Rebuild the project to regenerate the artifact" }], + }, + ); + } + return configPath; +} + +function toResolvedAssetsConfig( + assets: NonNullable, +): ResolvedAssetsConfig { + return { + htmlHandling: assets.html_handling, + notFoundHandling: assets.not_found_handling, + runWorkerFirst: assets.run_worker_first, + headers: assets.headers, + redirects: assets.redirects, + }; +} + +function assertNoUnsupportedBindings( + config: WranglerConfig, + configPath: string, +): void { + const bindingSources: Record = { + kv_namespaces: config.kv_namespaces, + d1_databases: config.d1_databases, + r2_buckets: config.r2_buckets, + "durable_objects.bindings": config.durable_objects?.bindings, + services: config.services, + "queues.producers": config.queues?.producers, + "queues.consumers": config.queues?.consumers, + hyperdrive: config.hyperdrive, + analytics_engine_datasets: config.analytics_engine_datasets, + vectorize: config.vectorize, + }; + + const used = Object.entries(bindingSources) + .filter(([, values]) => (values?.length ?? 0) > 0) + .map(([name]) => name); + + if (used.length > 0) { + throw new InvalidInputError( + `Unsupported bindings for Base44 full-stack deploys: ${used.join(", ")}. Remove them or file a feature request.`, + { + hints: [{ message: `Edit the bindings in ${configPath}` }], + }, + ); + } +} diff --git a/packages/cli/src/core/domains/api.ts b/packages/cli/src/core/domains/api.ts new file mode 100644 index 00000000..4ecc0138 --- /dev/null +++ b/packages/cli/src/core/domains/api.ts @@ -0,0 +1,128 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { + ApiError, + SchemaValidationError, + TimeoutError, +} from "@/core/errors.js"; +import type { + AddDomainRequest, + Domain, + RemoveDomainResponse, +} from "./schema.js"; +import { + AddDomainResponseSchema, + DomainsListResponseSchema, + RemoveDomainResponseSchema, +} from "./schema.js"; + +/** Connect a custom domain to the app (creates the CF custom hostname). */ +export async function addDomain(hostname: string): Promise { + const appClient = getAppClient(); + + const request: AddDomainRequest = { hostname }; + let response: KyResponse; + try { + response = await appClient.post("domains", { + json: request, + timeout: 120_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "connecting domain"); + } + + const result = AddDomainResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** List the custom domains connected to the app, with live status. */ +export async function listDomains(): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.get("domains"); + } catch (error) { + throw await ApiError.fromHttpError(error, "listing domains"); + } + + const result = DomainsListResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** Disconnect a custom domain (deletes the CF custom hostname + route). */ +export async function removeDomain( + hostname: string, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.delete( + `domains/${encodeURIComponent(hostname)}`, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "removing domain"); + } + + const result = RemoveDomainResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +interface WaitForDomainOptions { + /** Poll interval in ms (default 2000). */ + intervalMs?: number; + /** Give up after this many ms (default 10 minutes). */ + timeoutMs?: number; + /** Called with the latest domain state (or undefined) on each poll. */ + onTick?: (domain: Domain | undefined) => void; +} + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Poll `listDomains` until `hostname` is fully active (hostname + SSL), then + * resolve with it. Throws `TimeoutError` if it never activates within the + * budget — typically because the CNAME record hasn't been added yet. + */ +export async function waitForDomainActive( + hostname: string, + options: WaitForDomainOptions = {}, +): Promise { + const intervalMs = options.intervalMs ?? 2_000; + const timeoutMs = options.timeoutMs ?? 10 * 60_000; + const deadline = Date.now() + timeoutMs; + + for (;;) { + const domain = (await listDomains()).find((d) => d.hostname === hostname); + options.onTick?.(domain); + if (domain?.active) { + return domain; + } + if (Date.now() >= deadline) { + throw new TimeoutError( + `Timed out waiting for ${hostname} to become active`, + ); + } + await delay(intervalMs); + } +} diff --git a/packages/cli/src/core/domains/index.ts b/packages/cli/src/core/domains/index.ts new file mode 100644 index 00000000..4ac14404 --- /dev/null +++ b/packages/cli/src/core/domains/index.ts @@ -0,0 +1,2 @@ +export * from "./api.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/domains/schema.ts b/packages/cli/src/core/domains/schema.ts new file mode 100644 index 00000000..b8aff344 --- /dev/null +++ b/packages/cli/src/core/domains/schema.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +// ─── SHARED ────────────────────────────────────────────────── + +/** + * DNS / ownership details Cloudflare returns for a pending custom hostname. + * Values are provider-shaped and opaque to the CLI, so they pass through as + * `unknown` (rendered/serialized verbatim). + */ +const DomainVerificationSchema = z + .object({ + ownership_verification: z.unknown().nullable().optional(), + ownership_verification_http: z.unknown().nullable().optional(), + ssl_validation_records: z.array(z.unknown()).nullable().optional(), + ssl_validation_errors: z.array(z.unknown()).nullable().optional(), + }) + .transform((data) => ({ + ownershipVerification: data.ownership_verification ?? null, + ownershipVerificationHttp: data.ownership_verification_http ?? null, + sslValidationRecords: data.ssl_validation_records ?? null, + sslValidationErrors: data.ssl_validation_errors ?? null, + })); + +const DomainSchema = z + .object({ + hostname: z.string(), + cname_target: z.string(), + status: z.string().nullable(), + ssl_status: z.string().nullable(), + active: z.boolean(), + pending_deployment: z.boolean().optional(), + verification: DomainVerificationSchema, + }) + .transform((data) => ({ + hostname: data.hostname, + cnameTarget: data.cname_target, + status: data.status, + sslStatus: data.ssl_status, + active: data.active, + pendingDeployment: data.pending_deployment ?? false, + verification: data.verification, + })); + +export type Domain = z.infer; + +// ─── REQUESTS ──────────────────────────────────────────────── + +/** Request payload for POST domains (sent as snake_case JSON). */ +export interface AddDomainRequest { + hostname: string; +} + +// ─── RESPONSES ─────────────────────────────────────────────── + +/** POST domains returns a single domain view. */ +export const AddDomainResponseSchema = DomainSchema; + +export const DomainsListResponseSchema = z + .object({ domains: z.array(DomainSchema) }) + .transform((data) => data.domains); + +export const RemoveDomainResponseSchema = z + .object({ hostname: z.string(), deleted: z.boolean() }) + .transform((data) => ({ hostname: data.hostname, deleted: data.deleted })); + +export type RemoveDomainResponse = z.infer; diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index 5a358842..1011e654 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -262,6 +262,14 @@ export class InvalidInputError extends UserError { readonly code = "INVALID_INPUT"; } +/** + * Thrown when a polled operation (e.g. waiting for a custom domain to become + * active) does not reach the desired state within its time budget. + */ +export class TimeoutError extends UserError { + readonly code = "TIMEOUT"; +} + /** * Thrown when a required external dependency is not installed (e.g., Deno, Git). */ diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index 723c3e77..4b34076c 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -2,6 +2,7 @@ export * from "./auth/index.js"; export * from "./clients/index.js"; export * from "./config.js"; export * from "./consts.js"; +export * from "./deployments/index.js"; export * from "./errors.js"; export * from "./project/index.js"; export * from "./resources/index.js"; diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 68089173..1daa5456 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -56,6 +56,13 @@ interface DeployAllResult { interface DeployAllOptions { onFunctionStart?: (names: string[]) => void; onFunctionResult?: (result: SingleFunctionDeployResult) => void; + /** + * Deploy the legacy static site (tar.gz upload) when configured. + * The unified deploy command passes false and handles the site itself, + * so full-stack (Workers) projects can take the deployments path instead. + * @default true + */ + site?: boolean; } /** @@ -81,7 +88,7 @@ export async function deployAll( await authConfigResource.push(authConfig); const { results: connectorResults } = await pushConnectors(connectors); - if (project.site?.outputDirectory) { + if ((options?.site ?? true) && project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); const { appUrl } = await deploySite(outputDir); return { appUrl, connectorResults }; diff --git a/packages/cli/src/core/slug/api.ts b/packages/cli/src/core/slug/api.ts new file mode 100644 index 00000000..0311ae3f --- /dev/null +++ b/packages/cli/src/core/slug/api.ts @@ -0,0 +1,61 @@ +import type { KyResponse } from "ky"; +import { base44Client, getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { getAppContext } from "@/core/project/index.js"; +import type { AppSlug, UpdateSlugRequest } from "./schema.js"; +import { AppSlugResponseSchema, SlugSuggestionsSchema } from "./schema.js"; + +/** Fetch the app's current slug (from the app document). */ +export async function getSlug(): Promise { + const { id } = getAppContext(); + + let response: KyResponse; + try { + response = await base44Client.get(`api/apps/${id}`); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching app slug"); + } + + const result = AppSlugResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** + * Change the app's slug; pass null to reset to the auto-generated slug. + * When the requested slug is already in use, the API's alternative + * suggestions are surfaced as hints on the thrown ApiError. + */ +export async function updateSlug(slug: string | null): Promise { + const appClient = getAppClient(); + + const request: UpdateSlugRequest = { slug }; + let response: KyResponse; + try { + response = await appClient.patch("metadata/slug", { json: request }); + } catch (error) { + const apiError = await ApiError.fromHttpError(error, "updating slug"); + const suggestions = SlugSuggestionsSchema.safeParse(apiError.responseBody) + .data?.suggestions; + if (suggestions && suggestions.length > 0) { + apiError.hints.unshift({ + message: `Available alternatives: ${suggestions.join(", ")}`, + }); + } + throw apiError; + } + + const result = AppSlugResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} diff --git a/packages/cli/src/core/slug/index.ts b/packages/cli/src/core/slug/index.ts new file mode 100644 index 00000000..4ac14404 --- /dev/null +++ b/packages/cli/src/core/slug/index.ts @@ -0,0 +1,2 @@ +export * from "./api.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/slug/schema.ts b/packages/cli/src/core/slug/schema.ts new file mode 100644 index 00000000..0faa0263 --- /dev/null +++ b/packages/cli/src/core/slug/schema.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +/** + * App document returned by GET api/apps/{id} and PATCH metadata/slug. + * Only the slug is consumed; every other field is dropped. + */ +export const AppSlugResponseSchema = z + .object({ slug: z.string().nullable().optional() }) + .transform((data) => ({ slug: data.slug ?? null })); + +export type AppSlug = z.infer; + +// ─── REQUESTS ──────────────────────────────────────────────── + +/** Request payload for PATCH metadata/slug. Null resets to auto-generated. */ +export interface UpdateSlugRequest { + slug: string | null; +} + +// ─── ERRORS ────────────────────────────────────────────────── + +/** 400 "slug already in use" bodies carry alternative slug suggestions. */ +export const SlugSuggestionsSchema = z.object({ + suggestions: z.array(z.string()), +}); diff --git a/packages/cli/tests/cli/deployments.spec.ts b/packages/cli/tests/cli/deployments.spec.ts new file mode 100644 index 00000000..b7b90fd9 --- /dev/null +++ b/packages/cli/tests/cli/deployments.spec.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const DEPLOYMENT = { + deployment_id: "dep-1", + status: "ready" as const, + target: "preview" as const, + created_at: "2026-07-01T10:00:00Z", + urls: [ + { url: "https://dep-1.test-app.base44.app", kind: "immutable" as const }, + ], + framework: "react-router", +}; + +describe("deployments command", () => { + const t = setupCLITests(); + + it("lists deployments", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentsList({ + deployments: [ + DEPLOYMENT, + { + ...DEPLOYMENT, + deployment_id: "dep-2", + status: "failed", + target: "production", + framework: null, + }, + ], + }); + + const result = await t.run("deployments", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("dep-1"); + t.expectResult(result).toContain("dep-2"); + t.expectResult(result).toContain("2 deployments"); + }); + + it("lists deployments as JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentsList({ deployments: [DEPLOYMENT] }); + + const result = await t.run("deployments", "list", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.deployments).toEqual([ + { + deploymentId: "dep-1", + status: "ready", + target: "preview", + createdAt: "2026-07-01T10:00:00Z", + urls: [{ url: "https://dep-1.test-app.base44.app", kind: "immutable" }], + framework: "react-router", + }, + ]); + }); + + it("shows an empty-state message when there are no deployments", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentsList({ deployments: [] }); + + const result = await t.run("deployments", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No deployments found"); + }); + + it("gets a single deployment", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentGet("dep-1", DEPLOYMENT); + + const result = await t.run("deployments", "get", "dep-1"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("dep-1"); + t.expectResult(result).toContain("https://dep-1.test-app.base44.app"); + }); + + it("gets a single deployment as JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentGet("dep-1", DEPLOYMENT); + + const result = await t.run("deployments", "get", "dep-1", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.deploymentId).toBe("dep-1"); + expect(parsed.status).toBe("ready"); + }); + + it("fails when the API returns an error", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockError("get", `/api/apps/${t.api.appId}/deployments`, { + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("deployments", "list"); + + t.expectResult(result).toFail(); + }); +}); + +describe("promote command", () => { + const t = setupCLITests(); + + it("promotes a deployment to production", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockPromote({ + deployment_id: "dep-1", + urls: [{ url: "https://test-app.base44.app", kind: "production" }], + }); + + const result = await t.run("promote", "dep-1"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deployment dep-1 promoted to production"); + t.expectResult(result).toContain("https://test-app.base44.app"); + }); + + it("outputs JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockPromote({ + deployment_id: "dep-1", + urls: [{ url: "https://test-app.base44.app", kind: "production" }], + }); + + const result = await t.run("promote", "dep-1", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed).toEqual({ + deploymentId: "dep-1", + urls: [{ url: "https://test-app.base44.app", kind: "production" }], + }); + }); +}); + +describe("rollback command", () => { + const t = setupCLITests(); + + it("requires -y in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + + const result = await t.run("rollback"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--yes is required in non-interactive mode", + ); + }); + + it("rolls back production with -y", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockRollback({ + deployment_id: "dep-0", + urls: [{ url: "https://test-app.base44.app", kind: "production" }], + }); + + const result = await t.run("rollback", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain( + "Production rolled back to deployment dep-0", + ); + }); + + it("surfaces the API error when there is no prior production deployment", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockRollbackError({ + status: 400, + body: { message: "No previous production deployment to roll back to" }, + }); + + const result = await t.run("rollback", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "No previous production deployment to roll back to", + ); + }); +}); diff --git a/packages/cli/tests/cli/domains.spec.ts b/packages/cli/tests/cli/domains.spec.ts new file mode 100644 index 00000000..93438762 --- /dev/null +++ b/packages/cli/tests/cli/domains.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const DOMAIN = { + hostname: "app.example.com", + cname_target: "b44apps.dev", + status: "pending" as const, + ssl_status: "pending_validation" as const, + active: false, + pending_deployment: false, + verification: { + ownership_verification: { + type: "txt", + name: "_cf-custom-hostname.app.example.com", + value: "abc123", + }, + ownership_verification_http: { + http_url: + "http://app.example.com/.well-known/cf-custom-hostname-challenge/x", + http_body: "body", + }, + ssl_validation_records: [ + { txt_name: "_acme-challenge.app.example.com", txt_value: "zzz" }, + ], + ssl_validation_errors: null, + }, +}; + +const ACTIVE_DOMAIN = { + ...DOMAIN, + status: "active" as const, + ssl_status: "active" as const, + active: true, +}; + +describe("domains add command", () => { + const t = setupCLITests(); + + it("prints the CNAME record and status", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainAdd(DOMAIN); + + const result = await t.run("domains", "add", "app.example.com"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("CNAME"); + t.expectResult(result).toContain("app.example.com"); + t.expectResult(result).toContain("b44apps.dev"); + }); + + it("outputs JSON with --json (snake→camel transform)", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainAdd(DOMAIN); + + const result = await t.run("domains", "add", "app.example.com", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.hostname).toBe("app.example.com"); + expect(parsed.cnameTarget).toBe("b44apps.dev"); + expect(parsed.sslStatus).toBe("pending_validation"); + expect(parsed.pendingDeployment).toBe(false); + expect(parsed.verification.ownershipVerification).toEqual({ + type: "txt", + name: "_cf-custom-hostname.app.example.com", + value: "abc123", + }); + }); + + it("waits until the domain is active with --wait", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainAdd(DOMAIN); + // First poll of listDomains returns the active domain (no delay incurred). + t.api.mockDomainList({ domains: [ACTIVE_DOMAIN] }); + + const result = await t.run("domains", "add", "app.example.com", "--wait"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("app.example.com is active"); + }); + + it("fails when the API returns an error", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainAddError({ + status: 400, + body: { message: "invalid hostname" }, + }); + + const result = await t.run("domains", "add", "bad_host"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("invalid hostname"); + }); +}); + +describe("domains list command", () => { + const t = setupCLITests(); + + it("lists custom domains", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainList({ + domains: [DOMAIN, { ...ACTIVE_DOMAIN, hostname: "www.example.com" }], + }); + + const result = await t.run("domains", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("app.example.com"); + t.expectResult(result).toContain("www.example.com"); + t.expectResult(result).toContain("2 domains"); + }); + + it("lists domains as JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainList({ domains: [DOMAIN] }); + + const result = await t.run("domains", "list", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.domains).toHaveLength(1); + expect(parsed.domains[0].hostname).toBe("app.example.com"); + expect(parsed.domains[0].cnameTarget).toBe("b44apps.dev"); + expect(parsed.domains[0].active).toBe(false); + }); + + it("shows an empty-state message when there are no domains", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainList({ domains: [] }); + + const result = await t.run("domains", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No custom domains found"); + }); +}); + +describe("domains remove command", () => { + const t = setupCLITests(); + + it("requires -y in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + + const result = await t.run("domains", "remove", "app.example.com"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--yes is required in non-interactive mode", + ); + }); + + it("removes a domain with -y", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainRemove("app.example.com", { + hostname: "app.example.com", + deleted: true, + }); + + const result = await t.run("domains", "remove", "app.example.com", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Disconnected app.example.com"); + }); + + it("surfaces the API error", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainRemoveError("app.example.com", { + status: 500, + body: { message: "Server error" }, + }); + + const result = await t.run("domains", "remove", "app.example.com", "-y"); + + t.expectResult(result).toFail(); + }); +}); diff --git a/packages/cli/tests/cli/fullstack_deploy.spec.ts b/packages/cli/tests/cli/fullstack_deploy.spec.ts new file mode 100644 index 00000000..029c1e18 --- /dev/null +++ b/packages/cli/tests/cli/fullstack_deploy.spec.ts @@ -0,0 +1,269 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +/** Same algorithm as core: first 32 hex chars of sha256(utf8(appId) || bytes). */ +function assetHash(appId: string, content: string): string { + return createHash("sha256") + .update(Buffer.from(appId, "utf8")) + .update(Buffer.from(content)) + .digest("hex") + .slice(0, 32); +} + +const INDEX_HTML = "

Hello

\n"; +const APP_JS = 'console.log("app");\n'; + +interface CreateBody { + target: string; + framework: string | null; + config: { + main: string; + compatibility_date: string | null; + compatibility_flags: string[]; + assets_config: Record | null; + vars: Record; + }; + modules: { name: string; size: number; type: string }[]; + asset_manifest: Record; +} + +describe("deploy command (full-stack)", () => { + const t = setupCLITests(); + + /** Mocks hit by the unified deploy's resource-push phase (no resources). */ + function mockResourcePushes() { + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + } + + function mockHappyPath(options?: { buckets?: string[][] }) { + mockResourcePushes(); + const htmlHash = assetHash(t.api.appId, INDEX_HTML); + const jsHash = assetHash(t.api.appId, APP_JS); + t.api.mockDeploymentCreate({ + deployment_id: "dep-1", + script_name: "script-1", + immutable_url: "https://dep-1.test-app.base44.app", + asset_upload: { + jwt: "upload-jwt", + buckets: options?.buckets ?? [[htmlHash], [jsHash]], + }, + }); + t.api.mockAssetUpload("completion-jwt"); + t.api.mockDeploymentFinalize({ + status: "ready", + deployment_id: "dep-1", + urls: [{ url: "https://dep-1.test-app.base44.app", kind: "immutable" }], + }); + return { htmlHash, jsHash }; + } + + it("deploys a full-stack artifact: manifest hashes, bucket upload, finalize modules", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + const { htmlHash, jsHash } = mockHappyPath(); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 2 static assets (2 new)"); + t.expectResult(result).toContain("Full-stack app deployed"); + t.expectResult(result).toContain( + "Preview: https://dep-1.test-app.base44.app", + ); + + // Create request: manifest (salted hashes), modules, config passthrough + expect(t.api.deploymentCreateRequests).toHaveLength(1); + const body = t.api.deploymentCreateRequests[0] as CreateBody; + expect(body.target).toBe("preview"); + expect(body.config.main).toBe("index.js"); + expect(body.config.compatibility_date).toBe("2025-04-01"); + expect(body.config.compatibility_flags).toEqual(["nodejs_compat"]); + expect(body.config.vars).toEqual({ MY_VAR: "my-value" }); + expect(body.asset_manifest).toEqual({ + "/index.html": { hash: htmlHash, size: INDEX_HTML.length }, + "/assets/app-123.js": { hash: jsHash, size: APP_JS.length }, + }); + // .assetsignore honored: ignored.txt and .assetsignore itself excluded + expect(Object.keys(body.asset_manifest)).not.toContain("/ignored.txt"); + expect(Object.keys(body.asset_manifest)).not.toContain("/.assetsignore"); + + const moduleNames = body.modules.map((m) => m.name).sort(); + expect(moduleNames).toEqual([ + "assets/chunk-abc.js", + "index.js", + "index.js.map", + ]); + expect(body.modules.find((m) => m.name === "index.js.map")?.type).toBe( + "sourcemap", + ); + expect(body.modules.find((m) => m.name === "index.js")?.type).toBe("esm"); + + // Bucket upload: two buckets, base64 form fields named by hash + expect(t.api.assetUploadRequests).toHaveLength(2); + for (const upload of t.api.assetUploadRequests) { + expect(upload.authorization).toBe("Bearer upload-jwt"); + expect(upload.base64Query).toBe("true"); + } + const uploadedFields = t.api.assetUploadRequests.flatMap((r) => r.fields); + const uploadedByName = new Map(uploadedFields.map((f) => [f.name, f])); + expect([...uploadedByName.keys()].sort()).toEqual( + [htmlHash, jsHash].sort(), + ); + expect( + Buffer.from( + uploadedByName.get(htmlHash)?.data.toString() ?? "", + "base64", + ).toString(), + ).toBe(INDEX_HTML); + // Bun's compiled binary normalizes Blob types to include the charset. + expect(uploadedByName.get(htmlHash)?.contentType).toMatch( + /^text\/html(;\s*charset=utf-8)?$/i, + ); + + // Finalize: payload carries the completion jwt + one field per module + expect(t.api.finalizeRequests).toHaveLength(1); + const finalizeFields = t.api.finalizeRequests[0]; + const payloadField = finalizeFields.find((f) => f.name === "payload"); + expect(JSON.parse(payloadField?.data.toString() ?? "{}")).toEqual({ + completion_jwt: "completion-jwt", + }); + const fieldNames = finalizeFields.map((f) => f.name).sort(); + expect(fieldNames).toEqual([ + "assets/chunk-abc.js", + "index.js", + "index.js.map", + "payload", + ]); + expect(finalizeFields.find((f) => f.name === "index.js")?.contentType).toBe( + "application/javascript+module", + ); + expect( + finalizeFields.find((f) => f.name === "index.js.map")?.contentType, + ).toBe("application/source-map"); + }); + + it("deploys to production with --prod and prints the production URL", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockResourcePushes(); + t.api.mockDeploymentCreate({ + deployment_id: "dep-2", + script_name: "script-1", + immutable_url: "https://dep-2.test-app.base44.app", + asset_upload: { jwt: "upload-jwt", buckets: [] }, + }); + t.api.mockDeploymentFinalize({ + status: "ready", + deployment_id: "dep-2", + urls: [ + { url: "https://dep-2.test-app.base44.app", kind: "immutable" }, + { url: "https://test-app.base44.app", kind: "production" }, + ], + }); + + const result = await t.run("deploy", "-y", "--prod"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Production: https://test-app.base44.app"); + + const body = t.api.deploymentCreateRequests[0] as CreateBody; + expect(body.target).toBe("production"); + + // Empty buckets: the session JWT is already the completion token + expect(t.api.assetUploadRequests).toHaveLength(0); + const payloadField = t.api.finalizeRequests[0].find( + (f) => f.name === "payload", + ); + expect(JSON.parse(payloadField?.data.toString() ?? "{}")).toEqual({ + completion_jwt: "upload-jwt", + }); + }); + + it("outputs a single JSON document with --json", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockHappyPath(); + + const result = await t.run("deploy", "-y", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed).toEqual({ + deploymentId: "dep-1", + target: "preview", + urls: [{ url: "https://dep-1.test-app.base44.app", kind: "immutable" }], + }); + }); + + it("warns when the wrangler config lacks the nodejs_compat flag (e.g. Astro 6)", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + // Astro 6's generated wrangler.json can ship without compatibility flags. + const configPath = join( + t.getTempDir(), + "project", + "build", + "server", + "wrangler.json", + ); + const config = JSON.parse(await readFile(configPath, "utf-8")); + config.compatibility_flags = []; + await writeFile(configPath, JSON.stringify(config)); + mockHappyPath(); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("no 'nodejs_compat' compatibility flag"); + const body = t.api.deploymentCreateRequests[0] as CreateBody; + expect(body.config.compatibility_flags).toEqual([]); + }); + + it("surfaces a session-expired error when asset uploads keep failing with 401", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockResourcePushes(); + t.api.mockDeploymentCreate({ + deployment_id: "dep-1", + script_name: "script-1", + immutable_url: "https://dep-1.test-app.base44.app", + asset_upload: { + jwt: "expired-jwt", + buckets: [[assetHash(t.api.appId, INDEX_HTML)]], + }, + }); + t.api.mockAssetUploadError({ status: 401, body: { error: "expired" } }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Asset upload session expired — rerun deploy", + ); + }, 20_000); + + it("fails when the deployment API rejects the create call", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockResourcePushes(); + t.api.mockError("post", `/api/apps/${t.api.appId}/deployments`, { + status: 422, + body: { message: "unsupported artifact" }, + }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("unsupported artifact"); + }); + + it("rejects --build combined with --prebuilt", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + + const result = await t.run("deploy", "-y", "--build", "--prebuilt"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--build and --prebuilt cannot be combined", + ); + }); +}); diff --git a/packages/cli/tests/cli/logs_deployment.spec.ts b/packages/cli/tests/cli/logs_deployment.spec.ts new file mode 100644 index 00000000..cc78a089 --- /dev/null +++ b/packages/cli/tests/cli/logs_deployment.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const LOG_ROWS = [ + { + time: "2026-07-01T10:00:00.000Z", + level: "info" as const, + message: "Worker started", + }, + { + time: "2026-07-01T10:00:01.000Z", + level: "error" as const, + message: "Boom", + }, +]; + +describe("logs --deployment", () => { + const t = setupCLITests(); + + it("fetches logs for a deployment id", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentLogs("dep-1", LOG_ROWS); + + const result = await t.run("logs", "--deployment", "dep-1"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Showing 2 deployment log entries"); + t.expectResult(result).toContain("Worker started"); + t.expectResult(result).toContain("Boom"); + }); + + it("fetches logs for the production alias", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentLogs("production", LOG_ROWS); + + const result = await t.run("logs", "--deployment", "production"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Worker started"); + }); + + it("passes --since/--limit/--order filters through and outputs JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentLogs("dep-1", [LOG_ROWS[0]]); + + const result = await t.run( + "logs", + "--deployment", + "dep-1", + "--since", + "1h", + "--limit", + "10", + "--order", + "asc", + "--json", + ); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveLength(1); + expect(parsed[0].message).toBe("Worker started"); + }); + + it("shows an empty-state message when there are no logs", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentLogs("dep-1", []); + + const result = await t.run("logs", "--deployment", "dep-1"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain( + "No deployment logs found matching the filters.", + ); + }); + + it("rejects --deployment combined with --function", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + + const result = await t.run( + "logs", + "--deployment", + "dep-1", + "--function", + "my-fn", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--function cannot be combined with --deployment", + ); + }); + + it("rejects --deployment combined with --env", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + + const result = await t.run( + "logs", + "--deployment", + "dep-1", + "--env", + "prod", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--env is not supported with --deployment", + ); + }); + + it("rejects --follow combined with --until for deployments too", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + + const result = await t.run( + "logs", + "--deployment", + "dep-1", + "--follow", + "--until", + "1h", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("--until cannot be combined"); + }); + + it("fails when the API returns an error", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDeploymentLogsError("dep-1", { + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("logs", "--deployment", "dep-1"); + + t.expectResult(result).toFail(); + }); +}); diff --git a/packages/cli/tests/cli/slug.spec.ts b/packages/cli/tests/cli/slug.spec.ts new file mode 100644 index 00000000..148c3939 --- /dev/null +++ b/packages/cli/tests/cli/slug.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const APP = { id: "test-app-id", name: "My App", slug: "my-app" }; + +describe("slug command", () => { + const t = setupCLITests(); + + it("shows the current slug and app URL", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSiteUrl({ url: "https://my-app.base44.app" }); + + const result = await t.run("slug"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("my-app"); + t.expectResult(result).toContain("https://my-app.base44.app"); + }); + + it("outputs JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSiteUrl({ url: "https://my-app.base44.app" }); + + const result = await t.run("slug", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.slug).toBe("my-app"); + expect(parsed.url).toBe("https://my-app.base44.app"); + }); + + it("reports when the app has no slug", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet({ ...APP, slug: null }); + + const result = await t.run("slug"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("no slug"); + }); +}); + +describe("slug set command", () => { + const t = setupCLITests(); + + it("sets a custom slug and prints old slug, new slug, and URL", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSlugUpdate({ ...APP, slug: "new-slug" }); + t.api.mockSiteUrl({ url: "https://new-slug.base44.app" }); + + const result = await t.run("slug", "set", "new-slug"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("my-app"); + t.expectResult(result).toContain("new-slug"); + t.expectResult(result).toContain("https://new-slug.base44.app"); + expect(t.api.slugUpdateRequests).toEqual([{ slug: "new-slug" }]); + }); + + it("outputs JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSlugUpdate({ ...APP, slug: "new-slug" }); + t.api.mockSiteUrl({ url: "https://new-slug.base44.app" }); + + const result = await t.run("slug", "set", "new-slug", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.previousSlug).toBe("my-app"); + expect(parsed.slug).toBe("new-slug"); + expect(parsed.url).toBe("https://new-slug.base44.app"); + }); + + it("fails when the slug format is invalid", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSlugUpdateError({ + status: 400, + body: { + detail: + "Custom URL must be 3-50 characters and contain only letters, numbers, and hyphens", + }, + }); + + const result = await t.run("slug", "set", "x!"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("3-50 characters"); + }); + + it("surfaces suggestions when the slug is already in use", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSlugUpdateError({ + status: 400, + body: { + detail: "URL slug 'taken' is already in use", + suggestions: ["taken-app", "taken-hq"], + }, + }); + + const result = await t.run("slug", "set", "taken"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("already in use"); + t.expectResult(result).toContain("taken-app"); + }); +}); + +describe("slug reset command", () => { + const t = setupCLITests(); + + it("resets to the auto-generated slug", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSlugUpdate({ ...APP, slug: "my-app-12345678" }); + t.api.mockSiteUrl({ url: "https://my-app-12345678.base44.app" }); + + const result = await t.run("slug", "reset"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("my-app-12345678"); + expect(t.api.slugUpdateRequests).toEqual([{ slug: null }]); + }); + + it("surfaces API errors", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSlugUpdateError({ + status: 500, + body: { detail: "Server error" }, + }); + + const result = await t.run("slug", "reset"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Server error"); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 2cabf4f7..ae8de86c 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -197,6 +197,137 @@ interface CreateAppResponse { name: string; } +// ─── DEPLOYMENTS (FULL-STACK) TYPES ───────────────────────── + +interface DeploymentUrlPayload { + url: string; + kind: "immutable" | "production"; +} + +interface DeploymentCreateResponse { + deployment_id: string; + script_name: string; + immutable_url: string; + /** upload_url is filled in automatically to point at this test server. */ + asset_upload: { jwt: string; buckets: string[][] } | null; +} + +interface DeploymentFinalizeResponse { + status: "ready"; + deployment_id: string; + urls: DeploymentUrlPayload[]; +} + +interface DeploymentPayload { + deployment_id: string; + status: "ready" | "failed" | "in_progress"; + target: "preview" | "production"; + created_at: string; + urls: DeploymentUrlPayload[]; + framework: string | null; +} + +interface DeploymentsListResponse { + deployments: DeploymentPayload[]; +} + +interface PromoteResponse { + deployment_id: string; + urls: DeploymentUrlPayload[]; +} + +interface DomainPayload { + hostname: string; + cname_target: string; + status: string | null; + ssl_status: string | null; + active: boolean; + pending_deployment?: boolean; + verification: { + ownership_verification?: unknown; + ownership_verification_http?: unknown; + ssl_validation_records?: unknown[] | null; + ssl_validation_errors?: unknown[] | null; + }; +} + +interface DomainsListResponse { + domains: DomainPayload[]; +} + +interface RemoveDomainResponse { + hostname: string; + deleted: boolean; +} + +/** The app document (only the fields the CLI's slug commands consume). */ +interface AppPayload { + id: string; + name?: string; + slug?: string | null; +} + +/** A parsed part of a multipart/form-data request body. */ +interface MultipartField { + name: string; + filename?: string; + contentType?: string; + data: Buffer; +} + +/** + * Minimal multipart/form-data parser for captured raw request bodies + * (the global express.raw middleware buffers multipart bodies as-is). + */ +function parseMultipart( + body: Buffer, + contentTypeHeader: string, +): MultipartField[] { + const boundaryMatch = /boundary=(?:"([^"]+)"|([^;]+))/.exec( + contentTypeHeader, + ); + if (!boundaryMatch) { + throw new Error(`No multipart boundary in: ${contentTypeHeader}`); + } + const boundary = `--${boundaryMatch[1] ?? boundaryMatch[2]}`; + + const fields: MultipartField[] = []; + const raw = body.toString("binary"); + const sections = raw.split(boundary).slice(1, -1); // drop preamble + closing "--" + + for (const section of sections) { + const part = section.replace(/^\r\n/, ""); + const headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd === -1) continue; + + const headerBlock = part.slice(0, headerEnd); + const data = Buffer.from( + part.slice(headerEnd + 4).replace(/\r\n$/, ""), + "binary", + ); + + const nameMatch = /name="([^"]*)"/.exec(headerBlock); + const filenameMatch = /filename="([^"]*)"/.exec(headerBlock); + const typeMatch = /content-type:\s*([^\r\n]+)/i.exec(headerBlock); + + fields.push({ + name: nameMatch?.[1] ?? "", + filename: filenameMatch?.[1], + contentType: typeMatch?.[1].trim(), + data, + }); + } + + return fields; +} + +/** A captured asset bucket upload request. */ +interface CapturedAssetUpload { + authorization?: string; + base64Query?: string; + fields: MultipartField[]; +} + interface ListProjectsResponse { id: string; name: string; @@ -211,7 +342,7 @@ interface ErrorResponse { // ─── ROUTE HANDLER TYPES ───────────────────────────────────── -type Method = "GET" | "POST" | "PUT" | "DELETE"; +type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; interface RouteEntry { method: Method; @@ -289,6 +420,7 @@ export class TestAPIServer { | "get" | "post" | "put" + | "patch" | "delete"; this.app[method](entry.path, entry.handler); } @@ -501,6 +633,228 @@ export class TestAPIServer { ); } + // ─── DEPLOYMENT (FULL-STACK) ENDPOINTS ─────────────────── + + /** Captured JSON bodies of POST deployments requests. */ + readonly deploymentCreateRequests: unknown[] = []; + /** Captured asset bucket uploads (POST to the upload_url). */ + readonly assetUploadRequests: CapturedAssetUpload[] = []; + /** Captured multipart fields of finalize requests. */ + readonly finalizeRequests: MultipartField[][] = []; + + /** + * Mock POST /api/apps/{appId}/deployments. Captures the JSON request body + * in `deploymentCreateRequests`. When `asset_upload` is set, its + * `upload_url` is filled in to point at this server's /asset-upload route + * (see `mockAssetUpload`). + */ + mockDeploymentCreate(response: DeploymentCreateResponse): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/deployments`, + handler: (req, res) => { + this.deploymentCreateRequests.push(req.body); + const { asset_upload, ...rest } = response; + res.status(200).json({ + ...rest, + asset_upload: asset_upload + ? { ...asset_upload, upload_url: `${this.baseUrl}/asset-upload` } + : null, + }); + }, + }); + return this; + } + + /** + * Mock the direct asset bucket upload endpoint (the upload_url returned by + * mockDeploymentCreate). Captures each request's Authorization header, + * base64 query param, and multipart fields in `assetUploadRequests`, and + * responds 201 with the completion JWT. + */ + mockAssetUpload(completionJwt: string): this { + this.pendingRoutes.push({ + method: "POST", + path: "/asset-upload", + handler: (req, res) => { + this.assetUploadRequests.push({ + authorization: req.headers.authorization, + base64Query: String(req.query.base64 ?? ""), + fields: parseMultipart( + req.body as Buffer, + req.headers["content-type"] ?? "", + ), + }); + res.status(201).json({ result: { jwt: completionJwt } }); + }, + }); + return this; + } + + /** Mock the asset upload endpoint to always fail with the given error. */ + mockAssetUploadError(error: ErrorResponse): this { + return this.addErrorRoute("POST", "/asset-upload", error); + } + + /** + * Mock POST /api/apps/{appId}/deployments/{id}/finalize. Captures the + * multipart fields (payload JSON + one file field per module) in + * `finalizeRequests`. + */ + mockDeploymentFinalize(response: DeploymentFinalizeResponse): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/deployments/:deploymentId/finalize`, + handler: (req, res) => { + this.finalizeRequests.push( + parseMultipart(req.body as Buffer, req.headers["content-type"] ?? ""), + ); + res.status(200).json(response); + }, + }); + return this; + } + + /** Mock GET /api/apps/{appId}/deployments (list). */ + mockDeploymentsList(response: DeploymentsListResponse): this { + return this.addRoute( + "GET", + `/api/apps/${this.appId}/deployments`, + response, + ); + } + + /** Mock GET /api/apps/{appId}/deployments/{id} (single deployment). */ + mockDeploymentGet(deploymentId: string, response: DeploymentPayload): this { + return this.addRoute( + "GET", + `/api/apps/${this.appId}/deployments/${encodeURIComponent(deploymentId)}`, + response, + ); + } + + /** Mock POST /api/apps/{appId}/deployments/{id}/promote. */ + mockPromote(response: PromoteResponse): this { + return this.addRoute( + "POST", + `/api/apps/${this.appId}/deployments/:deploymentId/promote`, + response, + ); + } + + /** Mock POST /api/apps/{appId}/deployments/rollback. */ + mockRollback(response: PromoteResponse): this { + return this.addRoute( + "POST", + `/api/apps/${this.appId}/deployments/rollback`, + response, + ); + } + + mockRollbackError(error: ErrorResponse): this { + return this.addErrorRoute( + "POST", + `/api/apps/${this.appId}/deployments/rollback`, + error, + ); + } + + /** + * Mock deployment logs. Pass a deployment id for + * GET /deployments/{id}/logs, or "production" for the alias route + * GET /deployments/logs?alias=production. + */ + mockDeploymentLogs(idOrAlias: string, response: FunctionLogsResponse): this { + const path = + idOrAlias === "production" + ? `/api/apps/${this.appId}/deployments/logs` + : `/api/apps/${this.appId}/deployments/${encodeURIComponent(idOrAlias)}/logs`; + return this.addRoute("GET", path, { logs: response }); + } + + mockDeploymentLogsError(idOrAlias: string, error: ErrorResponse): this { + const path = + idOrAlias === "production" + ? `/api/apps/${this.appId}/deployments/logs` + : `/api/apps/${this.appId}/deployments/${encodeURIComponent(idOrAlias)}/logs`; + return this.addErrorRoute("GET", path, error); + } + + // ─── CUSTOM DOMAIN ENDPOINTS ───────────────────────────── + + /** Captured JSON bodies of POST domains requests. */ + readonly domainAddRequests: unknown[] = []; + + /** Mock POST /api/apps/{appId}/domains (connect a domain). */ + mockDomainAdd(response: DomainPayload): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/domains`, + handler: (req, res) => { + this.domainAddRequests.push(req.body); + res.status(200).json(response); + }, + }); + return this; + } + + mockDomainAddError(error: ErrorResponse): this { + return this.addErrorRoute("POST", `/api/apps/${this.appId}/domains`, error); + } + + /** Mock GET /api/apps/{appId}/domains (list). */ + mockDomainList(response: DomainsListResponse): this { + return this.addRoute("GET", `/api/apps/${this.appId}/domains`, response); + } + + /** Mock DELETE /api/apps/{appId}/domains/{hostname}. */ + mockDomainRemove(hostname: string, response: RemoveDomainResponse): this { + return this.addRoute( + "DELETE", + `/api/apps/${this.appId}/domains/${encodeURIComponent(hostname)}`, + response, + ); + } + + mockDomainRemoveError(hostname: string, error: ErrorResponse): this { + return this.addErrorRoute( + "DELETE", + `/api/apps/${this.appId}/domains/${encodeURIComponent(hostname)}`, + error, + ); + } + + // ─── SLUG ENDPOINTS ────────────────────────────────────── + + /** Mock GET /api/apps/{appId} (the app document; used for slug reads). */ + mockAppGet(response: AppPayload): this { + return this.addRoute("GET", `/api/apps/${this.appId}`, response); + } + + /** Captured JSON bodies of PATCH metadata/slug requests. */ + readonly slugUpdateRequests: unknown[] = []; + + /** Mock PATCH /api/apps/{appId}/metadata/slug (returns the updated app). */ + mockSlugUpdate(response: AppPayload): this { + this.pendingRoutes.push({ + method: "PATCH", + path: `/api/apps/${this.appId}/metadata/slug`, + handler: (req, res) => { + this.slugUpdateRequests.push(req.body); + res.status(200).json(response); + }, + }); + return this; + } + + mockSlugUpdateError(error: ErrorResponse): this { + return this.addErrorRoute( + "PATCH", + `/api/apps/${this.appId}/metadata/slug`, + error, + ); + } + // ─── SECRETS ENDPOINTS ─────────────────────────────────── mockSecretsList(response: SecretsListResponse): this { diff --git a/packages/cli/tests/core/deployments-manifest.spec.ts b/packages/cli/tests/core/deployments-manifest.spec.ts new file mode 100644 index 00000000..0a95fff2 --- /dev/null +++ b/packages/cli/tests/core/deployments-manifest.spec.ts @@ -0,0 +1,124 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, truncate, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { buildAssetManifest, hashAsset } from "@/core/deployments/manifest.js"; + +describe("hashAsset", () => { + it("computes the first 32 hex chars of sha256(utf8(app_id) || bytes)", () => { + // Known vector: sha256("test-app-id" + "hello world") = + // b24ad526981fbac802de45c88c134ba4... (first 32 hex chars) + expect(hashAsset("test-app-id", Buffer.from("hello world"))).toBe( + "b24ad526981fbac802de45c88c134ba4", + ); + }); + + it("matches a locally computed sha256 over the concatenated bytes", () => { + const expected = createHash("sha256") + .update(Buffer.concat([Buffer.from("app-1"), Buffer.from("content")])) + .digest("hex") + .slice(0, 32); + expect(hashAsset("app-1", Buffer.from("content"))).toBe(expected); + }); + + it("salts with the app id so tenants can only collide with themselves", () => { + const content = Buffer.from("hello world"); + expect(hashAsset("test-app-id", content)).not.toBe( + hashAsset("other-app", content), + ); + }); +}); + +describe("buildAssetManifest", () => { + let assetsDir: string; + + beforeEach(async () => { + assetsDir = await mkdtemp(join(tmpdir(), "b44-assets-")); + }); + + afterEach(async () => { + await rm(assetsDir, { recursive: true, force: true }); + }); + + it("builds manifest keys as /-prefixed forward-slash paths with hash and size", async () => { + await writeFile(join(assetsDir, "index.html"), "

Hello

\n"); + await mkdir(join(assetsDir, "assets")); + await writeFile(join(assetsDir, "assets", "app.js"), "console.log(1);"); + + const { manifest, filesByHash } = await buildAssetManifest( + assetsDir, + "test-app-id", + ); + + expect(Object.keys(manifest).sort()).toEqual([ + "/assets/app.js", + "/index.html", + ]); + expect(manifest["/index.html"]).toEqual({ + hash: hashAsset("test-app-id", Buffer.from("

Hello

\n")), + size: 15, + }); + const entry = manifest["/assets/app.js"]; + expect(filesByHash.get(entry.hash)?.contentType).toBe("text/javascript"); + expect(filesByHash.get(manifest["/index.html"].hash)?.contentType).toBe( + "text/html", + ); + }); + + it("honors .assetsignore patterns (exact names, * globs, directory patterns)", async () => { + await writeFile( + join(assetsDir, ".assetsignore"), + ["secret.txt", "*.log", "private/", "# a comment", ""].join("\n"), + ); + await writeFile(join(assetsDir, "keep.txt"), "keep"); + await writeFile(join(assetsDir, "secret.txt"), "drop"); + await writeFile(join(assetsDir, "debug.log"), "drop"); + await mkdir(join(assetsDir, "private")); + await writeFile(join(assetsDir, "private", "notes.txt"), "drop"); + await mkdir(join(assetsDir, "nested")); + await writeFile(join(assetsDir, "nested", "secret.txt"), "drop"); + await writeFile(join(assetsDir, "nested", "keep.js"), "keep"); + + const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); + + expect(Object.keys(manifest).sort()).toEqual([ + "/keep.txt", + "/nested/keep.js", + ]); + }); + + it("always skips .assetsignore, wrangler.json, and .dev.vars", async () => { + await writeFile(join(assetsDir, "index.html"), "hi"); + await writeFile(join(assetsDir, "wrangler.json"), "{}"); + await writeFile(join(assetsDir, ".dev.vars"), "SECRET=1"); + + const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); + + expect(Object.keys(manifest)).toEqual(["/index.html"]); + }); + + it("rejects files larger than 25 MiB with a per-file error", async () => { + const bigFile = join(assetsDir, "big.bin"); + await writeFile(bigFile, ""); + await truncate(bigFile, 25 * 1024 * 1024 + 1); + + await expect(buildAssetManifest(assetsDir, "test-app-id")).rejects.toThrow( + /"big\.bin".*exceeds the 25 MiB per-file limit/, + ); + }); + + it("dedupes identical files by hash in filesByHash", async () => { + await writeFile(join(assetsDir, "a.txt"), "same"); + await writeFile(join(assetsDir, "b.txt"), "same"); + + const { manifest, filesByHash } = await buildAssetManifest( + assetsDir, + "test-app-id", + ); + + expect(Object.keys(manifest)).toHaveLength(2); + expect(manifest["/a.txt"].hash).toBe(manifest["/b.txt"].hash); + expect(filesByHash.size).toBe(1); + }); +}); diff --git a/packages/cli/tests/core/deployments-modules.spec.ts b/packages/cli/tests/core/deployments-modules.spec.ts new file mode 100644 index 00000000..eb206041 --- /dev/null +++ b/packages/cli/tests/core/deployments-modules.spec.ts @@ -0,0 +1,131 @@ +import { mkdir, mkdtemp, rm, truncate, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { collectModules } from "@/core/deployments/modules.js"; +import type { ResolvedWranglerConfig } from "@/core/deployments/wrangler-config.js"; + +describe("collectModules", () => { + let configDir: string; + + beforeEach(async () => { + configDir = await mkdtemp(join(tmpdir(), "b44-modules-")); + }); + + afterEach(async () => { + await rm(configDir, { recursive: true, force: true }); + }); + + function makeConfig( + overrides: Partial = {}, + ): ResolvedWranglerConfig { + return { + configPath: join(configDir, "wrangler.json"), + configDir, + name: "test-worker", + main: "index.js", + assetsDirectory: null, + assetsConfig: null, + compatibilityDate: null, + compatibilityFlags: [], + vars: {}, + rules: [{ type: "ESModule", globs: ["**/*.js", "**/*.mjs"] }], + uploadSourceMaps: false, + ...overrides, + }; + } + + it("collects the entry first plus rules glob matches, preserving relative names", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + await mkdir(join(configDir, "assets")); + await writeFile(join(configDir, "assets", "chunk.js"), "export {};"); + await writeFile(join(configDir, "helper.mjs"), "export {};"); + await writeFile(join(configDir, "readme.txt"), "not a module"); + + const modules = await collectModules(makeConfig()); + + expect(modules[0].name).toBe("index.js"); + expect(modules[0].type).toBe("esm"); + expect(modules.map((m) => m.name).sort()).toEqual([ + "assets/chunk.js", + "helper.mjs", + "index.js", + ]); + expect(modules.every((m) => m.size > 0)).toBe(true); + }); + + it("excludes wrangler.json and .dev.vars", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + await writeFile(join(configDir, "wrangler.json"), "{}"); + await writeFile(join(configDir, ".dev.vars"), "SECRET=1"); + + const modules = await collectModules(makeConfig()); + + expect(modules.map((m) => m.name)).toEqual(["index.js"]); + }); + + it("includes .map files next to modules as sourcemap modules", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + await writeFile(join(configDir, "index.js.map"), "{}"); + await writeFile(join(configDir, "orphan.map"), "{}"); + + const modules = await collectModules(makeConfig()); + + const map = modules.find((m) => m.name === "index.js.map"); + expect(map?.type).toBe("sourcemap"); + // orphan.map is not adjacent to any module and upload_source_maps is off + expect(modules.find((m) => m.name === "orphan.map")).toBeUndefined(); + }); + + it("includes all .map files when upload_source_maps is set", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + await writeFile(join(configDir, "orphan.map"), "{}"); + + const modules = await collectModules( + makeConfig({ uploadSourceMaps: true }), + ); + + expect(modules.find((m) => m.name === "orphan.map")?.type).toBe( + "sourcemap", + ); + }); + + it("skips modules under the assets directory when it is inside the config dir", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + await mkdir(join(configDir, "client")); + await writeFile(join(configDir, "client", "app.js"), "console.log(1);"); + + const modules = await collectModules( + makeConfig({ assetsDirectory: join(configDir, "client") }), + ); + + expect(modules.map((m) => m.name)).toEqual(["index.js"]); + }); + + it("fails when the entry module does not exist", async () => { + await expect(collectModules(makeConfig())).rejects.toThrow( + /entry module does not exist/, + ); + }); + + it("fails on unknown rule types", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + + await expect( + collectModules( + makeConfig({ rules: [{ type: "CommonJS", globs: ["**/*.cjs"] }] }), + ), + ).rejects.toThrow(/Unsupported module rule type "CommonJS"/); + }); + + it("enforces the 40 MB total module payload limit", async () => { + await writeFile(join(configDir, "index.js"), "export default {};"); + const bigModule = join(configDir, "big.js"); + await writeFile(bigModule, ""); + await truncate(bigModule, 40 * 1024 * 1024 + 1); + + await expect(collectModules(makeConfig())).rejects.toThrow( + /exceeds the 40 MB limit/, + ); + }); +}); diff --git a/packages/cli/tests/core/deployments-wrangler-config.spec.ts b/packages/cli/tests/core/deployments-wrangler-config.spec.ts new file mode 100644 index 00000000..feb33481 --- /dev/null +++ b/packages/cli/tests/core/deployments-wrangler-config.spec.ts @@ -0,0 +1,185 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + detectFullStackArtifact, + resolveWranglerConfig, +} from "@/core/deployments/wrangler-config.js"; + +const FIXTURES_DIR = resolve(__dirname, "../fixtures"); + +const BASE_CONFIG = { + name: "test-worker", + main: "index.js", + no_bundle: true, + rules: [{ type: "ESModule", globs: ["**/*.js"] }], + compatibility_date: "2025-04-01", +}; + +describe("wrangler config resolution", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "b44-wrangler-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + async function writeRedirect(configPath: string): Promise { + await mkdir(join(root, ".wrangler", "deploy"), { recursive: true }); + await writeFile( + join(root, ".wrangler", "deploy", "config.json"), + JSON.stringify({ configPath, auxiliaryWorkers: [] }), + ); + } + + it("resolves the config through the redirect file (path relative to the redirect dir)", async () => { + await writeRedirect("../../dist/worker/wrangler.json"); + await mkdir(join(root, "dist", "worker"), { recursive: true }); + await writeFile( + join(root, "dist", "worker", "wrangler.json"), + JSON.stringify({ + ...BASE_CONFIG, + assets: { directory: "../client" }, + vars: { FOO: "bar" }, + compatibility_flags: ["nodejs_compat"], + }), + ); + + const config = await resolveWranglerConfig(root); + + expect(config.configDir).toBe(join(root, "dist", "worker")); + expect(config.main).toBe("index.js"); + expect(config.assetsDirectory).toBe(join(root, "dist", "client")); + expect(config.compatibilityDate).toBe("2025-04-01"); + expect(config.compatibilityFlags).toEqual(["nodejs_compat"]); + expect(config.vars).toEqual({ FOO: "bar" }); + expect(config.rules).toEqual([{ type: "ESModule", globs: ["**/*.js"] }]); + }); + + it("resolves the fullstack-project fixture", async () => { + const config = await resolveWranglerConfig( + resolve(FIXTURES_DIR, "fullstack-project"), + ); + + expect(config.main).toBe("index.js"); + expect(config.name).toBe("fullstack-project"); + expect(config.assetsDirectory).toBe( + resolve(FIXTURES_DIR, "fullstack-project", "build", "client"), + ); + }); + + it("falls back to a root wrangler.jsonc (JSON5-parsed) when no redirect file exists", async () => { + await writeFile( + join(root, "wrangler.jsonc"), + `{ + // hand-authored config with comments + "name": "test-worker", + "main": "dist/index.js", + "no_bundle": true, + "rules": [{ "type": "ESModule", "globs": ["**/*.js"] }], + }`, + ); + + const config = await resolveWranglerConfig(root); + + expect(config.configDir).toBe(root); + expect(config.main).toBe("dist/index.js"); + }); + + it("ignores extra redirect-file fields like prerenderWorkerConfigPath (Astro 6)", async () => { + await mkdir(join(root, ".wrangler", "deploy"), { recursive: true }); + await writeFile( + join(root, ".wrangler", "deploy", "config.json"), + JSON.stringify({ + configPath: "../../out/wrangler.json", + auxiliaryWorkers: [], + prerenderWorkerConfigPath: "../../out/prerender/wrangler.json", + }), + ); + await mkdir(join(root, "out"), { recursive: true }); + await writeFile( + join(root, "out", "wrangler.json"), + JSON.stringify(BASE_CONFIG), + ); + + const config = await resolveWranglerConfig(root); + + expect(config.configDir).toBe(join(root, "out")); + expect(config.main).toBe("index.js"); + }); + + it("fails clearly when only a wrangler.toml exists", async () => { + await writeFile(join(root, "wrangler.toml"), 'name = "test-worker"\n'); + + await expect(resolveWranglerConfig(root)).rejects.toThrow( + /wrangler\.toml is not supported/, + ); + }); + + it("fails clearly when the config lacks no_bundle: true", async () => { + await writeFile( + join(root, "wrangler.json"), + JSON.stringify({ ...BASE_CONFIG, no_bundle: undefined }), + ); + + await expect(resolveWranglerConfig(root)).rejects.toThrow( + /requires bundling; not yet supported/, + ); + }); + + it("fails fast on unsupported bindings, listing them", async () => { + await writeFile( + join(root, "wrangler.json"), + JSON.stringify({ + ...BASE_CONFIG, + kv_namespaces: [{ binding: "KV", id: "abc" }], + durable_objects: { bindings: [{ name: "DO", class_name: "Foo" }] }, + queues: { producers: [{ binding: "Q", queue: "q" }], consumers: [] }, + }), + ); + + await expect(resolveWranglerConfig(root)).rejects.toThrow( + /Unsupported bindings for Base44 full-stack deploys: kv_namespaces, durable_objects\.bindings, queues\.producers\. Remove them or file a feature request\./, + ); + }); + + it("allows vars and empty binding arrays", async () => { + await writeFile( + join(root, "wrangler.json"), + JSON.stringify({ + ...BASE_CONFIG, + vars: { A: "1" }, + kv_namespaces: [], + d1_databases: [], + services: [], + }), + ); + + const config = await resolveWranglerConfig(root); + expect(config.vars).toEqual({ A: "1" }); + }); + + it("detects nothing in a plain project", async () => { + expect(await detectFullStackArtifact(root)).toBeNull(); + }); + + it("prefers the redirect file over a root config", async () => { + await writeRedirect("../../out/wrangler.json"); + await mkdir(join(root, "out"), { recursive: true }); + await writeFile( + join(root, "out", "wrangler.json"), + JSON.stringify(BASE_CONFIG), + ); + await writeFile(join(root, "wrangler.json"), JSON.stringify(BASE_CONFIG)); + + const artifact = await detectFullStackArtifact(root); + expect(artifact?.source).toBe("redirect"); + + const config = await resolveWranglerConfig(root); + expect(config.configDir).toBe(join(root, "out")); + }); +}); diff --git a/packages/cli/tests/fixtures/fullstack-project/.wrangler/deploy/config.json b/packages/cli/tests/fixtures/fullstack-project/.wrangler/deploy/config.json new file mode 100644 index 00000000..ef993576 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/.wrangler/deploy/config.json @@ -0,0 +1,4 @@ +{ + "configPath": "../../build/server/wrangler.json", + "auxiliaryWorkers": [] +} diff --git a/packages/cli/tests/fixtures/fullstack-project/base44/.app.jsonc b/packages/cli/tests/fixtures/fullstack-project/base44/.app.jsonc new file mode 100644 index 00000000..d7852426 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/packages/cli/tests/fixtures/fullstack-project/base44/config.jsonc b/packages/cli/tests/fixtures/fullstack-project/base44/config.jsonc new file mode 100644 index 00000000..07684f53 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/base44/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Fullstack Project" +} diff --git a/packages/cli/tests/fixtures/fullstack-project/build/client/.assetsignore b/packages/cli/tests/fixtures/fullstack-project/build/client/.assetsignore new file mode 100644 index 00000000..31164dd1 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/client/.assetsignore @@ -0,0 +1,2 @@ +ignored.txt +*.log diff --git a/packages/cli/tests/fixtures/fullstack-project/build/client/assets/app-123.js b/packages/cli/tests/fixtures/fullstack-project/build/client/assets/app-123.js new file mode 100644 index 00000000..702645f1 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/client/assets/app-123.js @@ -0,0 +1 @@ +console.log("app"); diff --git a/packages/cli/tests/fixtures/fullstack-project/build/client/ignored.txt b/packages/cli/tests/fixtures/fullstack-project/build/client/ignored.txt new file mode 100644 index 00000000..c95db47d --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/client/ignored.txt @@ -0,0 +1 @@ +should not be uploaded diff --git a/packages/cli/tests/fixtures/fullstack-project/build/client/index.html b/packages/cli/tests/fixtures/fullstack-project/build/client/index.html new file mode 100644 index 00000000..986a4a1a --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/client/index.html @@ -0,0 +1 @@ +

Hello

diff --git a/packages/cli/tests/fixtures/fullstack-project/build/server/assets/chunk-abc.js b/packages/cli/tests/fixtures/fullstack-project/build/server/assets/chunk-abc.js new file mode 100644 index 00000000..4dc009f6 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/server/assets/chunk-abc.js @@ -0,0 +1,3 @@ +export default function handler() { + return new Response("ok"); +} diff --git a/packages/cli/tests/fixtures/fullstack-project/build/server/index.js b/packages/cli/tests/fixtures/fullstack-project/build/server/index.js new file mode 100644 index 00000000..e304b45b --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/server/index.js @@ -0,0 +1,2 @@ +import handler from "./assets/chunk-abc.js"; +export default { fetch: handler }; diff --git a/packages/cli/tests/fixtures/fullstack-project/build/server/index.js.map b/packages/cli/tests/fixtures/fullstack-project/build/server/index.js.map new file mode 100644 index 00000000..c75fce6e --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/server/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sources":[],"mappings":""} diff --git a/packages/cli/tests/fixtures/fullstack-project/build/server/wrangler.json b/packages/cli/tests/fixtures/fullstack-project/build/server/wrangler.json new file mode 100644 index 00000000..7c548bc6 --- /dev/null +++ b/packages/cli/tests/fixtures/fullstack-project/build/server/wrangler.json @@ -0,0 +1,31 @@ +{ + "name": "fullstack-project", + "main": "index.js", + "no_bundle": true, + "rules": [ + { + "type": "ESModule", + "globs": ["**/*.js", "**/*.mjs"] + } + ], + "assets": { + "directory": "../client" + }, + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "MY_VAR": "my-value" + }, + "kv_namespaces": [], + "d1_databases": [], + "r2_buckets": [], + "durable_objects": { + "bindings": [] + }, + "services": [], + "queues": { + "producers": [], + "consumers": [] + }, + "hyperdrive": [] +}