Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/lib/download/readmeContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions app/lib/stores/workbench.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`);
Expand Down
162 changes: 162 additions & 0 deletions chef-deploy/cli.js
Original file line number Diff line number Diff line change
@@ -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>', 'Path to the Chef project', process.cwd())
.option('-t, --token <token>', 'Convex deploy key (otherwise read from .env.local / CONVEX_DEPLOY_KEY)')
.option('-d, --deployment <name>', 'Convex deployment name (otherwise read from .env.local / CONVEX_DEPLOYMENT)')
.option('--build-command <cmd>', 'Command used to build the site', 'npm run build')
.option('--build-dir <dir>', 'Directory containing the built site', DEFAULT_BUILD_DIR)
.option('--chef-url <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);
},
);
24 changes: 24 additions & 0 deletions chef-deploy/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
packages:
- 'chefshot'
- 'chef-deploy'
- 'test-kitchen'
- 'chef-agent'
# You can add more workspace packages here later if needed
Expand Down
1 change: 1 addition & 0 deletions template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading