diff --git a/app/lib/download/readmeContent.ts b/app/lib/download/readmeContent.ts index 83266b9fb..42fb5f878 100644 --- a/app/lib/download/readmeContent.ts +++ b/app/lib/download/readmeContent.ts @@ -25,6 +25,30 @@ Check out the [Convex docs](https://docs.convex.dev/) for more information on ho * Check out the [Hosting and Deployment](https://docs.convex.dev/production/) docs for how to deploy your app * Read the [Best Practices](https://docs.convex.dev/understanding/best-practices/) guide for tips on how to improve you app further +## Deploying via the CLI (recommended) + +You can rebuild and redeploy your Chef-hosted site from your machine. \`.env.local\` in this zip already contains both \`CONVEX_DEPLOYMENT\` and \`CONVEX_DEPLOY_KEY\`, so no extra setup is needed: + +\`\`\`bash +npm install +npm run chef-deploy +\`\`\` + +This runs \`npm run build\`, zips the \`dist/\` directory, and uploads it to your existing ${convexDeploymentName ? `\`${convexDeploymentName}.convex.app\`` : '`*.convex.app`'} site. + +If you need to pass flags, invoke the CLI directly: + +\`\`\`bash +npx --yes @convex-dev/chef-deploy --help +\`\`\` + +Useful flags: + +- \`--build-command "pnpm build"\` if you prefer pnpm +- \`--build-dir build\` if your framework writes somewhere other than \`dist/\` +- \`--skip-build\` to upload an already-built \`dist/\` as-is +- \`--chef-url https://...\` to target a non-default Chef host + ## HTTP API User-defined http routes are defined in the \`convex/router.ts\` file. We split these routes into a separate file from \`convex/http.ts\` to allow us to prevent the LLM from modifying the authentication routes. diff --git a/app/lib/stores/workbench.client.ts b/app/lib/stores/workbench.client.ts index a66d08071..7f7b2e6cb 100644 --- a/app/lib/stores/workbench.client.ts +++ b/app/lib/stores/workbench.client.ts @@ -31,6 +31,33 @@ import type { ConvexToolName } from '~/lib/common/types'; const { saveAs } = fileSaver; +const DEPLOY_SCRIPT_NAME = 'chef-deploy'; +const DEPLOY_SCRIPT_COMMAND = 'npx --yes @convex-dev/chef-deploy'; + +async function injectDeployScript(zip: JSZip) { + const entry = zip.file('package.json'); + if (!entry) { + return; + } + let raw: string; + try { + raw = await entry.async('string'); + } catch { + return; + } + let pkg: any; + try { + pkg = JSON.parse(raw); + } catch { + return; + } + pkg.scripts = pkg.scripts ?? {}; + if (!pkg.scripts[DEPLOY_SCRIPT_NAME]) { + pkg.scripts[DEPLOY_SCRIPT_NAME] = DEPLOY_SCRIPT_COMMAND; + zip.file('package.json', JSON.stringify(pkg, null, 2) + '\n'); + } +} + export interface ArtifactState { id: string; title: string; @@ -602,6 +629,7 @@ export class WorkbenchStore { if (!hasCursorRules) { zip.file('.cursor/rules/convex_rules.mdc', cursorRulesContent); } + await injectDeployScript(zip); // Generate the zip file and save it const content = await zip.generateAsync({ type: 'blob' }); saveAs(content, `${projectName}.zip`); diff --git a/chef-deploy/cli.js b/chef-deploy/cli.js new file mode 100755 index 000000000..e33d7cf0e --- /dev/null +++ b/chef-deploy/cli.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { Command } from 'commander'; +import dotenv from 'dotenv'; +import JSZip from 'jszip'; + +const DEFAULT_CHEF_URL = 'https://chef.convex.dev'; +const DEFAULT_BUILD_DIR = 'dist'; + +function parseEnvLocal(projectDir) { + const envLocalPath = join(projectDir, '.env.local'); + if (!existsSync(envLocalPath)) { + return {}; + } + return dotenv.parse(readFileSync(envLocalPath)); +} + +function extractDeploymentName(raw) { + // `.env.local` stores `CONVEX_DEPLOYMENT=dev:funky-otter-123 # team: ... project: ...` + const stripped = raw.split('#')[0].trim(); + return stripped.includes(':') ? stripped.split(':').pop().trim() : stripped; +} + +function runCommand(command, cwd) { + const [bin, ...args] = command.split(' '); + const result = spawnSync(bin, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' }); + if (result.status !== 0) { + throw new Error(`Command failed (${command}) with exit code ${result.status}`); + } +} + +async function addDirectoryToZip(zip, dir, base) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const absPath = join(dir, entry.name); + if (entry.isDirectory()) { + await addDirectoryToZip(zip, absPath, base); + } else if (entry.isFile()) { + const relPath = relative(base, absPath); + zip.file(relPath, await readFile(absPath)); + } + } +} + +async function buildZip(buildPath) { + const zip = new JSZip(); + await addDirectoryToZip(zip, buildPath, buildPath); + return zip.generateAsync({ type: 'nodebuffer' }); +} + +async function uploadBundle({ chefUrl, token, deploymentName, zipBuffer }) { + const form = new FormData(); + form.append('file', new Blob([zipBuffer]), 'dist.zip'); + form.append('deploymentName', deploymentName); + form.append('token', token); + + const url = `${chefUrl.replace(/\/+$/, '')}/api/deploy-simple`; + const response = await fetch(url, { method: 'POST', body: form }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Upload failed (${response.status} ${response.statusText}): ${text}`); + } + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } +} + +async function run(opts) { + const projectDir = resolve(opts.projectDir); + if (!existsSync(projectDir)) { + throw new Error(`Project directory does not exist: ${projectDir}`); + } + + const env = parseEnvLocal(projectDir); + const token = opts.token ?? process.env.CONVEX_DEPLOY_KEY ?? env.CONVEX_DEPLOY_KEY; + const rawDeployment = opts.deployment ?? process.env.CONVEX_DEPLOYMENT ?? env.CONVEX_DEPLOYMENT; + + if (!token) { + throw new Error( + 'Missing CONVEX_DEPLOY_KEY. Set it in .env.local, the CONVEX_DEPLOY_KEY env var, or pass --token.', + ); + } + if (!rawDeployment) { + throw new Error( + 'Missing CONVEX_DEPLOYMENT. Set it in .env.local, the CONVEX_DEPLOYMENT env var, or pass --deployment.', + ); + } + + const deploymentName = extractDeploymentName(rawDeployment); + console.log(`→ Project: ${projectDir}`); + console.log(`→ Deployment: ${deploymentName}`); + console.log(`→ Chef host: ${opts.chefUrl}`); + + if (!opts.skipInstall) { + if (!existsSync(join(projectDir, 'node_modules'))) { + console.log('\n→ Installing dependencies (npm install)...'); + runCommand('npm install --no-fund --no-audit', projectDir); + } else { + console.log('→ node_modules exists, skipping install'); + } + } + + if (!opts.skipBuild) { + console.log(`\n→ Building (${opts.buildCommand})...`); + runCommand(opts.buildCommand, projectDir); + } + + const buildPath = join(projectDir, opts.buildDir); + if (!existsSync(buildPath) || !statSync(buildPath).isDirectory()) { + throw new Error( + `Build output directory not found: ${buildPath}. Pass --build-dir if your build writes elsewhere.`, + ); + } + + console.log(`\n→ Zipping ${opts.buildDir}/...`); + const zipBuffer = await buildZip(buildPath); + console.log(` ${(zipBuffer.length / 1024).toFixed(1)} KiB`); + + console.log('\n→ Uploading to Chef...'); + const result = await uploadBundle({ + chefUrl: opts.chefUrl, + token, + deploymentName, + zipBuffer, + }); + + console.log('\n✓ Deployed'); + console.log(` https://${deploymentName}.convex.app`); + if (result && typeof result === 'object' && 'localDevWarning' in result) { + console.log(` Warning: ${result.localDevWarning}`); + } +} + +const program = new Command() + .name('chef-deploy') + .description('Build and deploy a Chef project to its *.convex.app host.') + .option('-p, --project-dir ', 'Path to the Chef project', process.cwd()) + .option('-t, --token ', 'Convex deploy key (otherwise read from .env.local / CONVEX_DEPLOY_KEY)') + .option('-d, --deployment ', 'Convex deployment name (otherwise read from .env.local / CONVEX_DEPLOYMENT)') + .option('--build-command ', 'Command used to build the site', 'npm run build') + .option('--build-dir ', 'Directory containing the built site', DEFAULT_BUILD_DIR) + .option('--chef-url ', 'Chef host', DEFAULT_CHEF_URL) + .option('--skip-build', 'Skip the build step and upload --build-dir as-is', false) + .option('--skip-install', 'Skip installing dependencies', false); + +program.parseAsync(process.argv).then( + () => + run(program.opts()).catch((err) => { + console.error(`\n✗ ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + }), + (err) => { + console.error(`\n✗ ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + }, +); diff --git a/chef-deploy/package.json b/chef-deploy/package.json new file mode 100644 index 000000000..a0e35c66a --- /dev/null +++ b/chef-deploy/package.json @@ -0,0 +1,24 @@ +{ + "name": "@convex-dev/chef-deploy", + "version": "0.1.0-alpha.0", + "description": "Build and deploy a Chef project from the command line to its *.convex.app host.", + "type": "module", + "bin": { + "chef-deploy": "./cli.js" + }, + "files": [ + "cli.js" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "deploy": "node cli.js" + }, + "dependencies": { + "commander": "^13.1.0", + "dotenv": "^16.4.5", + "jszip": "^3.10.1" + }, + "packageManager": "pnpm@9.5.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f4391738..14d68cea8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -519,6 +519,18 @@ importers: specifier: ^2.1.9 version: 2.1.9(@edge-runtime/vm@5.0.0)(@types/node@20.17.30)(jsdom@26.0.0)(sass-embedded@1.83.4)(sugarss@4.0.1(postcss@8.5.3)) + chef-deploy: + dependencies: + commander: + specifier: ^13.1.0 + version: 13.1.0 + dotenv: + specifier: ^16.4.5 + version: 16.4.7 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + chefshot: dependencies: '@playwright/test': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f589b9fa3..0119e9a35 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - 'chefshot' + - 'chef-deploy' - 'test-kitchen' - 'chef-agent' # You can add more workspace packages here later if needed diff --git a/template/package.json b/template/package.json index b3061d20d..8add5d771 100644 --- a/template/package.json +++ b/template/package.json @@ -8,6 +8,7 @@ "dev:frontend": "vite --open", "dev:backend": "convex dev", "build": "vite build", + "chef-deploy": "npx --yes @convex-dev/chef-deploy", "lint": "tsc -p convex -noEmit --pretty false && tsc -p . -noEmit --pretty false && convex dev --once && vite build" }, "dependencies": {