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)
This commit is contained in:
小墨 2026-04-13 00:00:13 +00:00
parent d84a860d15
commit d520df29d4
7 changed files with 1014 additions and 43 deletions

119
package-lock.json generated
View File

@ -475,11 +475,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"netbsd" "netbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -508,11 +510,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"openbsd" "openbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -541,11 +545,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"openharmony" "openharmony"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -3225,6 +3231,7 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -3246,6 +3253,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -3267,6 +3275,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -3288,6 +3297,7 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -3309,6 +3319,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -3330,6 +3341,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -3351,6 +3363,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -3372,6 +3385,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -3393,6 +3407,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -3414,6 +3429,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -3435,6 +3451,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@ -5168,11 +5185,13 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"aix" "aix"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5184,11 +5203,13 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5200,11 +5221,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5216,11 +5239,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5232,11 +5257,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5248,11 +5275,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5264,11 +5293,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5280,11 +5311,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5296,11 +5329,13 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5312,11 +5347,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5328,11 +5365,13 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5344,11 +5383,13 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5360,11 +5401,13 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5376,11 +5419,13 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5392,11 +5437,13 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5408,11 +5455,13 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5424,11 +5473,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5440,11 +5491,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"netbsd" "netbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5456,11 +5509,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"openbsd" "openbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5472,11 +5527,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"sunos" "sunos"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5488,11 +5545,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5504,11 +5563,13 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5520,11 +5581,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"extraneous": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -5643,9 +5706,11 @@
"version": "0.28.0", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"extraneous": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },

View File

@ -88,7 +88,7 @@ export class OGraphClient {
} }
} }
private async request<T>(path: string, options: RequestInit = {}): Promise<T> { private async request<T>(path: string, options: RequestInit = {}, retries = 2): Promise<T> {
const url = `${this.endpoint}${path}` const url = `${this.endpoint}${path}`
const headers = new Headers(options.headers) const headers = new Headers(options.headers)
@ -97,15 +97,34 @@ export class OGraphClient {
headers.set('Content-Type', 'application/json') headers.set('Content-Type', 'application/json')
} }
let lastError: Error | undefined
for (let attempt = 0; attempt <= retries; attempt++) {
try { try {
const response = await fetch(url, { ...options, headers }) const response = await fetch(url, { ...options, headers })
// 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 result = await response.json() const result = await response.json()
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
throw new Error('Authentication failed. Check your token.') throw new Error('Authentication failed. Check your token.')
} }
const errorMessage = (result as { error?: string }).error ?? `HTTP ${response.status}: ${response.statusText}` const errorMessage =
(result as { error?: string }).error ?? `HTTP ${response.status}: ${response.statusText}`
throw new Error(errorMessage) throw new Error(errorMessage)
} }
@ -114,9 +133,17 @@ export class OGraphClient {
if (error instanceof Error && error.message.includes('fetch')) { if (error instanceof Error && error.message.includes('fetch')) {
throw new Error(`Cannot reach OGraph API at ${this.endpoint}`) 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 ─────────────────────────────────────────────────────────────── // ─── Object-Defs ───────────────────────────────────────────────────────────────

View File

@ -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<T> {
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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T>
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<CfAccount[]> {
return this.request<CfAccount[]>('/accounts')
}
// ─── D1 Database ───────────────────────────────────────────────────────────
async createD1Database(accountId: string, name: string): Promise<CfD1Database> {
return this.request<CfD1Database>(`/accounts/${accountId}/d1/database`, {
method: 'POST',
body: JSON.stringify({ name }),
})
}
async queryD1(accountId: string, databaseId: string, sql: string): Promise<CfD1QueryResult[]> {
return this.request<CfD1QueryResult[]>(`/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<CfWorkerScript> {
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<CfWorkerScript>
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<string | null> {
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',
}),
})
}
}

View File

@ -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<string> {
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<boolean> {
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<string> {
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<string> {
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<string> {
// 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 <token>', 'Cloudflare API Token')
cmd.option('--account-id <id>', 'Cloudflare Account ID')
cmd.option('--name <name>', 'Worker name', 'ograph')
cmd.option('--db-name <name>', 'D1 database name', 'ograph')
cmd.option('--domain <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<void> {
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()
}

View File

@ -11,6 +11,7 @@ import { createProjectionDefsCommand } from './commands/projection-defs.js'
import { createProjectionsCommand } from './commands/projections.js' import { createProjectionsCommand } from './commands/projections.js'
import { createReactionsCommand } from './commands/reactions.js' import { createReactionsCommand } from './commands/reactions.js'
import { createHealthCommand } from './commands/health.js' import { createHealthCommand } from './commands/health.js'
import { createDeployCommand } from './commands/deploy.js'
// ─── Main Program ────────────────────────────────────────────────────────────── // ─── Main Program ──────────────────────────────────────────────────────────────
@ -27,5 +28,6 @@ program.addCommand(createProjectionDefsCommand())
program.addCommand(createProjectionsCommand()) program.addCommand(createProjectionsCommand())
program.addCommand(createReactionsCommand()) program.addCommand(createReactionsCommand())
program.addCommand(createHealthCommand()) program.addCommand(createHealthCommand())
program.addCommand(createDeployCommand())
program.parse() program.parse()

View File

@ -48,8 +48,10 @@ describe('OGraphClient v2.4', () => {
await client.init() await client.init()
} }
const jsonHeaders = { get: (name: string) => name.toLowerCase() === 'content-type' ? 'application/json' : null }
function mockOk(data: unknown) { 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) { function mockFail(status: number, error: string) {
@ -57,6 +59,7 @@ describe('OGraphClient v2.4', () => {
ok: false, ok: false,
status, status,
statusText: error, statusText: error,
headers: jsonHeaders,
json: () => Promise.resolve({ error }), json: () => Promise.resolve({ error }),
}) })
} }

View File

@ -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<T>(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')
})
})