mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
6bee94be03
* fix(deploy): honour target for Cloudflare Pages branch and URL selection (#4483) - deployToCloudflarePages: add optional target ('preview'|'production', default 'production') - Branch derivation: preview→'preview', production/omitted→'main' - URL selection: preview uses per-deploy alias; production uses production root - routes/deploy.ts: read target from request body (default 'production'), pass to deployToCloudflarePages, persist actual target (not hardcoded 'preview') - db.ts normalizeDeployment: read target from DB row instead of hardcoding 'preview' - contracts: widen target fields to 'preview'|'production'; add target to DeployProjectFileRequest * test(deploy): type FormData branch capture as string | undefined (#4483) The Phase-1 branch-capture objects were typed `{ branch?: string }` but assigned `string | undefined`, which fails `tsc` under `exactOptionalPropertyTypes`. Type the field as `branch: string | undefined` so the daemon typecheck gate passes; assertions are unchanged. * fix(deploy): reject invalid deploy target with HTTP 400 (#4576) The deploy route coerced any non-'preview' target to 'production', so a malformed value (typo, empty string, wrong case) silently published to the production Cloudflare branch instead of a gated preview. Validate the supplied target up front and return 400 for anything outside preview|production; an omitted target still defaults to production (non-breaking). Addresses review feedback on #4576 / #4483. * feat(deploy): add `od deploy` CLI subcommand with --target (#4483) Land the CLI surface for the preview/production deploy capability so it is not API-only. `od deploy <projectId> --file <name> [--target preview| production] [--provider vercel-self|cloudflare-pages] [--cf-zone-id] [--cf-zone-name] [--cf-domain-prefix] [--json] [--daemon-url]` POSTs to the same `/api/projects/:id/deploy` endpoint the web UI uses, with local validation of `--target` before any request (omitted target = no key, server defaults to production). Addresses the surface-closure review feedback on #4576 by shipping the embeddability (CLI) surface alongside the API contract. The human web-UI preview/production selector remains tracked as a follow-up in #4577. Tests: apps/daemon/tests/cli-deploy.test.ts (11 cases, written test-first). * feat(deploy): add web UI target selector for Cloudflare Pages deploys Closes the web-UI surface gap PerishCode flagged on #4576: deploy target (preview/production) had HTTP + od CLI support but no way to choose it from the deploy modal. Adds a target <select> to the Cloudflare Pages deploy panel, defaulted to Preview, threaded through deployProjectFile's new optional target argument and forwarded in the deploy POST body. References #4576. * fix(deploy): reject vercel-self target=production instead of silently deploying preview POST /api/projects/:id/deploy resolved the requested target but only threaded it into deployToCloudflarePages(); deployToVercel() never received it and always behaved as preview. A caller explicitly requesting target=production against the Vercel provider was silently deployed as preview with no error (Codex P2 finding on PR #4576). Real Vercel production-target support is out of scope for this PR, so reject the explicit request with 400 BAD_REQUEST instead. The guard gates on the raw requested value, not the resolved target, so an omitted target (which defaults to 'production' for Cloudflare Pages parity) keeps deploying Vercel as preview exactly as before. * fix(web): default Cloudflare Pages deploy target to production The deploy target selector defaulted to 'preview', both in its initial useState and when syncing from the (currently placeholder, always-'preview') GET /api/deploy/config response. A user who opened the Cloudflare Pages deploy modal and clicked Deploy without touching the selector silently deployed to preview instead of production, contradicting the daemon's documented default for an omitted target and pre-PR behavior. Default both sites to 'production'; explicit user selection of either target is untouched. FileViewer.deploy-target.test.tsx covers the default and both explicit selections. Fixes #4483
381 lines
11 KiB
TypeScript
381 lines
11 KiB
TypeScript
// Contract test for the `od deploy` CLI surface. Verifies that the subcommand
|
|
// is registered in SUBCOMMAND_MAP, that flags are parsed correctly, and that
|
|
// exactly the right HTTP body is sent to POST /api/projects/:id/deploy.
|
|
//
|
|
// Mirrors the stub-server + runCli pattern from cli-templates.test.ts so the
|
|
// harness is familiar to reviewers and consistent with the repo standard.
|
|
//
|
|
// ASSUMPTION (Cloudflare flags): The CLI is expected to accept three flags to
|
|
// populate the `cloudflarePages` body field (matching CloudflarePagesDeploySelection
|
|
// from packages/contracts):
|
|
// --cf-zone-id <id> → cloudflarePages.zoneId
|
|
// --cf-zone-name <name> → cloudflarePages.zoneName
|
|
// --cf-domain-prefix <p> → cloudflarePages.domainPrefix
|
|
// The implementer should honor this assumption; if the flag names change, update
|
|
// the case 7 assertions accordingly.
|
|
|
|
import http from 'node:http';
|
|
import { execFile } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve as pathResolve } from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
const execFileP = promisify(execFile);
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const DAEMON_ROOT = pathResolve(__dirname, '..');
|
|
const REPO_ROOT = pathResolve(__dirname, '../../..');
|
|
const CLI_SRC = pathResolve(__dirname, '../src/cli.ts');
|
|
const TSX_CLI = pathResolve(REPO_ROOT, 'node_modules/tsx/dist/cli.mjs');
|
|
|
|
interface CapturedRequest {
|
|
method: string;
|
|
url: string;
|
|
body: string;
|
|
}
|
|
|
|
interface StubServer {
|
|
baseUrl: string;
|
|
requests: CapturedRequest[];
|
|
setResponder: (
|
|
fn: (req: CapturedRequest) => { status: number; body: unknown } | null,
|
|
) => void;
|
|
close: () => Promise<void>;
|
|
}
|
|
|
|
async function startStubServer(): Promise<StubServer> {
|
|
const requests: CapturedRequest[] = [];
|
|
let responder:
|
|
| ((req: CapturedRequest) => { status: number; body: unknown } | null)
|
|
| null = null;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
let raw = '';
|
|
req.on('data', (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
req.on('end', () => {
|
|
const captured: CapturedRequest = {
|
|
method: req.method ?? '',
|
|
url: req.url ?? '',
|
|
body: raw,
|
|
};
|
|
requests.push(captured);
|
|
const response = responder?.(captured) ?? { status: 200, body: { ok: true } };
|
|
res.statusCode = response.status;
|
|
res.setHeader('content-type', 'application/json');
|
|
res.end(JSON.stringify(response.body));
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolveListen) => server.listen(0, '127.0.0.1', resolveListen));
|
|
const addr = server.address();
|
|
if (!addr || typeof addr === 'string') throw new Error('stub server has no address');
|
|
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
|
|
return {
|
|
baseUrl,
|
|
requests,
|
|
setResponder: (fn) => {
|
|
responder = fn;
|
|
},
|
|
close: () =>
|
|
new Promise<void>((resolveClose, rejectClose) => {
|
|
server.close((err) => (err ? rejectClose(err) : resolveClose()));
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function runCli(
|
|
args: string[],
|
|
options: { env?: NodeJS.ProcessEnv } = {},
|
|
): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
...options.env,
|
|
};
|
|
delete env.NODE_OPTIONS;
|
|
try {
|
|
const { stdout, stderr } = await execFileP(
|
|
process.execPath,
|
|
[TSX_CLI, CLI_SRC, ...args],
|
|
{
|
|
cwd: DAEMON_ROOT,
|
|
env,
|
|
timeout: 15_000,
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
},
|
|
);
|
|
return { stdout, stderr, code: 0 };
|
|
} catch (err) {
|
|
const failed = err as { stdout?: string; stderr?: string; code?: number | null };
|
|
return {
|
|
stdout: failed.stdout ?? '',
|
|
stderr: failed.stderr ?? '',
|
|
code: failed.code ?? 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
// A minimal DeploymentInfo response that the stub returns for success cases.
|
|
// Fields match DeploymentInfo + DeployProjectFileResponse from packages/contracts.
|
|
const STUB_DEPLOYMENT = {
|
|
id: 'deploy-abc',
|
|
projectId: 'proj-1',
|
|
fileName: 'index.html',
|
|
providerId: 'vercel-self',
|
|
url: 'https://example.vercel.app',
|
|
deploymentCount: 1,
|
|
target: 'production' as const,
|
|
status: 'ready' as const,
|
|
createdAt: 1_700_000_000_000,
|
|
updatedAt: 1_700_000_000_001,
|
|
};
|
|
|
|
describe('od deploy CLI', () => {
|
|
let stub: StubServer;
|
|
|
|
beforeAll(async () => {
|
|
stub = await startStubServer();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await stub.close();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
stub.requests.length = 0;
|
|
stub.setResponder(() => ({ status: 200, body: STUB_DEPLOYMENT }));
|
|
});
|
|
|
|
afterEach(() => {
|
|
stub.setResponder(() => ({ status: 200, body: STUB_DEPLOYMENT }));
|
|
});
|
|
|
|
// Case 1: --target preview → body.target === 'preview', exit 0
|
|
it('POSTs to /api/projects/:id/deploy with target=preview when --target preview is given', async () => {
|
|
const result = await runCli([
|
|
'deploy',
|
|
'proj-1',
|
|
'--file',
|
|
'index.html',
|
|
'--target',
|
|
'preview',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(stub.requests).toHaveLength(1);
|
|
const req = stub.requests[0]!;
|
|
expect(req.method).toBe('POST');
|
|
expect(req.url).toBe('/api/projects/proj-1/deploy');
|
|
const body = JSON.parse(req.body);
|
|
expect(body.target).toBe('preview');
|
|
expect(body.fileName).toBe('index.html');
|
|
});
|
|
|
|
// Case 2: --target production → body.target === 'production', exit 0
|
|
it('POSTs with target=production when --target production is given', async () => {
|
|
const result = await runCli([
|
|
'deploy',
|
|
'proj-1',
|
|
'--file',
|
|
'index.html',
|
|
'--target',
|
|
'production',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(stub.requests).toHaveLength(1);
|
|
const body = JSON.parse(stub.requests[0]!.body);
|
|
expect(body.target).toBe('production');
|
|
});
|
|
|
|
// Case 3: omitted --target → body must NOT contain a `target` key at all
|
|
// (so the endpoint applies its own production default server-side)
|
|
it('omits the target key entirely from the POST body when --target is not supplied', async () => {
|
|
const result = await runCli([
|
|
'deploy',
|
|
'proj-1',
|
|
'--file',
|
|
'index.html',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(stub.requests).toHaveLength(1);
|
|
const body = JSON.parse(stub.requests[0]!.body);
|
|
expect('target' in body).toBe(false);
|
|
expect(body.fileName).toBe('index.html');
|
|
});
|
|
|
|
// Case 4: invalid --target value → CLI rejects locally (no HTTP request made)
|
|
it('rejects --target staging locally and makes no HTTP request', async () => {
|
|
const result = await runCli([
|
|
'deploy',
|
|
'proj-1',
|
|
'--file',
|
|
'index.html',
|
|
'--target',
|
|
'staging',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).not.toBe(0);
|
|
expect(stub.requests).toHaveLength(0);
|
|
// stderr should mention the invalid value so the user understands what went wrong
|
|
expect(result.stderr).toMatch(/staging/);
|
|
});
|
|
|
|
// Case 5: --json → stdout is the raw parseable JSON from the daemon
|
|
it('outputs the raw daemon JSON response to stdout under --json', async () => {
|
|
const stubResponse = { ...STUB_DEPLOYMENT, url: 'https://example-json.vercel.app' };
|
|
stub.setResponder(() => ({ status: 200, body: stubResponse }));
|
|
|
|
const result = await runCli([
|
|
'deploy',
|
|
'proj-1',
|
|
'--file',
|
|
'index.html',
|
|
'--json',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(stub.requests).toHaveLength(1);
|
|
const parsed = JSON.parse(result.stdout);
|
|
expect(parsed).toMatchObject({
|
|
id: stubResponse.id,
|
|
url: stubResponse.url,
|
|
target: stubResponse.target,
|
|
status: stubResponse.status,
|
|
});
|
|
});
|
|
|
|
// Case 6a: daemon returns 400 → CLI exits non-zero and surfaces the error message
|
|
it('exits non-zero and surfaces the error message when the daemon returns 400', async () => {
|
|
stub.setResponder(() => ({
|
|
status: 400,
|
|
body: { error: 'fileName required' },
|
|
}));
|
|
|
|
const result = await runCli([
|
|
'deploy',
|
|
'proj-1',
|
|
'--file',
|
|
'index.html',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stderr).toMatch(/fileName required/);
|
|
});
|
|
|
|
// Case 6b: daemon returns 500 → CLI exits non-zero
|
|
it('exits non-zero when the daemon returns 500', async () => {
|
|
stub.setResponder(() => ({
|
|
status: 500,
|
|
body: { error: 'internal error' },
|
|
}));
|
|
|
|
const result = await runCli([
|
|
'deploy',
|
|
'proj-1',
|
|
'--file',
|
|
'index.html',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).not.toBe(0);
|
|
});
|
|
|
|
// Case 7: --provider cloudflare-pages with CF flags → body includes providerId and cloudflarePages
|
|
// ASSUMPTION: Cloudflare-specific flags are --cf-zone-id, --cf-zone-name, --cf-domain-prefix
|
|
// matching CloudflarePagesDeploySelection { zoneId, zoneName, domainPrefix }
|
|
it('sends providerId=cloudflare-pages and cloudflarePages object when --provider cloudflare-pages is given with CF flags', async () => {
|
|
stub.setResponder(() => ({
|
|
status: 200,
|
|
body: { ...STUB_DEPLOYMENT, providerId: 'cloudflare-pages' },
|
|
}));
|
|
|
|
const result = await runCli([
|
|
'deploy',
|
|
'proj-1',
|
|
'--file',
|
|
'index.html',
|
|
'--provider',
|
|
'cloudflare-pages',
|
|
'--cf-zone-id',
|
|
'zone-abc',
|
|
'--cf-zone-name',
|
|
'example.com',
|
|
'--cf-domain-prefix',
|
|
'my-project',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(stub.requests).toHaveLength(1);
|
|
const body = JSON.parse(stub.requests[0]!.body);
|
|
expect(body.providerId).toBe('cloudflare-pages');
|
|
expect(body.cloudflarePages).toMatchObject({
|
|
zoneId: 'zone-abc',
|
|
zoneName: 'example.com',
|
|
domainPrefix: 'my-project',
|
|
});
|
|
});
|
|
|
|
// Default provider is vercel-self when --provider is omitted
|
|
it('defaults to providerId=vercel-self when --provider is not given', async () => {
|
|
const result = await runCli([
|
|
'deploy',
|
|
'proj-1',
|
|
'--file',
|
|
'index.html',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
const body = JSON.parse(stub.requests[0]!.body);
|
|
expect(body.providerId).toBe('vercel-self');
|
|
});
|
|
|
|
// Missing required --file flag → CLI should reject locally without HTTP
|
|
it('exits non-zero and emits no HTTP request when --file is missing', async () => {
|
|
const result = await runCli([
|
|
'deploy',
|
|
'proj-1',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).not.toBe(0);
|
|
expect(stub.requests).toHaveLength(0);
|
|
expect(result.stderr).toMatch(/--file/i);
|
|
});
|
|
|
|
// Missing positional projectId → CLI should reject locally without HTTP
|
|
it('exits non-zero and emits no HTTP request when projectId is missing', async () => {
|
|
const result = await runCli([
|
|
'deploy',
|
|
'--file',
|
|
'index.html',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).not.toBe(0);
|
|
expect(stub.requests).toHaveLength(0);
|
|
});
|
|
});
|