diff --git a/biome.json b/biome.json index 6dc9349191..3ffbb8f0de 100644 --- a/biome.json +++ b/biome.json @@ -5,6 +5,7 @@ "frontend/**/*.{tsx,ts,js}", "!frontend/packages", "rivetkit-typescript/**/*.{tsx,ts,css}", + "cloud-cli/**/*.{ts,tsx}", "examples/**/*.{ts,tsx}", "!/**/node_modules" ], diff --git a/cloud-cli/.gitignore b/cloud-cli/.gitignore new file mode 100644 index 0000000000..f2ae0bf0a4 --- /dev/null +++ b/cloud-cli/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +bun.lockb +package-lock.json diff --git a/cloud-cli/README.md b/cloud-cli/README.md new file mode 100644 index 0000000000..aa8e8625dd --- /dev/null +++ b/cloud-cli/README.md @@ -0,0 +1,144 @@ +# Rivet Cloud CLI + +A premium command-line interface for deploying Docker images and streaming logs on [Rivet Cloud](https://hub.rivet.dev). + +## Installation + +```bash +# From the monorepo root +cd cloud-cli +bun install +bun run build + +# Run directly with Bun +bun run src/index.ts +``` + +## Authentication + +All commands require a Rivet Cloud API token. Get yours from the [Rivet dashboard](https://hub.rivet.dev) → project → **Connect** → **Rivet Cloud**. + +Set it as an environment variable (recommended): + +```bash +export RIVET_CLOUD_TOKEN=cloud_api_... +``` + +Or pass it per-command: + +```bash +rivet-cloud deploy --token cloud_api_... +``` + +--- + +## Commands + +### `rivet-cloud deploy` + +Build a Docker image and deploy it to a Rivet Cloud managed pool. + +```bash +rivet-cloud deploy [options] +``` + +| Option | Description | Default | +|---|---|---| +| `-t, --token ` | Cloud API token | `RIVET_CLOUD_TOKEN` | +| `-n, --namespace ` | Target namespace (created if absent) | `production` | +| `-p, --pool ` | Managed pool name | `default` | +| `--context ` | Docker build context directory | `.` | +| `-f, --dockerfile ` | Path to Dockerfile | auto-detect | +| `--tag ` | Docker image tag | git short SHA or timestamp | +| `--min-count ` | Minimum runner instances | `1` | +| `--max-count ` | Maximum runner instances | `5` | +| `-e, --env ` | Environment variable (repeatable) | — | +| `--command ` | Override container entrypoint | — | +| `--args ` | Space-separated args for the command | — | +| `--platform ` | Docker build platform | `linux/amd64` | +| `--api-url ` | Cloud API base URL | `https://cloud-api.rivet.dev` | + +**Examples:** + +```bash +# Deploy with defaults (builds . → namespace "production" → pool "default") +rivet-cloud deploy + +# Deploy to a PR preview namespace +rivet-cloud deploy --namespace pr-42 + +# Deploy with environment variables and custom counts +rivet-cloud deploy \ + --namespace staging \ + --min-count 2 \ + --max-count 10 \ + --env DATABASE_URL=postgres://... \ + --env API_KEY=secret +``` + +--- + +### `rivet-cloud logs` + +Stream real-time logs from a Rivet Cloud managed pool (similar to the hub.rivet.dev Logs view). + +```bash +rivet-cloud logs [options] +``` + +| Option | Description | Default | +|---|---|---| +| `-t, --token ` | Cloud API token | `RIVET_CLOUD_TOKEN` | +| `-n, --namespace ` | Target namespace | `production` | +| `-p, --pool ` | Managed pool name | `default` | +| `--filter ` | Only show lines containing this string | — | +| `--region ` | Filter by region slug (e.g. `us-west-1`) | all regions | +| `--api-url ` | Cloud API base URL | `https://cloud-api.rivet.dev` | + +Logs stream over SSE with automatic reconnection (up to 8 retries with exponential back-off). Press **Ctrl+C** to stop. + +**Examples:** + +```bash +# Stream all logs +rivet-cloud logs + +# Filter to error lines in us-west-1 +rivet-cloud logs --filter ERROR --region us-west-1 + +# Follow a PR preview namespace +rivet-cloud logs --namespace pr-42 +``` + +--- + +## Global Flags + +| Flag | Description | +|---|---| +| `-h, --help` | Show help for any command | +| `-V, --version` | Output CLI version | + +--- + +## Design Notes + +- **Authentication** — uses `RIVET_CLOUD_TOKEN` (the same secret as `rivet-dev/deploy-action`) +- **Deploy flow** — inspects token → ensures namespace → fetches registry credentials → `docker build` + `docker push` → upserts managed pool +- **Pool name** — defaults to `"default"` matching the deploy-action convention +- **Log streaming** — SSE stream with exponential back-off reconnect, mirrors `use-deployment-logs-stream.ts` in the frontend +- **Colors** — Rivet brand palette (`#FF4500` accent, `#FAFAFA` primary, `#A0A0A0` secondary) +- **Progress** — uses [tasuku](https://github.com/privatenumber/tasuku) for deploy task indication + +## Development + +```bash +# Run tests +bun test + +# Type-check +bun run check-types + +# Run CLI directly +bun run src/index.ts --help +``` diff --git a/cloud-cli/package.json b/cloud-cli/package.json new file mode 100644 index 0000000000..3c559a4935 --- /dev/null +++ b/cloud-cli/package.json @@ -0,0 +1,28 @@ +{ + "name": "@rivet-dev/cloud-cli", + "version": "0.1.0", + "description": "Rivet Cloud CLI \u2014 deploy and stream logs for Rivet Cloud managed pools", + "bin": { + "rivet-cloud": "./dist/index.js" + }, + "scripts": { + "build": "bun build src/index.ts --outdir dist --target bun --packages external", + "dev": "bun run src/index.ts", + "test": "bun test", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@rivet-gg/cloud": "*", + "chalk": "^5.4.1", + "commander": "^12.1.0", + "es-toolkit": "^1.38.0", + "tasuku": "^2.0.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.2" + }, + "engines": { + "bun": ">=1.0.0" + } +} diff --git a/cloud-cli/src/commands/deploy.ts b/cloud-cli/src/commands/deploy.ts new file mode 100644 index 0000000000..317abfb676 --- /dev/null +++ b/cloud-cli/src/commands/deploy.ts @@ -0,0 +1,341 @@ +/** + * `rivet-cloud deploy` — build a Docker image, push it to the Rivet registry, + * and update the managed pool configuration. + * + * Flow (mirrors rivet-dev/deploy-action): + * 1. Inspect the cloud token → get org + project + * 2. Ensure the target namespace exists (create if absent) + * 3. Ensure the managed pool exists (create with zero replicas if absent) + * 4. `docker login` to the Rivet registry using the cloud token + * 5. `docker build` the local context + * 6. `docker tag` + `docker push` the image + * 7. `managedPools.upsert` with the new image reference + */ + +import { type Rivet, RivetError } from "@rivet-gg/cloud"; +import type { Command } from "commander"; +import { attemptAsync, delay } from "es-toolkit"; +import task from "tasuku"; +import { resolveToken } from "../lib/auth.ts"; +import { createCloudClient, type DockerCredentials } from "../lib/client.ts"; +import { + assertDockerAvailable, + dockerBuild, + dockerLogin, + dockerTagAndPush, +} from "../lib/docker.ts"; +import { colors, detail, fatal, header } from "../utils/output.ts"; + +export interface DeployOptions { + token?: string; + namespace: string; + pool: string; + context: string; + dockerfile?: string; + tag?: string; + minCount: string; + maxCount: string; + env?: string[]; + command?: string; + args?: string; + apiUrl?: string; + platform?: string; + registryUrl?: string; +} + +const IMAGE_ID_DISPLAY_LENGTH = 19; + +export function registerDeployCommand(program: Command): void { + program + .command("deploy") + .description( + "Build a Docker image and deploy it to a Rivet Cloud managed pool", + ) + .option( + "-t, --token ", + "Cloud API token (overrides RIVET_CLOUD_TOKEN)", + ) + .option( + "-n, --namespace ", + "Target namespace (created if absent)", + "production", + ) + .option("-p, --pool ", "Managed pool name", "default") + .option("--context ", "Docker build context directory", ".") + .option("-f, --dockerfile ", "Path to Dockerfile") + .option( + "--tag ", + "Docker image tag (defaults to git short SHA or timestamp)", + ) + .option("--min-count ", "Minimum runner instances", "1") + .option("--max-count ", "Maximum runner instances", "10") + .option( + "-e, --env ", + "Environment variable (repeatable)", + (val: string, prev: string[]) => [...(prev ?? []), val], + ) + .option("--command ", "Override container entrypoint command") + .option("--args ", "Space-separated args passed to the command") + .option( + "--platform ", + "Docker build platform (e.g. linux/amd64)", + "linux/amd64", + ) + .option( + "--api-url ", + "Cloud API base URL", + "https://cloud-api.rivet.dev", + ) + .option( + "--registry-url ", + "Docker registry URL", + "registry.rivet.dev", + ) + .action(async (opts: DeployOptions) => { + await runDeploy(opts); + }); +} + +async function runDeploy(opts: DeployOptions): Promise { + const token = resolveToken(opts.token); + const client = createCloudClient({ token, baseUrl: opts.apiUrl }); + + console.log( + `\n${colors.accentBold("▶")} ${colors.label("Rivet Cloud Deploy")}\n`, + ); + + await task("Setting up", async ({ task, }) => { + const { result: identity } = await task( + "Authenticating", + async ({ setTitle, setStatus }) => { + const [error, identity] = await attemptAsync(() => client.apiTokens.inspect()); + + if (!identity) { + return fatal( + "Authentication failed. Check that RIVET_CLOUD_TOKEN is valid and not expired.", + error, + ); + }; + + setTitle( + `Authenticated`, + ); + setStatus(`${identity.organization} / ${identity.project}`) + return identity; + }, + {}, + ); + + // Step 2: Ensure namespace exists + const { + result: { namespace }, + } = await task(`Namespace: ${opts.namespace}`, async ({ setTitle, setStatus }) => { + try { + const ns = await client.namespaces.get(identity.project, opts.namespace, { + org: identity.organization, + }); + setStatus("Exists"); + return ns; + } catch (err) { + if (err instanceof RivetError && err.statusCode !== 404) { + throw err; + } + + // TODO: If the namespace doesn't exist, we should search for any similarly named namespaces and ask the user to confirm before creating a new one. This can help prevent typos from creating unintended namespaces. + // For now, we just create the namespace if it's not found. + // In the future, we may want to add a `--force` flag to skip the confirmation prompt. + } + const ns = await client.namespaces.create(identity.project, { + displayName: opts.namespace, + org: identity.organization, + }); + setTitle(`Namespace: ${ns.namespace.name}`); + setStatus("Created"); + return ns; + }); + + // Step 3: Docker availability + await assertDockerAvailable(); + + // Step 4: Ensure managed pool exists + const { + result: { managedPool }, + } = await task(`Managed pool: ${opts.pool}`, async ({ setStatus }) => { + try { + const response = await client.managedPools.get( + identity.project, + namespace.name, + opts.pool, + { org: identity.organization }, + ); + + setStatus("Exists"); + return response; + } catch (err) { + if (err instanceof RivetError && err.statusCode !== 404) { + throw err; + } + } + + const pool = await client.managedPools.upsert( + identity.project, + namespace.name, + opts.pool, + { + org: identity.organization, + displayName: opts.pool, + minCount: Number(opts.minCount), + maxCount: Number(opts.maxCount), + }, + ); + + while (true) { + const { managedPool: pool } = await client.managedPools.get( + identity.project, + namespace.name, + opts.pool, + { org: identity.organization }, + ); + if (pool.status === "ready") { + break; + } + // capitalize first letter of status for display + const status = pool.status.charAt(0).toUpperCase() + pool.status.slice(1); + setStatus(`${status}...`); + await delay(2000); + } + + setStatus("Created"); + return pool; + }); + + + + // Use the cloud token directly as Docker registry credentials. + // The Rivet registry accepts cloud tokens as the password. + const creds: DockerCredentials = { + registryUrl: opts.registryUrl ?? "registry.rivet.dev", + username: "token", + password: token, + }; + + // Step 4: Docker login + await task( + `Logging in to ${creds.registryUrl}`, + async ({ streamPreview }) => { + await dockerLogin({ ...creds, stream: streamPreview }); + }, + ); + + }); + + // Step 5: Build Docker image + const { result: buildResult } = await task( + "Building Docker image", + async ({ setTitle, streamPreview }) => { + const result = await dockerBuild({ + context: opts.context, + dockerfile: opts.dockerfile, + platform: opts.platform, + stream: streamPreview, + }); + setTitle( + `Built image ${result.imageId.slice(0, IMAGE_ID_DISPLAY_LENGTH)}`, + ); + return result; + }, + ); + + // Derive image tag + const imageTag = opts.tag ?? (await resolveImageTag()); + const repository = `${identity.project}/${opts.pool}`; + const remoteRef = `${creds.registryUrl}/${identity.organization}/${repository}:${imageTag}`; + + // Step 6: Tag and push + await task(`Pushing ${remoteRef}`, async ({ streamPreview }) => { + await dockerTagAndPush({ + localImageId: buildResult.imageId, + remoteRef, + stream: streamPreview, + }); + }); + + // Step 7: Parse environment variables + const envVars = parseEnvVars(opts.env); + + // Step 8: Upsert managed pool + await task( + `Updating managed pool "${managedPool.name}"`, + async ({ setTitle }) => { + await client.managedPools.upsert( + identity.project, + namespace.name, + managedPool.name, + { + org: identity.organization, + image: { repository, tag: imageTag }, + minCount: Number(opts.minCount), + maxCount: Number(opts.maxCount), + ...(Object.keys(envVars).length > 0 + ? { environment: envVars } + : {}), + ...(opts.command ? { command: opts.command } : {}), + ...(opts.args ? { args: opts.args.split(" ") } : {}), + }, + ); + setTitle(`Pool "${managedPool.name}" updated`); + }, + ); + + await task("Deploying", async ({ setStatus, setTitle }) => { + while (true) { + const { managedPool: pool } = await client.managedPools.get( + identity.project, + namespace.name, + managedPool.name, + { org: identity.organization }, + ); + if (pool.error || pool.status === "ready") { + break; + } + + setStatus(pool.status); + await delay(2000); + } + setTitle("Deployed!"); + }); + + console.log(""); + detail("Namespace", opts.namespace); + detail("Pool", managedPool.name); + detail("Image", `${repository}:${imageTag}`); + detail( + "Dashboard", + `https://dashboard.rivet.dev/orgs/${identity.organization}/projects/${identity.project}/ns/${opts.namespace}`, + ); + console.log(""); +} + +async function resolveImageTag(): Promise { + try { + const result = await Bun.$`git rev-parse --short HEAD`.quiet().text(); + return result.trim(); + } catch { + return `deploy-${Date.now()}`; + } +} + +function parseEnvVars(raw: string[] | undefined): Record { + if (!raw?.length) return {}; + const out: Record = {}; + for (const entry of raw) { + const eq = entry.indexOf("="); + if (eq === -1) { + fatal(`Invalid --env value "${entry}". Expected KEY=VALUE format.`); + } + const key = entry.slice(0, eq); + const value = entry.slice(eq + 1); + out[key] = value; + } + return out; +} diff --git a/cloud-cli/src/commands/logs.ts b/cloud-cli/src/commands/logs.ts new file mode 100644 index 0000000000..33bf0e8d08 --- /dev/null +++ b/cloud-cli/src/commands/logs.ts @@ -0,0 +1,172 @@ +/** + * `rivet-cloud logs` — stream real-time logs from a Rivet Cloud managed pool. + * + * The implementation mirrors the frontend's use-deployment-logs-stream.ts: + * it opens an SSE connection to the Cloud API log endpoint via RivetSse from + * @rivet-gg/cloud, reconnects on failure with exponential back-off (up to 8 + * retries), and prints every log line to stdout. + */ + +import type { Command } from "commander"; +import { RivetSse, type Rivet } from "@rivet-gg/cloud"; +import { createCloudClient } from "../lib/client.ts"; +import { resolveToken } from "../lib/auth.ts"; +import { + colors, + detail, + fatal, + formatRegion, + formatTimestamp, + header, +} from "../utils/output.ts"; + +export interface LogsOptions { + token?: string; + namespace: string; + pool: string; + filter?: string; + region?: string; + apiUrl?: string; +} + +const MAX_RETRIES = 8; +const BASE_DELAY_MS = 1_000; + +export function registerLogsCommand(program: Command): void { + program + .command("logs") + .description("Stream real-time logs from a Rivet Cloud managed pool") + .option("-t, --token ", "Cloud API token (overrides RIVET_CLOUD_TOKEN)") + .option("-n, --namespace ", "Target namespace", "production") + .option("-p, --pool ", "Managed pool name", "default") + .option("--filter ", "Only show log lines containing this string") + .option("--region ", "Filter logs by region slug") + .option("--api-url ", "Cloud API base URL", "https://cloud-api.rivet.dev") + .action(async (opts: LogsOptions) => { + await runLogs(opts); + }); +} + +async function runLogs(opts: LogsOptions): Promise { + const token = resolveToken(opts.token); + const client = createCloudClient({ token, baseUrl: opts.apiUrl }); + const apiUrl = opts.apiUrl ?? "https://cloud-api.rivet.dev"; + + // Resolve org + project from token + let identity: { project: string; organization: string }; + + try { + identity = await client.apiTokens.inspect(); + } catch (err: unknown) { + fatal("Authentication failed. Check that RIVET_CLOUD_TOKEN is valid.", err); + } + + const { project, organization: org } = identity; + + console.log(""); + header("Rivet Cloud Logs"); + detail("Project", project); + detail("Namespace", opts.namespace); + detail("Pool", opts.pool); + if (opts.filter) detail("Filter", opts.filter); + if (opts.region) detail("Region", opts.region); + console.log( + `\n${colors.dim("Streaming logs — press Ctrl+C to stop.")}\n`, + ); + + const controller = new AbortController(); + + process.on("SIGINT", () => { + controller.abort(); + console.log(`\n${colors.dim("Stopped.")}`); + process.exit(0); + }); + + await streamWithRetry(token, apiUrl, project, org, opts, controller.signal); +} + +async function streamWithRetry( + token: string, + apiUrl: string, + project: string, + org: string, + opts: LogsOptions, + signal: AbortSignal, +): Promise { + const streamOptions = { + environment: "", + baseUrl: apiUrl, + token, + }; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (signal.aborted) return; + + try { + const stream = RivetSse.streamLogs( + streamOptions, + project, + opts.namespace, + opts.pool, + { + contains: opts.filter, + region: opts.region, + abortSignal: signal, + }, + ); + + for await (const event of stream) { + if (signal.aborted) return; + + if (event.event === "connected") { + // Connection established — no action needed. + } else if (event.event === "end") { + return; + } else if (event.event === "error") { + throw new Error(event.data.message); + } else if (event.event === "log") { + printLogLine(event.data); + } + } + + // Stream ended cleanly + return; + } catch (err: unknown) { + if (signal.aborted) return; + if ((err as Error).name === "AbortError") return; + + if (attempt < MAX_RETRIES) { + const delay = BASE_DELAY_MS * 2 ** attempt; + console.error( + colors.dim( + ` Connection lost. Reconnecting in ${delay}ms… (attempt ${attempt + 1}/${MAX_RETRIES})`, + ), + ); + await sleep(delay, signal); + } else { + fatal( + `Failed to connect to log stream after ${MAX_RETRIES} retries.`, + err, + ); + } + } + } +} + +function printLogLine(data: Rivet.LogStreamEvent.Log["data"]): void { + const ts = formatTimestamp(data.timestamp); + const region = formatRegion(data.region); + const msg = data.message; + console.log(`${ts} ${region} ${msg}`); +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(resolve, ms); + signal.addEventListener("abort", () => { + clearTimeout(timeout); + resolve(); + }); + }); +} + diff --git a/cloud-cli/src/index.ts b/cloud-cli/src/index.ts new file mode 100644 index 0000000000..ffacedfe24 --- /dev/null +++ b/cloud-cli/src/index.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env bun +/** + * Rivet Cloud CLI — entry point. + * + * Usage: + * rivet-cloud deploy [options] + * rivet-cloud logs [options] + * rivet-cloud --help + * rivet-cloud --version + */ + +import { Command } from "commander"; +import { registerDeployCommand } from "./commands/deploy.ts"; +import { registerLogsCommand } from "./commands/logs.ts"; +import { colors } from "./utils/output.ts"; + +const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json(); + +const program = new Command(); + +program + .name("rivet-cloud") + .description( + [ + colors.accentBold("Rivet Cloud CLI"), + colors.secondary( + "Deploy Docker images and stream logs for Rivet Cloud managed pools.", + ), + "", + colors.dim("Docs: https://rivet.dev/docs"), + colors.dim("Dashboard: https://hub.rivet.dev"), + ].join("\n"), + ) + .version(pkg.version, "-V, --version", "Output the current version") + .helpOption("-h, --help", "Display help for command"); + +registerDeployCommand(program); +registerLogsCommand(program); + +// Show help when no command is provided +if (process.argv.length <= 2) { + program.help(); +} + +program.parseAsync(process.argv); diff --git a/cloud-cli/src/lib/auth.ts b/cloud-cli/src/lib/auth.ts new file mode 100644 index 0000000000..823a7c3503 --- /dev/null +++ b/cloud-cli/src/lib/auth.ts @@ -0,0 +1,31 @@ +/** + * Authentication helpers. + * + * The CLI primarily uses RIVET_CLOUD_TOKEN (a static API token) — the same + * secret used by the rivet-dev/deploy-action. This token is passed to the + * Cloud API as a Bearer token. + * + * For interactive browser-based Clerk auth flows, the pattern would mirror + * what the frontend does (getToken from a Clerk session). We expose the + * low-level helper here so commands can easily call it. + */ + +import { colors } from "../utils/output.ts"; + +export function resolveToken(cliToken: string | undefined): string { + const token = cliToken ?? process.env.RIVET_CLOUD_TOKEN; + if (!token) { + console.error( + colors.error( + "No token found. Provide RIVET_CLOUD_TOKEN env var or pass --token .", + ), + ); + console.error( + colors.dim( + " Get your token from https://hub.rivet.dev → project → Connect → Rivet Cloud", + ), + ); + process.exit(1); + } + return token; +} diff --git a/cloud-cli/src/lib/client.ts b/cloud-cli/src/lib/client.ts new file mode 100644 index 0000000000..9c031e0e4d --- /dev/null +++ b/cloud-cli/src/lib/client.ts @@ -0,0 +1,29 @@ +/** + * Cloud client factory. + * + * Creates a typed RivetClient from @rivet-gg/cloud for communicating with the + * Rivet Cloud REST API (https://cloud-api.rivet.dev). + */ + +import { RivetClient, RivetError } from "@rivet-gg/cloud"; + +export { RivetClient, RivetError }; + +export function createCloudClient(opts: { + token: string; + baseUrl?: string; +}): RivetClient { + return new RivetClient({ + environment: "", + baseUrl: opts.baseUrl ?? "https://cloud-api.rivet.dev", + token: opts.token, + }); +} + +/** Docker registry credentials (not yet part of the SDK — use token-based auth). */ +export interface DockerCredentials { + registryUrl: string; + username: string; + password: string; +} + diff --git a/cloud-cli/src/lib/docker.ts b/cloud-cli/src/lib/docker.ts new file mode 100644 index 0000000000..9649d46878 --- /dev/null +++ b/cloud-cli/src/lib/docker.ts @@ -0,0 +1,169 @@ +/** + * Docker helpers — build, login, tag, and push Docker images. + * Uses Bun's built-in shell operator for subprocess execution. + */ + +import type { Writable } from "node:stream"; +import { $ } from "bun"; +import { colors, error, fatal } from "../utils/output.ts"; + +export interface BuildResult { + imageId: string; +} + +/** + * Run `docker build` and return the local image ID. + * We use `--iidfile` to capture the image digest reliably. + */ +export async function dockerBuild(opts: { + context: string; + dockerfile?: string; + platform?: string; + stream?: Writable; +}): Promise { + const iidFile = `/tmp/rivet-cloud-cli-iid-${Date.now()}`; + + const dockerfileArgs = opts.dockerfile ? ["-f", opts.dockerfile] : []; + const platformArgs = opts.platform ? ["--platform", opts.platform] : []; + + const proc = Bun.spawn([ + "docker", + "build", + ...dockerfileArgs, + ...platformArgs, + "--iidfile", + iidFile, + opts.context, + ]); + + if (opts.stream) { + pipeToWritable(proc.stdout, opts.stream); + pipeToWritable(proc.stderr, opts.stream); + } + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw error( + `Docker build failed. Make sure Docker is running and a Dockerfile exists in ${opts.context}.`, + `exit code: ${exitCode}`, + ); + } + + const imageId = (await Bun.file(iidFile).text()).trim(); + return { imageId }; +} + +/** + * Login to a Docker registry using `docker login`. + */ +export async function dockerLogin(opts: { + registryUrl: string; + username: string; + password: string; + stream?: Writable; +}): Promise { + const command = [ + "docker", + "login", + opts.registryUrl, + "--username", + opts.username, + "--password-stdin", + ]; + + opts?.stream?.write(`$ ${command.join(" ")}\n`); + + const proc = Bun.spawn( + command, + { + stdin: new TextEncoder().encode(opts.password), + stderr: "pipe", + stdout: "pipe", + }, + ); + + if (opts.stream) { + pipeToWritable(proc.stdout, opts.stream); + pipeToWritable(proc.stderr, opts.stream); + } + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw error( + `Docker login failed for registry ${opts.registryUrl}.`, + `exit code: ${exitCode}`, + ); + } +} + +/** + * Tag a local image and push it to a remote registry. + * Returns the full remote image reference (e.g. registry.rivet.dev/org/proj/repo:tag). + */ +export async function dockerTagAndPush(opts: { + localImageId: string; + remoteRef: string; + stream?: Writable; +}): Promise { + const tagProc = Bun.spawn([ + "docker", + "tag", + opts.localImageId, + opts.remoteRef, + ]); + + if (opts.stream) { + pipeToWritable(tagProc.stdout, opts.stream); + pipeToWritable(tagProc.stderr, opts.stream); + } + + const tagExitCode = await tagProc.exited; + if (tagExitCode !== 0) { + throw error( + `Docker tag failed for ${opts.remoteRef}.`, + `exit code: ${tagExitCode}`, + ); + } + + const pushProc = Bun.spawn(["docker", "push", opts.remoteRef]); + + if (opts.stream) { + pipeToWritable(pushProc.stdout, opts.stream); + pipeToWritable(pushProc.stderr, opts.stream); + } + + const pushExitCode = await pushProc.exited; + if (pushExitCode !== 0) { + throw error( + `Docker push failed for ${opts.remoteRef}.`, + `exit code: ${pushExitCode}`, + ); + } +} + +/** + * Check whether the `docker` binary is reachable. + */ +export async function assertDockerAvailable(): Promise { + try { + await $`docker info`.quiet(); + } catch { + fatal( + "Docker is not available. Please install Docker and make sure it's running.", + ); + } +} + +function pipeToWritable( + source: ReadableStream | undefined, + dest: Writable, +): void { + if (!source) return; + source.pipeTo( + new WritableStream({ + write(chunk) { + dest.write(chunk); + }, + }), + ); +} diff --git a/cloud-cli/src/utils/output.ts b/cloud-cli/src/utils/output.ts new file mode 100644 index 0000000000..6966003b47 --- /dev/null +++ b/cloud-cli/src/utils/output.ts @@ -0,0 +1,94 @@ +/** + * Output helpers — Rivet brand colors and formatted messages. + * + * Rivet brand colors (from tailwind.config.mjs): + * accent #FF4500 (orange-red) + * text-primary #FAFAFA + * text-secondary #A0A0A0 + * background #000000 + */ + +import chalk from "chalk"; + +export const colors = { + /** Rivet brand accent — #FF4500 orange-red */ + accent: chalk.hex("#FF4500"), + /** Rivet brand accent, bold */ + accentBold: chalk.hex("#FF4500").bold, + /** Bright primary text */ + primary: chalk.hex("#FAFAFA"), + /** Secondary / muted text */ + secondary: chalk.hex("#A0A0A0"), + /** Alias for secondary */ + dim: chalk.hex("#A0A0A0"), + /** Success green */ + success: chalk.hex("#4ade80"), + /** Warning yellow */ + warning: chalk.hex("#facc15"), + /** Error red */ + error: chalk.hex("#f87171").bold, + /** Bold white label */ + label: chalk.white.bold, + /** Monospace code */ + code: chalk.cyan, +}; + +/** Print the Rivet logo / wordmark prefix. */ +export function logo(): string { + return colors.accentBold("▶ rivet"); +} + +/** Print a section header line. */ +export function header(text: string): void { + console.log(`\n${colors.accentBold("◆")} ${colors.label(text)}`); +} + +/** Print a success line with a checkmark. */ +export function success(text: string): void { + console.log(` ${colors.success("✓")} ${text}`); +} + +/** Print a detail / sub-item line. */ +export function detail(key: string, value: string): void { + console.log(` ${colors.dim(key + ":")} ${colors.primary(value)}`); +} + +/** Print an info line. */ +export function info(text: string): void { + console.log(` ${colors.secondary("·")} ${colors.secondary(text)}`); +} + +/** Print an error and exit. */ +export function fatal(text: string, cause?: unknown): never { + console.error(`\n${colors.error("✗ Error:")} ${text}`); + if (cause) { + const causeMsg = cause instanceof Error ? cause.message : String(cause); + console.error(` ${colors.dim(causeMsg)}`); + } + process.exit(1); +} + +/** Create an error with a cause chain. */ +export function error(message: string, cause: unknown): Error { + const error = new Error(message); + error.cause = cause; + return error; +} + +/** Format a log timestamp for display. */ +export function formatTimestamp(iso: string): string { + try { + const d = new Date(iso); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const mm = String(d.getUTCMinutes()).padStart(2, "0"); + const ss = String(d.getUTCSeconds()).padStart(2, "0"); + return colors.dim(`${hh}:${mm}:${ss}`); + } catch { + return colors.dim(iso); + } +} + +/** Format a region slug for display. */ +export function formatRegion(region: string): string { + return colors.secondary(`[${region}]`); +} diff --git a/cloud-cli/tests/client.test.ts b/cloud-cli/tests/client.test.ts new file mode 100644 index 0000000000..cdc1e5710c --- /dev/null +++ b/cloud-cli/tests/client.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for the Cloud CLI. + * + * These tests verify that: + * 1. `createCloudClient` — creates an @rivet-gg/cloud RivetClient correctly. + * 2. `resolveToken` — picks up the token from the CLI flag or env var. + */ + +import { describe, expect, it, mock, afterEach } from "bun:test"; +import { RivetClient, RivetError } from "@rivet-gg/cloud"; +import { createCloudClient } from "../src/lib/client.ts"; + +// --------------------------------------------------------------------------- +// createCloudClient factory +// --------------------------------------------------------------------------- + +describe("createCloudClient", () => { + it("returns a RivetClient instance", () => { + const client = createCloudClient({ token: "tok" }); + expect(client).toBeInstanceOf(RivetClient); + }); + + it("uses the provided base URL", () => { + const client = createCloudClient({ + token: "tok", + baseUrl: "https://custom-api.rivet.dev", + }); + expect(client).toBeInstanceOf(RivetClient); + }); + + it("falls back to the default Cloud API URL when none is provided", () => { + const client = createCloudClient({ token: "tok" }); + expect(client).toBeInstanceOf(RivetClient); + }); +}); + +// --------------------------------------------------------------------------- +// SDK error handling (RivetError) +// --------------------------------------------------------------------------- + +describe("RivetClient error handling", () => { + it("throws RivetError for non-OK responses", async () => { + const originalFetch = global.fetch; + global.fetch = mock(async () => + new Response(JSON.stringify({ message: "Unauthorized", code: "UNAUTHORIZED" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as typeof fetch; + + const client = createCloudClient({ token: "invalid" }); + try { + await client.apiTokens.inspect(); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(RivetError); + expect((err as RivetError).statusCode).toBe(401); + } finally { + global.fetch = originalFetch; + } + }); + + it("returns project and organization from inspect()", async () => { + const originalFetch = global.fetch; + global.fetch = mock(async () => + new Response( + JSON.stringify({ project: "my-project", organization: "my-org" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) as unknown as typeof fetch; + + const client = createCloudClient({ token: "valid-token" }); + const result = await client.apiTokens.inspect(); + expect(result.project).toBe("my-project"); + expect(result.organization).toBe("my-org"); + + global.fetch = originalFetch; + }); + + it("throws RivetError with statusCode 404 when namespace not found", async () => { + const originalFetch = global.fetch; + global.fetch = mock(async () => + new Response(JSON.stringify({ message: "Not found", code: "NOT_FOUND" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as typeof fetch; + + const client = createCloudClient({ token: "tok" }); + try { + await client.namespaces.get("proj", "ns-name", { org: "org" }); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(RivetError); + expect((err as RivetError).statusCode).toBe(404); + } finally { + global.fetch = originalFetch; + } + }); + + it("sends a PUT request when upserting a managed pool", async () => { + const originalFetch = global.fetch; + let capturedUrl: string | undefined; + let capturedMethod: string | undefined; + let capturedBody: Record | undefined; + // biome-ignore lint/suspicious/noExplicitAny: test mock signature + global.fetch = mock(async (url: string | URL, init?: RequestInit) => { + capturedUrl = String(url); + capturedMethod = init?.method; + capturedBody = init?.body ? JSON.parse(init.body as string) : undefined; + return new Response( + JSON.stringify({ + managedPool: { + name: "default", + status: "ready", + config: { displayName: "default", minCount: 1, maxCount: 5 }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + const client = createCloudClient({ token: "tok" }); + await client.managedPools.upsert("proj", "production", "default", { + org: "my-org", + image: { repository: "proj/default", tag: "abc123" }, + minCount: 1, + maxCount: 5, + }); + + expect(capturedMethod).toBe("PUT"); + expect(capturedUrl).toContain("/projects/proj/namespaces/production/managed-pools/default"); + const image = capturedBody?.image as Record | undefined; + expect(image?.tag).toBe("abc123"); + expect(capturedBody?.minCount).toBe(1); + + global.fetch = originalFetch; + }); +}); + +// --------------------------------------------------------------------------- +// auth helpers +// --------------------------------------------------------------------------- + +describe("resolveToken", () => { + const originalEnv = process.env.RIVET_CLOUD_TOKEN; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.RIVET_CLOUD_TOKEN; + } else { + process.env.RIVET_CLOUD_TOKEN = originalEnv; + } + }); + + it("returns the CLI-supplied token", async () => { + const { resolveToken } = await import("../src/lib/auth.ts"); + delete process.env.RIVET_CLOUD_TOKEN; + const tok = resolveToken("my-explicit-token"); + expect(tok).toBe("my-explicit-token"); + }); + + it("falls back to RIVET_CLOUD_TOKEN env var", async () => { + process.env.RIVET_CLOUD_TOKEN = "env-token"; + const { resolveToken } = await import("../src/lib/auth.ts"); + const tok = resolveToken(undefined); + expect(tok).toBe("env-token"); + }); +}); + diff --git a/cloud-cli/tsconfig.json b/cloud-cli/tsconfig.json new file mode 100644 index 0000000000..4aa916104c --- /dev/null +++ b/cloud-cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["bun-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*", "tests/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 733d3061c0..66454f968b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,31 @@ importers: specifier: ^8.8.5 version: 8.8.5 + cloud-cli: + dependencies: + '@rivet-gg/cloud': + specifier: https://pkg.pr.new/rivet-dev/cloud/@rivet-gg/cloud@413534c + version: https://pkg.pr.new/rivet-dev/cloud/@rivet-gg/cloud@413534c + chalk: + specifier: ^5.4.1 + version: 5.6.2 + commander: + specifier: ^12.1.0 + version: 12.1.0 + es-toolkit: + specifier: ^1.38.0 + version: 1.45.1 + tasuku: + specifier: ^2.0.0 + version: 2.3.0 + devDependencies: + '@types/bun': + specifier: latest + version: 1.3.11 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + engine: {} engine/docker/template: @@ -2462,7 +2487,7 @@ importers: version: 4.3.19(react@19.1.0)(zod@3.25.76) drizzle-orm: specifier: ^0.44.2 - version: 0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.0(@types/react@19.2.13))(kysely@0.28.8)(pg@8.17.2)(sql.js@1.13.0) + version: 0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.11)(kysely@0.28.8)(pg@8.17.2)(sql.js@1.13.0) fdb-tuple: specifier: ^1.0.0 version: 1.0.0 @@ -2633,7 +2658,7 @@ importers: version: 4.3.19(react@19.1.0)(zod@3.25.76) drizzle-orm: specifier: ^0.44.2 - version: 0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.0(@types/react@19.2.13))(kysely@0.28.8)(pg@8.17.2)(sql.js@1.13.0) + version: 0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.11)(kysely@0.28.8)(pg@8.17.2)(sql.js@1.13.0) fdb-tuple: specifier: ^1.0.0 version: 1.0.0 @@ -2786,7 +2811,7 @@ importers: version: 0.31.5 drizzle-orm: specifier: ^0.44.2 - version: 0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.0(@types/react@19.2.13))(kysely@0.28.8)(pg@8.17.2)(sql.js@1.13.0) + version: 0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.11)(kysely@0.28.8)(pg@8.17.2)(sql.js@1.13.0) devDependencies: '@types/node': specifier: ^22.13.9 @@ -3976,7 +4001,7 @@ importers: version: 0.31.5 drizzle-orm: specifier: ^0.44.2 - version: 0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.0(@types/react@19.2.13))(kysely@0.28.8)(pg@8.17.2)(sql.js@1.13.0) + version: 0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.11)(kysely@0.28.8)(pg@8.17.2)(sql.js@1.13.0) get-port: specifier: ^7.1.0 version: 7.1.0 @@ -4566,7 +4591,7 @@ importers: version: 19.2.13 drizzle-orm: specifier: ^0.38.0 - version: 0.38.4(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/react@19.2.13)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.0(@types/react@19.2.13))(kysely@0.28.8)(pg@8.17.2)(react@19.1.0)(sql.js@1.13.0) + version: 0.38.4(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/react@19.2.13)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.11)(kysely@0.28.8)(pg@8.17.2)(react@19.1.0)(sql.js@1.13.0) typescript: specifier: ^5.7.3 version: 5.9.3 @@ -10065,6 +10090,9 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/bun@1.3.11': + resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==} + '@types/canvas-confetti@1.9.0': resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} @@ -11157,10 +11185,8 @@ packages: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} - bun-types@1.3.0: - resolution: {integrity: sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ==} - peerDependencies: - '@types/react': ^19 + bun-types@1.3.11: + resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -12356,6 +12382,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -16587,6 +16616,9 @@ packages: engines: {node: '>=18'} deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + tasuku@2.3.0: + resolution: {integrity: sha512-9Jtk+XAnttslCw4i9RceSZGr2PT5TLn0vUyWnoP2SdlSYzkiYRY6kB5qnPi5IQ/Em8e4oBow1rpaA39iijTIrA==} + terminal-link@2.1.1: resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} engines: {node: '>=8'} @@ -21196,7 +21228,7 @@ snapshots: dependencies: '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.10 + '@types/node': 22.19.15 '@types/yargs': 15.0.19 chalk: 4.1.2 @@ -24642,6 +24674,10 @@ snapshots: '@types/node': 22.19.15 optional: true + '@types/bun@1.3.11': + dependencies: + bun-types: 1.3.11 + '@types/canvas-confetti@1.9.0': {} '@types/chai@5.2.3': @@ -24651,7 +24687,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 22.19.11 + '@types/node': 22.19.15 '@types/d3-array@3.2.1': {} @@ -24861,7 +24897,7 @@ snapshots: '@types/mysql@2.15.27': dependencies: - '@types/node': 22.19.11 + '@types/node': 22.19.15 '@types/nlcst@2.0.3': dependencies: @@ -24911,7 +24947,7 @@ snapshots: '@types/pg@8.15.6': dependencies: - '@types/node': 22.19.11 + '@types/node': 22.19.15 pg-protocol: 1.11.0 pg-types: 2.2.0 @@ -24951,7 +24987,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 22.19.10 + '@types/node': 22.19.15 '@types/semver@7.7.1': {} @@ -24973,7 +25009,7 @@ snapshots: '@types/tedious@4.0.14': dependencies: - '@types/node': 22.19.11 + '@types/node': 22.19.15 '@types/three@0.171.0': dependencies: @@ -26190,11 +26226,9 @@ snapshots: buildcheck@0.0.7: optional: true - bun-types@1.3.0(@types/react@19.2.13): + bun-types@1.3.11: dependencies: '@types/node': 22.19.15 - '@types/react': 19.2.13 - optional: true bundle-name@4.1.0: dependencies: @@ -27109,7 +27143,7 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/react@19.2.13)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.0(@types/react@19.2.13))(kysely@0.28.8)(pg@8.17.2)(react@19.1.0)(sql.js@1.13.0): + drizzle-orm@0.38.4(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/react@19.2.13)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.11)(kysely@0.28.8)(pg@8.17.2)(react@19.1.0)(sql.js@1.13.0): optionalDependencies: '@cloudflare/workers-types': 4.20251014.0 '@opentelemetry/api': 1.9.0 @@ -27118,13 +27152,13 @@ snapshots: '@types/react': 19.2.13 '@types/sql.js': 1.4.9 better-sqlite3: 11.10.0 - bun-types: 1.3.0(@types/react@19.2.13) + bun-types: 1.3.11 kysely: 0.28.8 pg: 8.17.2 react: 19.1.0 sql.js: 1.13.0 - drizzle-orm@0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.0(@types/react@19.2.13))(kysely@0.28.8)(pg@8.17.2)(sql.js@1.13.0): + drizzle-orm@0.44.6(@cloudflare/workers-types@4.20251014.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@11.10.0)(bun-types@1.3.11)(kysely@0.28.8)(pg@8.17.2)(sql.js@1.13.0): optionalDependencies: '@cloudflare/workers-types': 4.20251014.0 '@opentelemetry/api': 1.9.0 @@ -27132,7 +27166,7 @@ snapshots: '@types/pg': 8.16.0 '@types/sql.js': 1.4.9 better-sqlite3: 11.10.0 - bun-types: 1.3.0(@types/react@19.2.13) + bun-types: 1.3.11 kysely: 0.28.8 pg: 8.17.2 sql.js: 1.13.0 @@ -27233,6 +27267,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.45.1: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -32613,6 +32649,8 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + tasuku@2.3.0: {} + terminal-link@2.1.1: dependencies: ansi-escapes: 4.3.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 288b453c43..4bcc43c7d0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - cloud-cli - engine - engine/docker/template - engine/sdks/typescript/api-full