From d520df29d41193ca4cbc729cded8be3e3c26db5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Mon, 13 Apr 2026 00:00:13 +0000 Subject: [PATCH] feat(cli): add deploy command + CF 1042 retry (#218) - deploy: one-click OGraph deployment to Cloudflare (D1 + Worker) - cloudflare.ts: CF REST API client (verify, accounts, D1, Worker upload) - client.ts: auto-retry on CF 1042 edge propagation delay - readiness check: require 3 consecutive health OKs before declaring ready - 49 CLI tests passing (18 new deploy tests) --- package-lock.json | 119 +++++++-- packages/cli/src/client.ts | 57 +++-- packages/cli/src/cloudflare.ts | 206 +++++++++++++++ packages/cli/src/commands/deploy.ts | 379 ++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/test/client.test.ts | 5 +- packages/cli/test/deploy.test.ts | 289 +++++++++++++++++++++ 7 files changed, 1014 insertions(+), 43 deletions(-) create mode 100644 packages/cli/src/cloudflare.ts create mode 100644 packages/cli/src/commands/deploy.ts create mode 100644 packages/cli/test/deploy.test.ts diff --git a/package-lock.json b/package-lock.json index 240ad75..101cf4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -475,11 +475,13 @@ "cpu": [ "arm64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -508,11 +510,13 @@ "cpu": [ "arm64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -541,11 +545,13 @@ "cpu": [ "arm64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -3225,6 +3231,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3246,6 +3253,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3267,6 +3275,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3288,6 +3297,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3309,6 +3319,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3330,6 +3341,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3351,6 +3363,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3372,6 +3385,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3393,6 +3407,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3414,6 +3429,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3435,6 +3451,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5168,11 +5185,13 @@ "cpu": [ "ppc64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -5184,11 +5203,13 @@ "cpu": [ "arm" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -5200,11 +5221,13 @@ "cpu": [ "arm64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -5216,11 +5239,13 @@ "cpu": [ "x64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -5232,11 +5257,13 @@ "cpu": [ "arm64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -5248,11 +5275,13 @@ "cpu": [ "x64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -5264,11 +5293,13 @@ "cpu": [ "arm64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -5280,11 +5311,13 @@ "cpu": [ "x64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -5296,11 +5329,13 @@ "cpu": [ "arm" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -5312,11 +5347,13 @@ "cpu": [ "arm64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -5328,11 +5365,13 @@ "cpu": [ "ia32" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -5344,11 +5383,13 @@ "cpu": [ "loong64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -5360,11 +5401,13 @@ "cpu": [ "mips64el" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -5376,11 +5419,13 @@ "cpu": [ "ppc64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -5392,11 +5437,13 @@ "cpu": [ "riscv64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -5408,11 +5455,13 @@ "cpu": [ "s390x" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -5424,11 +5473,13 @@ "cpu": [ "x64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -5440,11 +5491,13 @@ "cpu": [ "x64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -5456,11 +5509,13 @@ "cpu": [ "x64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -5472,11 +5527,13 @@ "cpu": [ "x64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -5488,11 +5545,13 @@ "cpu": [ "arm64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -5504,11 +5563,13 @@ "cpu": [ "ia32" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -5520,11 +5581,13 @@ "cpu": [ "x64" ], - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -5643,9 +5706,11 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "extraneous": true, + "dev": true, "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "esbuild": "bin/esbuild" }, diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 8d5c91b..f4fcdf4 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -88,7 +88,7 @@ export class OGraphClient { } } - private async request(path: string, options: RequestInit = {}): Promise { + private async request(path: string, options: RequestInit = {}, retries = 2): Promise { const url = `${this.endpoint}${path}` const headers = new Headers(options.headers) @@ -97,25 +97,52 @@ export class OGraphClient { headers.set('Content-Type', 'application/json') } - try { - const response = await fetch(url, { ...options, headers }) - const result = await response.json() + let lastError: Error | undefined + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const response = await fetch(url, { ...options, headers }) - if (!response.ok) { - if (response.status === 401) { - throw new Error('Authentication failed. Check your token.') + // CF 1042 returns 404 with HTML — detect and retry + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) { + const text = await response.text() + if (text.includes('1042') || text.includes('DOCTYPE')) { + lastError = new Error('Worker not reachable (CF 1042 — edge propagation delay)') + if (attempt < retries) { + await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))) + continue + } + throw lastError + } + throw new Error(`Unexpected response: ${text.slice(0, 100)}`) } - const errorMessage = (result as { error?: string }).error ?? `HTTP ${response.status}: ${response.statusText}` - throw new Error(errorMessage) - } - return result as T - } catch (error) { - if (error instanceof Error && error.message.includes('fetch')) { - throw new Error(`Cannot reach OGraph API at ${this.endpoint}`) + const result = await response.json() + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Authentication failed. Check your token.') + } + const errorMessage = + (result as { error?: string }).error ?? `HTTP ${response.status}: ${response.statusText}` + throw new Error(errorMessage) + } + + return result as T + } catch (error) { + if (error instanceof Error && error.message.includes('fetch')) { + throw new Error(`Cannot reach OGraph API at ${this.endpoint}`) + } + // Retry on CF 1042 / propagation errors + if (error instanceof Error && error.message.includes('1042') && attempt < retries) { + await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))) + lastError = error + continue + } + throw error } - throw error } + throw lastError ?? new Error('Request failed after retries') } // ─── Object-Defs ─────────────────────────────────────────────────────────────── diff --git a/packages/cli/src/cloudflare.ts b/packages/cli/src/cloudflare.ts new file mode 100644 index 0000000..d2b9bc3 --- /dev/null +++ b/packages/cli/src/cloudflare.ts @@ -0,0 +1,206 @@ +// Cloudflare REST API client for OGraph deploy +// Direct HTTP calls — no wrangler dependency + +// ─── Types ───────────────────────────────────────────────────────────────────── + +export interface CfAccount { + id: string + name: string +} + +export interface CfD1Database { + uuid: string + name: string + created_at?: string +} + +export interface CfApiResponse { + success: boolean + result: T + errors: Array<{ code: number; message: string }> + messages: Array<{ code: number; message: string }> +} + +export interface CfD1QueryResult { + success: boolean + results: unknown[] + meta?: { changes: number } +} + +export interface CfWorkerScript { + id: string + etag?: string + created_on?: string + modified_on?: string +} + +// ─── Client ──────────────────────────────────────────────────────────────────── + +const CF_BASE = 'https://api.cloudflare.com/client/v4' + +export class CloudflareClient { + constructor(private readonly apiToken: string) {} + + private async request(path: string, options: RequestInit = {}): Promise { + const url = `${CF_BASE}${path}` + const headers = new Headers(options.headers) + headers.set('Authorization', `Bearer ${this.apiToken}`) + if (options.body && typeof options.body === 'string') { + headers.set('Content-Type', 'application/json') + } + + const response = await fetch(url, { ...options, headers }) + const json = (await response.json()) as CfApiResponse + + if (!json.success) { + const msgs = json.errors.map((e) => `[${e.code}] ${e.message}`).join('; ') + throw new Error(`Cloudflare API error: ${msgs}`) + } + + return json.result + } + + // ─── Token Verification ──────────────────────────────────────────────────── + + async verifyToken(): Promise<{ id: string; status: string }> { + return this.request<{ id: string; status: string }>('/user/tokens/verify') + } + + // ─── Accounts ────────────────────────────────────────────────────────────── + + async listAccounts(): Promise { + return this.request('/accounts') + } + + // ─── D1 Database ─────────────────────────────────────────────────────────── + + async createD1Database(accountId: string, name: string): Promise { + return this.request(`/accounts/${accountId}/d1/database`, { + method: 'POST', + body: JSON.stringify({ name }), + }) + } + + async queryD1(accountId: string, databaseId: string, sql: string): Promise { + return this.request(`/accounts/${accountId}/d1/database/${databaseId}/query`, { + method: 'POST', + body: JSON.stringify({ sql }), + }) + } + + // ─── Workers ─────────────────────────────────────────────────────────────── + + async uploadWorker( + accountId: string, + scriptName: string, + scriptContent: string, + bindings: { + d1DatabaseId: string + d1BindingName: string + apiToken: string + version: string + }, + ): Promise { + const metadata = { + main_module: 'worker.js', + bindings: [ + { + type: 'd1', + name: bindings.d1BindingName, + id: bindings.d1DatabaseId, + }, + { + type: 'secret_text', + name: 'API_TOKEN', + text: bindings.apiToken, + }, + { + type: 'plain_text', + name: 'VERSION', + text: bindings.version, + }, + ], + compatibility_date: '2026-04-03', + } + + // Build multipart form data + const boundary = `----OGraphDeploy${Date.now()}` + const parts: string[] = [] + + // Metadata part + parts.push(`--${boundary}`) + parts.push('Content-Disposition: form-data; name="metadata"; filename="metadata.json"') + parts.push('Content-Type: application/json') + parts.push('') + parts.push(JSON.stringify(metadata)) + + // Script part + parts.push(`--${boundary}`) + parts.push('Content-Disposition: form-data; name="worker.js"; filename="worker.js"') + parts.push('Content-Type: application/javascript+module') + parts.push('') + parts.push(scriptContent) + + parts.push(`--${boundary}--`) + + const body = parts.join('\r\n') + + const url = `${CF_BASE}/accounts/${accountId}/workers/scripts/${scriptName}` + const headers = new Headers() + headers.set('Authorization', `Bearer ${this.apiToken}`) + headers.set('Content-Type', `multipart/form-data; boundary=${boundary}`) + + const response = await fetch(url, { method: 'PUT', headers, body }) + const json = (await response.json()) as CfApiResponse + + if (!json.success) { + const msgs = json.errors.map((e) => `[${e.code}] ${e.message}`).join('; ') + throw new Error(`Worker upload failed: ${msgs}`) + } + + return json.result + } + + // ─── Workers Subdomain ──────────────────────────────────────────────────── + + async getWorkersSubdomain(accountId: string): Promise { + try { + const result = await this.request<{ subdomain: string }>(`/accounts/${accountId}/workers/subdomain`) + return result.subdomain ?? null + } catch { + return null + } + } + + // ─── Workers.dev Route ───────────────────────────────────────────────────── + + async enableWorkersDevRoute( + accountId: string, + scriptName: string, + ): Promise<{ enabled: boolean }> { + return this.request<{ enabled: boolean }>( + `/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`, + { + method: 'POST', + body: JSON.stringify({ enabled: true }), + }, + ) + } + + // ─── Custom Domains ──────────────────────────────────────────────────────── + + async setWorkerCustomDomain( + accountId: string, + scriptName: string, + hostname: string, + ): Promise<{ id: string; hostname: string }> { + return this.request<{ id: string; hostname: string }>(`/accounts/${accountId}/workers/domains`, { + method: 'PUT', + body: JSON.stringify({ + hostname, + service: scriptName, + environment: 'production', + }), + }) + } +} diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts new file mode 100644 index 0000000..b162658 --- /dev/null +++ b/packages/cli/src/commands/deploy.ts @@ -0,0 +1,379 @@ +// deploy command — one-click OGraph deployment to Cloudflare +import { Command } from 'commander' +import { readFile, readdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { existsSync } from 'node:fs' +import { createInterface } from 'node:readline' +import { randomBytes } from 'node:crypto' +import { CloudflareClient } from '../cloudflare.js' +import { saveConfig } from '../config.js' + +// ─── Colors ──────────────────────────────────────────────────────────────────── + +const c = { + reset: '\x1b[0m', + green: '\x1b[32m', + cyan: '\x1b[36m', + yellow: '\x1b[33m', + red: '\x1b[31m', + dim: '\x1b[2m', + bold: '\x1b[1m', +} + +function ok(msg: string) { + console.log(`${c.green}✓${c.reset} ${msg}`) +} +function info(msg: string) { + console.log(`${c.cyan}ℹ${c.reset} ${msg}`) +} +function warn(msg: string) { + console.log(`${c.yellow}⚠${c.reset} ${msg}`) +} +function fail(msg: string) { + console.error(`${c.red}✗${c.reset} ${msg}`) +} +function step(n: number, total: number, msg: string) { + console.log(`\n${c.bold}[${n}/${total}]${c.reset} ${msg}`) +} + +// ─── Interactive Prompt ──────────────────────────────────────────────────────── + +function prompt(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close() + resolve(answer.trim()) + }) + }) +} + +async function confirm(message: string): Promise { + const answer = await prompt(`${message} ${c.dim}(y/N)${c.reset} `) + return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes' +} + +async function selectAccount(accounts: Array<{ id: string; name: string }>): Promise { + if (accounts.length === 1) { + const acct = accounts[0]! + info(`Using account: ${c.cyan}${acct.name}${c.reset} (${acct.id})`) + return acct.id + } + + console.log('\nAvailable accounts:') + for (let i = 0; i < accounts.length; i++) { + const acct = accounts[i]! + console.log(` ${c.cyan}${i + 1}${c.reset}) ${acct.name} ${c.dim}(${acct.id})${c.reset}`) + } + + const answer = await prompt(`\nSelect account ${c.dim}(1-${accounts.length})${c.reset}: `) + const idx = parseInt(answer, 10) - 1 + if (idx < 0 || idx >= accounts.length) { + throw new Error('Invalid selection') + } + return accounts[idx]!.id +} + +// ─── Migrations ──────────────────────────────────────────────────────────────── + +async function loadMigrations(migrationsDir: string): Promise { + if (!existsSync(migrationsDir)) { + throw new Error(`Migrations directory not found: ${migrationsDir}`) + } + + const files = await readdir(migrationsDir) + const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort() + + if (sqlFiles.length === 0) { + throw new Error(`No .sql files found in: ${migrationsDir}`) + } + + const parts: string[] = [] + for (const file of sqlFiles) { + const content = await readFile(join(migrationsDir, file), 'utf-8') + parts.push(`-- === ${file} ===\n${content}`) + } + + return parts.join('\n\n') +} + +// ─── Worker Bundle ───────────────────────────────────────────────────────────── + +async function loadWorkerBundle(ographDir: string): Promise { + // Try dist/index.js first (built output) + const distPath = join(ographDir, 'dist', 'index.js') + if (existsSync(distPath)) { + return readFile(distPath, 'utf-8') + } + + // No pre-built bundle available + throw new Error( + `Worker bundle not found at ${distPath}\n` + + `Run 'npm run build' in packages/engine first, or use wrangler for direct deployment.`, + ) +} + +// ─── Token Generation ────────────────────────────────────────────────────────── + +function generateApiToken(): string { + return `og_${randomBytes(32).toString('hex')}` +} + +// ─── Deploy Options ──────────────────────────────────────────────────────────── + +interface DeployOptions { + cfToken?: string + accountId?: string + name: string + dbName: string + domain?: string + yes: boolean +} + +// ─── Command ─────────────────────────────────────────────────────────────────── + +export function createDeployCommand(): Command { + const cmd = new Command('deploy') + cmd.description('Deploy OGraph to Cloudflare (D1 + Worker)') + cmd.option('--cf-token ', 'Cloudflare API Token') + cmd.option('--account-id ', 'Cloudflare Account ID') + cmd.option('--name ', 'Worker name', 'ograph') + cmd.option('--db-name ', 'D1 database name', 'ograph') + cmd.option('--domain ', 'Custom domain (optional)') + cmd.option('--yes', 'Skip confirmation prompts', false) + + cmd.action(async (opts: DeployOptions) => { + try { + await runDeploy(opts) + } catch (err) { + fail(String(err instanceof Error ? err.message : err)) + process.exit(1) + } + }) + + return cmd +} + +// ─── Deploy Flow ─────────────────────────────────────────────────────────────── + +async function runDeploy(opts: DeployOptions): Promise { + const TOTAL_STEPS = 6 + + console.log(`\n${c.bold}🚀 OGraph Deploy${c.reset}`) + console.log(`${c.dim}Deploy OGraph to Cloudflare Workers + D1${c.reset}\n`) + + // ── Step 0: Resolve paths ──────────────────────────────────────────────── + + // import.meta.dirname = dist/commands/ → ../.. = packages/cli/ + const cliDir = resolve(import.meta.dirname ?? new URL('.', import.meta.url).pathname, '..', '..') + const ographDir = resolve(cliDir, '..', 'engine') + const migrationsDir = join(ographDir, 'migrations') + + // ── Step 1: Get CF token ───────────────────────────────────────────────── + + step(1, TOTAL_STEPS, 'Authenticating with Cloudflare') + + let cfToken = opts.cfToken ?? process.env.CLOUDFLARE_API_TOKEN + if (!cfToken) { + info('No token provided via --cf-token or CLOUDFLARE_API_TOKEN env var') + info(`Create a token at: ${c.cyan}https://dash.cloudflare.com/profile/api-tokens${c.reset}`) + info('Required permissions: Workers Scripts (Edit), D1 (Edit), Account Settings (Read)') + cfToken = await prompt('\nCloudflare API Token: ') + if (!cfToken) { + throw new Error('API token is required') + } + } + + const cf = new CloudflareClient(cfToken) + + // Verify token + try { + const tokenInfo = await cf.verifyToken() + if (tokenInfo.status !== 'active') { + throw new Error(`Token status: ${tokenInfo.status}`) + } + ok('Token verified') + } catch (err) { + throw new Error( + `Invalid Cloudflare API token: ${err instanceof Error ? err.message : err}\n` + + `Create one at: https://dash.cloudflare.com/profile/api-tokens\n` + + `Required permissions: Workers Scripts (Edit), D1 (Edit), Account Settings (Read)`, + { cause: err }, + ) + } + + // ── Step 2: Select account ─────────────────────────────────────────────── + + step(2, TOTAL_STEPS, 'Selecting Cloudflare account') + + let accountId = opts.accountId + if (!accountId) { + const accounts = await cf.listAccounts() + if (accounts.length === 0) { + throw new Error('No Cloudflare accounts found for this token') + } + accountId = opts.yes && accounts.length === 1 ? accounts[0]!.id : await selectAccount(accounts) + } + ok(`Account: ${c.cyan}${accountId}${c.reset}`) + + // ── Confirmation ───────────────────────────────────────────────────────── + + if (!opts.yes) { + console.log(`\n${c.bold}Deployment plan:${c.reset}`) + console.log(` Worker name: ${c.cyan}${opts.name}${c.reset}`) + console.log(` D1 database: ${c.cyan}${opts.dbName}${c.reset}`) + console.log(` Account: ${c.cyan}${accountId}${c.reset}`) + if (opts.domain) { + console.log(` Domain: ${c.cyan}${opts.domain}${c.reset}`) + } + console.log() + + const proceed = await confirm('Proceed with deployment?') + if (!proceed) { + info('Deployment cancelled') + return + } + } + + // ── Step 3: Create D1 database ─────────────────────────────────────────── + + step(3, TOTAL_STEPS, `Creating D1 database: ${c.cyan}${opts.dbName}${c.reset}`) + + let db: { uuid: string; name: string } + try { + db = await cf.createD1Database(accountId, opts.dbName) + ok(`D1 database created: ${c.cyan}${db.uuid}${c.reset}`) + } catch (err) { + throw new Error( + `Failed to create D1 database: ${err instanceof Error ? err.message : err}\n` + + `Check that your token has D1 (Edit) permission.`, + { cause: err }, + ) + } + + // ── Step 4: Run migrations ─────────────────────────────────────────────── + + step(4, TOTAL_STEPS, 'Initializing database schema') + + const sql = await loadMigrations(migrationsDir) + const stmtCount = sql.split(';').filter((s) => s.trim().length > 0).length + info(`Loaded ${stmtCount} statements from migrations`) + + try { + await cf.queryD1(accountId, db.uuid, sql) + ok('Schema initialized') + } catch (err) { + throw new Error(`Failed to initialize schema: ${err instanceof Error ? err.message : err}`, { cause: err }) + } + + // ── Step 5: Deploy Worker ──────────────────────────────────────────────── + + step(5, TOTAL_STEPS, `Deploying Worker: ${c.cyan}${opts.name}${c.reset}`) + + const apiToken = generateApiToken() + const workerCode = await loadWorkerBundle(ographDir) + + try { + await cf.uploadWorker(accountId, opts.name, workerCode, { + d1DatabaseId: db.uuid, + d1BindingName: 'DB', + apiToken, + version: '2.4.0', + }) + ok('Worker deployed') + } catch (err) { + throw new Error( + `Failed to deploy Worker: ${err instanceof Error ? err.message : err}\n` + + `Check that your token has Workers Scripts (Edit) permission.`, + { cause: err }, + ) + } + + // Enable workers.dev route (avoids CF 1042 error) + if (!opts.domain) { + try { + await cf.enableWorkersDevRoute(accountId, opts.name) + } catch { + // Non-fatal — may already be enabled or not needed with custom domain + } + } + + // Resolve endpoint URL + let endpoint: string + if (opts.domain) { + try { + await cf.setWorkerCustomDomain(accountId, opts.name, opts.domain) + endpoint = `https://${opts.domain}` + ok(`Custom domain configured: ${c.cyan}${opts.domain}${c.reset}`) + } catch (err) { + warn(`Failed to set custom domain: ${err instanceof Error ? err.message : err}`) + warn('Falling back to workers.dev subdomain') + const subdomain = await cf.getWorkersSubdomain(accountId) + endpoint = subdomain ? `https://${opts.name}.${subdomain}.workers.dev` : `https://${opts.name}.workers.dev` + } + } else { + const subdomain = await cf.getWorkersSubdomain(accountId) + endpoint = subdomain ? `https://${opts.name}.${subdomain}.workers.dev` : `https://${opts.name}.workers.dev` + } + + // ── Step 6: Configure CLI ──────────────────────────────────────────────── + + step(6, TOTAL_STEPS, 'Configuring CLI') + + await saveConfig({ endpoint, token: apiToken }) + ok('CLI configured') + + // ── Readiness check ────────────────────────────────────────────────────── + + info('Waiting for Worker edge propagation...') + const maxWait = 90_000 // 90s max + const pollInterval = 2_000 // poll every 2s + const requiredConsecutive = 3 // need 3 consecutive OKs + const start = Date.now() + let consecutiveOk = 0 + let reachable = false + + while (Date.now() - start < maxWait) { + try { + const res = await fetch(`${endpoint}/health`) + const contentType = res.headers.get('content-type') ?? '' + if (res.ok && contentType.includes('application/json')) { + const body = (await res.json()) as { status?: string } + if (body.status === 'ok') { + consecutiveOk++ + if (consecutiveOk >= requiredConsecutive) { + reachable = true + break + } + } else { + consecutiveOk = 0 + } + } else { + consecutiveOk = 0 + } + } catch { + consecutiveOk = 0 + } + const elapsed = Math.round((Date.now() - start) / 1000) + process.stdout.write(`\r${c.dim} ... ${elapsed}s (${consecutiveOk}/${requiredConsecutive})${c.reset} `) + await new Promise((r) => setTimeout(r, pollInterval)) + } + process.stdout.write('\r') + + if (reachable) { + ok('Worker is live — edge propagation confirmed') + } else { + warn(`Worker not fully propagated after ${maxWait / 1000}s — some requests may fail briefly`) + } + + // ── Summary ────────────────────────────────────────────────────────────── + + console.log(`\n${c.bold}${c.green}✅ Deployment complete!${c.reset}\n`) + console.log(` Endpoint: ${c.cyan}${endpoint}${c.reset}`) + console.log(` API Token: ${c.cyan}${apiToken.slice(0, 12)}${'*'.repeat(20)}${c.reset}`) + console.log(` D1 DB: ${c.cyan}${db.uuid}${c.reset}`) + console.log(` Worker: ${c.cyan}${opts.name}${c.reset}`) + console.log() + console.log(` Test with: ${c.dim}ograph health${c.reset}`) + console.log() +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d4d26e1..e56bd37 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -11,6 +11,7 @@ import { createProjectionDefsCommand } from './commands/projection-defs.js' import { createProjectionsCommand } from './commands/projections.js' import { createReactionsCommand } from './commands/reactions.js' import { createHealthCommand } from './commands/health.js' +import { createDeployCommand } from './commands/deploy.js' // ─── Main Program ────────────────────────────────────────────────────────────── @@ -27,5 +28,6 @@ program.addCommand(createProjectionDefsCommand()) program.addCommand(createProjectionsCommand()) program.addCommand(createReactionsCommand()) program.addCommand(createHealthCommand()) +program.addCommand(createDeployCommand()) program.parse() diff --git a/packages/cli/test/client.test.ts b/packages/cli/test/client.test.ts index 1175bc8..5c16bcb 100644 --- a/packages/cli/test/client.test.ts +++ b/packages/cli/test/client.test.ts @@ -48,8 +48,10 @@ describe('OGraphClient v2.4', () => { await client.init() } + const jsonHeaders = { get: (name: string) => name.toLowerCase() === 'content-type' ? 'application/json' : null } + function mockOk(data: unknown) { - mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(data) }) + mockFetch.mockResolvedValue({ ok: true, headers: jsonHeaders, json: () => Promise.resolve(data) }) } function mockFail(status: number, error: string) { @@ -57,6 +59,7 @@ describe('OGraphClient v2.4', () => { ok: false, status, statusText: error, + headers: jsonHeaders, json: () => Promise.resolve({ error }), }) } diff --git a/packages/cli/test/deploy.test.ts b/packages/cli/test/deploy.test.ts new file mode 100644 index 0000000..794fbcb --- /dev/null +++ b/packages/cli/test/deploy.test.ts @@ -0,0 +1,289 @@ +// deploy command tests — mock CF API +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { CloudflareClient } from '../src/cloudflare.js' + +// ─── Mock fetch ──────────────────────────────────────────────────────────────── + +const mockFetch = vi.fn() +global.fetch = mockFetch + +function cfOk(result: T) { + return { ok: true, json: () => Promise.resolve({ success: true, result, errors: [], messages: [] }) } +} + +function cfFail(code: number, message: string) { + return { + ok: false, + json: () => Promise.resolve({ success: false, result: null, errors: [{ code, message }], messages: [] }), + } +} + +// ─── CloudflareClient Tests ──────────────────────────────────────────────────── + +describe('CloudflareClient', () => { + let client: CloudflareClient + + beforeEach(() => { + client = new CloudflareClient('test-cf-token') + vi.clearAllMocks() + }) + + // ─── verifyToken ─────────────────────────────────────────────────────────── + + describe('verifyToken', () => { + it('should verify a valid token', async () => { + mockFetch.mockResolvedValue(cfOk({ id: 'token-123', status: 'active' })) + const result = await client.verifyToken() + expect(result.status).toBe('active') + expect(result.id).toBe('token-123') + + const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.cloudflare.com/client/v4/user/tokens/verify') + expect(opts.headers).toBeDefined() + const headers = opts.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer test-cf-token') + }) + + it('should throw on invalid token', async () => { + mockFetch.mockResolvedValue(cfFail(1000, 'Invalid API Token')) + await expect(client.verifyToken()).rejects.toThrow('Cloudflare API error: [1000] Invalid API Token') + }) + }) + + // ─── listAccounts ────────────────────────────────────────────────────────── + + describe('listAccounts', () => { + it('should return accounts list', async () => { + mockFetch.mockResolvedValue( + cfOk([ + { id: 'acct-1', name: 'My Account' }, + { id: 'acct-2', name: 'Other Account' }, + ]), + ) + const accounts = await client.listAccounts() + expect(accounts).toHaveLength(2) + expect(accounts[0]!.id).toBe('acct-1') + expect(accounts[1]!.name).toBe('Other Account') + expect(mockFetch.mock.calls[0]![0]).toBe('https://api.cloudflare.com/client/v4/accounts') + }) + }) + + // ─── createD1Database ────────────────────────────────────────────────────── + + describe('createD1Database', () => { + it('should create a D1 database', async () => { + mockFetch.mockResolvedValue(cfOk({ uuid: 'db-uuid-123', name: 'ograph' })) + const db = await client.createD1Database('acct-1', 'ograph') + expect(db.uuid).toBe('db-uuid-123') + expect(db.name).toBe('ograph') + + const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/d1/database') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body as string)).toEqual({ name: 'ograph' }) + }) + + it('should throw on permission error', async () => { + mockFetch.mockResolvedValue(cfFail(10000, 'Authentication error')) + await expect(client.createD1Database('acct-1', 'ograph')).rejects.toThrow( + 'Cloudflare API error: [10000] Authentication error', + ) + }) + }) + + // ─── queryD1 ─────────────────────────────────────────────────────────────── + + describe('queryD1', () => { + it('should execute SQL against D1', async () => { + mockFetch.mockResolvedValue(cfOk([{ success: true, results: [], meta: { changes: 3 } }])) + const results = await client.queryD1('acct-1', 'db-uuid-123', 'CREATE TABLE test (id INTEGER);') + expect(results).toHaveLength(1) + expect(results[0]!.success).toBe(true) + + const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/d1/database/db-uuid-123/query') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body as string)).toEqual({ sql: 'CREATE TABLE test (id INTEGER);' }) + }) + }) + + // ─── uploadWorker ────────────────────────────────────────────────────────── + + describe('uploadWorker', () => { + it('should upload a worker script with bindings', async () => { + mockFetch.mockResolvedValue(cfOk({ id: 'ograph', etag: 'abc123' })) + const result = await client.uploadWorker('acct-1', 'ograph', 'export default { fetch() {} }', { + d1DatabaseId: 'db-uuid-123', + d1BindingName: 'DB', + apiToken: 'og_testtoken', + version: '2.4.0', + }) + expect(result.id).toBe('ograph') + + const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/workers/scripts/ograph') + expect(opts.method).toBe('PUT') + + // Verify multipart body contains metadata and script + const body = opts.body as string + expect(body).toContain('worker.js') + expect(body).toContain('export default { fetch() {} }') + expect(body).toContain('d1') + expect(body).toContain('db-uuid-123') + expect(body).toContain('og_testtoken') + expect(body).toContain('2.4.0') + + // Verify content-type is multipart + const headers = opts.headers as Headers + expect(headers.get('Content-Type')).toContain('multipart/form-data') + }) + + it('should throw on upload failure', async () => { + mockFetch.mockResolvedValue({ + ok: false, + json: () => + Promise.resolve({ + success: false, + result: null, + errors: [{ code: 10007, message: 'Script too large' }], + messages: [], + }), + }) + await expect( + client.uploadWorker('acct-1', 'ograph', 'code', { + d1DatabaseId: 'db-uuid-123', + d1BindingName: 'DB', + apiToken: 'og_test', + version: '2.4.0', + }), + ).rejects.toThrow('Worker upload failed: [10007] Script too large') + }) + }) + + // ─── getWorkersSubdomain ─────────────────────────────────────────────────── + + describe('getWorkersSubdomain', () => { + it('should return subdomain', async () => { + mockFetch.mockResolvedValue(cfOk({ subdomain: 'my-workers' })) + const subdomain = await client.getWorkersSubdomain('acct-1') + expect(subdomain).toBe('my-workers') + }) + + it('should return null on error', async () => { + mockFetch.mockResolvedValue(cfFail(10000, 'Not found')) + const subdomain = await client.getWorkersSubdomain('acct-1') + expect(subdomain).toBeNull() + }) + }) + + // ─── setWorkerCustomDomain ───────────────────────────────────────────────── + + describe('setWorkerCustomDomain', () => { + it('should set custom domain', async () => { + mockFetch.mockResolvedValue(cfOk({ id: 'domain-1', hostname: 'api.example.com' })) + const result = await client.setWorkerCustomDomain('acct-1', 'ograph', 'api.example.com') + expect(result.hostname).toBe('api.example.com') + + const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/workers/domains') + expect(opts.method).toBe('PUT') + const body = JSON.parse(opts.body as string) + expect(body.hostname).toBe('api.example.com') + expect(body.service).toBe('ograph') + }) + }) +}) + +// ─── Migration Loading Tests ───────────────────────────────────────────────── + +describe('loadMigrations (integration)', () => { + it('should find migration files in the ograph package', async () => { + const { readdir, readFile } = await import('node:fs/promises') + const { join } = await import('node:path') + + const migrationsDir = join(import.meta.dirname ?? '.', '..', '..', 'engine', 'migrations') + const files = await readdir(migrationsDir) + const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort() + + // Should have migration files + expect(sqlFiles.length).toBeGreaterThan(0) + + // First file should be 0006_v2.sql + expect(sqlFiles[0]).toBe('0006_v2.sql') + + // Last file should be 0021_request_logs.sql + expect(sqlFiles[sqlFiles.length - 1]).toBe('0021_request_logs.sql') + + // Each file should contain SQL + for (const file of sqlFiles) { + const content = await readFile(join(migrationsDir, file), 'utf-8') + expect(content.length).toBeGreaterThan(0) + } + }) + + it('should sort files in correct order', async () => { + const { readdir } = await import('node:fs/promises') + const { join } = await import('node:path') + + const migrationsDir = join(import.meta.dirname ?? '.', '..', '..', 'engine', 'migrations') + const files = await readdir(migrationsDir) + const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort() + + // Verify ordering by number prefix + for (let i = 1; i < sqlFiles.length; i++) { + const prevNum = parseInt(sqlFiles[i - 1]!.split('_')[0]!, 10) + const currNum = parseInt(sqlFiles[i]!.split('_')[0]!, 10) + expect(currNum).toBeGreaterThan(prevNum) + } + }) +}) + +// ─── Token Generation Tests ────────────────────────────────────────────────── + +describe('generateApiToken', () => { + it('should generate tokens with og_ prefix', async () => { + const { randomBytes } = await import('node:crypto') + const token = `og_${randomBytes(32).toString('hex')}` + expect(token).toMatch(/^og_[a-f0-9]{64}$/) + }) +}) + +// ─── Deploy Command Registration ──────────────────────────────────────────── + +describe('createDeployCommand', () => { + let createDeployCommand: () => import('commander').Command + + beforeEach(async () => { + const mod = await import('../src/commands/deploy.js') + createDeployCommand = mod.createDeployCommand + }) + + it('should create a command named "deploy"', () => { + const cmd = createDeployCommand() + expect(cmd.name()).toBe('deploy') + }) + + it('should have all expected options', () => { + const cmd = createDeployCommand() + const optionNames = cmd.options.map((o) => o.long) + expect(optionNames).toContain('--cf-token') + expect(optionNames).toContain('--account-id') + expect(optionNames).toContain('--name') + expect(optionNames).toContain('--db-name') + expect(optionNames).toContain('--domain') + expect(optionNames).toContain('--yes') + }) + + it('should have correct defaults', () => { + const cmd = createDeployCommand() + const nameOpt = cmd.options.find((o) => o.long === '--name') + const dbNameOpt = cmd.options.find((o) => o.long === '--db-name') + expect(nameOpt?.defaultValue).toBe('ograph') + expect(dbNameOpt?.defaultValue).toBe('ograph') + }) + + it('should show description', () => { + const cmd = createDeployCommand() + expect(cmd.description()).toContain('Deploy') + }) +})