From 7b77e17b4fb9066040d64a1e227c8786446dbaf0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 18 May 2026 22:13:54 +0800 Subject: [PATCH 1/3] fix(cli): let the server enforce slug-length plan limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy/AI-setup slug prompt rejected any slug of 5 characters or fewer unconditionally, even for paid users — so a Pro user trying to deploy with a short slug (e.g. `oxc`) could never get past the prompt. The plan-aware check already exists on the API; trust it and re-prompt on 403 so free-plan users still get a smooth retry loop. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/cli/create-project.ts | 5 ++- packages/void/src/cli/project-slug.ts | 6 ++-- .../test/unit/deploy-project-setup.test.ts | 35 ++++++++++++++++--- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/void/src/cli/create-project.ts b/packages/void/src/cli/create-project.ts index 04880c29b..2da1078e8 100644 --- a/packages/void/src/cli/create-project.ts +++ b/packages/void/src/cli/create-project.ts @@ -33,5 +33,8 @@ export async function promptAndCreateProject( } function isRetryableCreateError(error: PlatformApiError): boolean { - return error.status === 400 || error.status === 409 || error.status === 422; + // 403 covers slug-length plan rejection — re-prompt so the user can pick a longer slug. + return ( + error.status === 400 || error.status === 403 || error.status === 409 || error.status === 422 + ); } diff --git a/packages/void/src/cli/project-slug.ts b/packages/void/src/cli/project-slug.ts index 03770840f..3c4ca04a0 100644 --- a/packages/void/src/cli/project-slug.ts +++ b/packages/void/src/cli/project-slug.ts @@ -10,9 +10,9 @@ export function validateProjectSlug(value: string | undefined): string | undefin if (!valid) { return 'Slug must be lowercase alphanumeric with dashes.'; } - if (slug.length <= 5) { - return 'Slugs of 5 characters or fewer require a paid plan. Use at least 6 characters.'; - } + // Plan-based slug-length restrictions are enforced server-side so the check + // can read the caller's current plan. The retry loop in create-project.ts + // re-prompts when the server rejects. return undefined; } diff --git a/packages/void/test/unit/deploy-project-setup.test.ts b/packages/void/test/unit/deploy-project-setup.test.ts index a29659832..e5d3b3bda 100644 --- a/packages/void/test/unit/deploy-project-setup.test.ts +++ b/packages/void/test/unit/deploy-project-setup.test.ts @@ -77,7 +77,7 @@ describe('promptDeployProjectConfig', () => { expect(JSON.parse(readFileSync(join(root, '.void', 'project.json'), 'utf-8'))).toEqual(config); }); - it('rejects short free-plan project slugs at the prompt', async () => { + it('accepts short slugs at the prompt and lets the server enforce plan limits', async () => { const { promptProjectSlug } = await import('../../src/cli/prompt.ts'); mockText.mockResolvedValueOnce('valid-app'); @@ -86,11 +86,38 @@ describe('promptDeployProjectConfig', () => { const promptConfig = mockText.mock.calls[0]?.[0] as { validate: (value: string) => string | undefined; }; - expect(promptConfig.validate('abcde')).toBe( - 'Slugs of 5 characters or fewer require a paid plan. Use at least 6 characters.', - ); + // Short slugs no longer rejected client-side — plan is checked server-side. + expect(promptConfig.validate('abc')).toBeUndefined(); + expect(promptConfig.validate('abcde')).toBeUndefined(); expect(promptConfig.validate('abcdef')).toBeUndefined(); + // Format check still runs locally. expect(promptConfig.validate('ABC')).toBe('Slug must be lowercase alphanumeric with dashes.'); + expect(promptConfig.validate('')).toBe('Project slug is required.'); + }); + + it('re-prompts when the server rejects a short slug for a free-plan user', async () => { + const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { PlatformApiError } = await import('../../src/cli/client.ts'); + const client = createClient(); + client.listProjects.mockResolvedValueOnce([]); + client.createProject + .mockRejectedValueOnce( + new PlatformApiError( + 'createProject', + 403, + JSON.stringify({ error: 'short slugs (5 characters or fewer) require a paid plan' }), + ), + ) + .mockResolvedValueOnce({ id: 'proj_new', slug: 'longer-app' }); + mockSelect.mockResolvedValueOnce('create'); + mockText.mockResolvedValueOnce('oxc').mockResolvedValueOnce('longer-app'); + + const config = await promptDeployProjectConfig(root, client); + + expect(client.createProject).toHaveBeenNthCalledWith(1, 'oxc'); + expect(client.createProject).toHaveBeenNthCalledWith(2, 'longer-app'); + expect(mockText).toHaveBeenCalledTimes(2); + expect(config).toEqual({ projectId: 'proj_new', slug: 'longer-app' }); }); it('does not show project names when creating with existing projects available', async () => { From 92ad011345a8d207c0dbb34b712403d618c671f6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 18 May 2026 23:08:51 +0800 Subject: [PATCH 2/3] fix(cli): narrow 403 retry to slug errors + retry AI-setup path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the slug-length plan rejection retry loop: - isRetryableCreateError now only retries on 403 when the error message mentions "slug" — prevents infinite re-prompt loops on unrelated 403s like the "account suspended" response from the auth middleware. - runAiSetupPrompt (dev-server AI prompt) now retries on slug-related 403s instead of bailing with a generic "Failed to create project" error. Previously the client-side length check shielded this path; removing it exposed the gap. - Add a test asserting non-slug 403s are surfaced (not retried). --- packages/void/src/cli/create-project.ts | 21 ++++++-- packages/void/src/index.ts | 54 ++++++++++++------- .../test/unit/deploy-project-setup.test.ts | 17 ++++++ 3 files changed, 67 insertions(+), 25 deletions(-) diff --git a/packages/void/src/cli/create-project.ts b/packages/void/src/cli/create-project.ts index 2da1078e8..4bbdb90b7 100644 --- a/packages/void/src/cli/create-project.ts +++ b/packages/void/src/cli/create-project.ts @@ -32,9 +32,20 @@ export async function promptAndCreateProject( } } -function isRetryableCreateError(error: PlatformApiError): boolean { - // 403 covers slug-length plan rejection — re-prompt so the user can pick a longer slug. - return ( - error.status === 400 || error.status === 403 || error.status === 409 || error.status === 422 - ); +export function isRetryableCreateError(error: PlatformApiError): boolean { + if (error.status === 400 || error.status === 409 || error.status === 422) { + return true; + } + // 403 is retryable ONLY when it's the slug-length plan rejection — other + // 403s (e.g. account suspended from auth middleware) are not fixable by + // choosing a different slug and would cause an infinite re-prompt loop. + if (error.status === 403) { + return isSlugRelatedError(error); + } + return false; +} + +function isSlugRelatedError(error: PlatformApiError): boolean { + const message = (error.code ?? error.body ?? '').toLowerCase(); + return message.includes('slug'); } diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index df6d91f72..bc99cefdc 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -514,28 +514,42 @@ async function runAiSetupPrompt( let slug: string; if (result === '__create__') { - const defaultSlug = inferDefaultSlug(root); const { validateProjectSlug } = await import('./cli/project-slug.ts'); - const slugResult = await text({ - message: 'Choose a slug for your new Void project:', - placeholder: 'my-app', - initialValue: defaultSlug, - validate: validateProjectSlug, - }); - if (isCancel(slugResult)) { - return; - } - - try { - const created = await client.createProject(slugResult.trim()); - projectId = created.id; - slug = created.slug; - } catch (err: unknown) { - voidError( - `project: Failed to create project: '${err instanceof Error ? err.message : String(err)}'.`, - ); - return; + const { isRetryableCreateError } = await import('./cli/create-project.ts'); + const { PlatformApiError } = await import('./cli/client.ts'); + let defaultSlug = inferDefaultSlug(root); + // Retry loop: server-side plan checks (e.g. slug-length on free plan) can + // reject after client validation. Re-prompt instead of bailing so users + // can pick a different slug without restarting the dev server. + let created: { id: string; slug: string } | undefined; + while (!created) { + const slugResult = await text({ + message: 'Choose a slug for your new Void project:', + placeholder: 'my-app', + initialValue: defaultSlug, + validate: validateProjectSlug, + }); + if (isCancel(slugResult)) { + return; + } + const trimmed = slugResult.trim(); + try { + created = await client.createProject(trimmed); + } catch (err: unknown) { + if (err instanceof PlatformApiError && isRetryableCreateError(err)) { + const reason = err.code || err.body || `HTTP ${err.status}`; + log.error(`project: Could not create project '${trimmed}': ${reason}.`); + defaultSlug = trimmed; + continue; + } + voidError( + `project: Failed to create project: '${err instanceof Error ? err.message : String(err)}'.`, + ); + return; + } } + projectId = created.id; + slug = created.slug; log.success(`Project created: ${pc.blue(slug)}`); } else { const project = projects.find( diff --git a/packages/void/test/unit/deploy-project-setup.test.ts b/packages/void/test/unit/deploy-project-setup.test.ts index e5d3b3bda..0b71ee743 100644 --- a/packages/void/test/unit/deploy-project-setup.test.ts +++ b/packages/void/test/unit/deploy-project-setup.test.ts @@ -120,6 +120,23 @@ describe('promptDeployProjectConfig', () => { expect(config).toEqual({ projectId: 'proj_new', slug: 'longer-app' }); }); + it('does not retry on non-slug 403 errors (e.g. account suspended)', async () => { + const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { PlatformApiError } = await import('../../src/cli/client.ts'); + const client = createClient(); + client.listProjects.mockResolvedValueOnce([]); + client.createProject.mockRejectedValueOnce( + new PlatformApiError('createProject', 403, JSON.stringify({ error: 'account suspended' })), + ); + mockSelect.mockResolvedValueOnce('create'); + mockText.mockResolvedValueOnce('my-app'); + + await expect(promptDeployProjectConfig(root, client)).rejects.toThrow(PlatformApiError); + + expect(client.createProject).toHaveBeenCalledTimes(1); + expect(mockText).toHaveBeenCalledTimes(1); + }); + it('does not show project names when creating with existing projects available', async () => { const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); const client = createClient(); From b8cb4bf678d580f407e18ea3a52c2357d05b9a88 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 18 May 2026 23:37:54 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(cli):=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20share=20slug-retry=20helper=20+=20inspect=20full=20?= =?UTF-8?q?403=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract createProjectWithSlugRetry from promptAndCreateProject so the AI-setup path in index.ts reuses the same retry loop instead of duplicating prompt/error/log logic (Cursor Bugbot review on 92ad0113). - Widen isSlugRelatedError to inspect both error.code and error.body instead of short-circuiting with `code ?? body`, so a future 403 response with a generic top-level code (e.g. "forbidden") and a slug message in `message` still triggers the re-prompt (Codex P2 review on 92ad0113). - Add a unit test covering the generic-code + slug-in-message case. --- packages/void/src/cli/create-project.ts | 60 +++++++++++++---- packages/void/src/index.ts | 64 +++++++++---------- .../test/unit/deploy-project-setup.test.ts | 26 ++++++++ 3 files changed, 104 insertions(+), 46 deletions(-) diff --git a/packages/void/src/cli/create-project.ts b/packages/void/src/cli/create-project.ts index 4bbdb90b7..6bd083286 100644 --- a/packages/void/src/cli/create-project.ts +++ b/packages/void/src/cli/create-project.ts @@ -1,25 +1,35 @@ import { log } from '@clack/prompts'; -import { PlatformApiError, type PlatformClient } from './client.ts'; +import { PlatformApiError, type CreateProjectResponse, type PlatformClient } from './client.ts'; import { pc } from './output.ts'; import { writeProjectConfig, type ProjectConfig } from './project.ts'; import { promptProjectSlug } from './prompt.ts'; import { inferDefaultSlug } from './resolve-project.ts'; -export async function promptAndCreateProject( - root: string, +/** + * Prompts for a slug and calls `client.createProject`, re-prompting whenever + * the server rejects with a retryable error (e.g. free-plan short-slug 403, + * slug-already-taken 409, validation 400/422). Returns `undefined` when the + * caller's prompt callback returns `undefined` (cancel). + * + * The shared retry loop keeps `promptAndCreateProject` (deploy/link paths) + * and the AI-setup path in `index.ts` in sync. + */ +export async function createProjectWithSlugRetry( client: PlatformClient, -): Promise { - let defaultSlug = inferDefaultSlug(root); + initialDefaultSlug: string, + promptSlug: (defaultSlug: string) => Promise, +): Promise { + let defaultSlug = initialDefaultSlug; while (true) { - // promptProjectSlug exits the process on Ctrl+C, so no cancel check needed here - const slug = await promptProjectSlug(defaultSlug); + const slug = await promptSlug(defaultSlug); + if (slug === undefined) { + return undefined; + } try { log.step(`Creating project ${pc.blue(slug)}...`); const project = await client.createProject(slug); - const config: ProjectConfig = { projectId: project.id, slug: project.slug }; - writeProjectConfig(root, config); - log.success(`Project created: ${pc.blue(config.slug)}`); - return config; + log.success(`Project created: ${pc.blue(project.slug)}`); + return project; } catch (error) { if (error instanceof PlatformApiError && isRetryableCreateError(error)) { const reason = error.code || error.body || `HTTP ${error.status}`; @@ -32,6 +42,26 @@ export async function promptAndCreateProject( } } +export async function promptAndCreateProject( + root: string, + client: PlatformClient, +): Promise { + // promptProjectSlug exits the process on Ctrl+C, so it never returns undefined. + const project = await createProjectWithSlugRetry( + client, + inferDefaultSlug(root), + async (defaultSlug) => promptProjectSlug(defaultSlug), + ); + // createProjectWithSlugRetry only returns undefined when the prompt does, + // which promptProjectSlug never does (it exits on cancel). + if (!project) { + throw new Error('Project creation cancelled.'); + } + const config: ProjectConfig = { projectId: project.id, slug: project.slug }; + writeProjectConfig(root, config); + return config; +} + export function isRetryableCreateError(error: PlatformApiError): boolean { if (error.status === 400 || error.status === 409 || error.status === 422) { return true; @@ -46,6 +76,10 @@ export function isRetryableCreateError(error: PlatformApiError): boolean { } function isSlugRelatedError(error: PlatformApiError): boolean { - const message = (error.code ?? error.body ?? '').toLowerCase(); - return message.includes('slug'); + // Inspect BOTH `code` (parsed error/message field) and `body` (full response + // text). The server's current short-slug response embeds "slug" in `error`, + // but a future generic code like "forbidden" with the slug detail only in a + // separate message field would slip past a `code ?? body` short-circuit. + const haystack = `${error.code ?? ''} ${error.body ?? ''}`.toLowerCase(); + return haystack.includes('slug'); } diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index bc99cefdc..a4771a181 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -515,42 +515,40 @@ async function runAiSetupPrompt( if (result === '__create__') { const { validateProjectSlug } = await import('./cli/project-slug.ts'); - const { isRetryableCreateError } = await import('./cli/create-project.ts'); - const { PlatformApiError } = await import('./cli/client.ts'); - let defaultSlug = inferDefaultSlug(root); - // Retry loop: server-side plan checks (e.g. slug-length on free plan) can - // reject after client validation. Re-prompt instead of bailing so users - // can pick a different slug without restarting the dev server. - let created: { id: string; slug: string } | undefined; - while (!created) { - const slugResult = await text({ - message: 'Choose a slug for your new Void project:', - placeholder: 'my-app', - initialValue: defaultSlug, - validate: validateProjectSlug, - }); - if (isCancel(slugResult)) { - return; - } - const trimmed = slugResult.trim(); - try { - created = await client.createProject(trimmed); - } catch (err: unknown) { - if (err instanceof PlatformApiError && isRetryableCreateError(err)) { - const reason = err.code || err.body || `HTTP ${err.status}`; - log.error(`project: Could not create project '${trimmed}': ${reason}.`); - defaultSlug = trimmed; - continue; - } - voidError( - `project: Failed to create project: '${err instanceof Error ? err.message : String(err)}'.`, - ); - return; - } + const { createProjectWithSlugRetry } = await import('./cli/create-project.ts'); + let created; + try { + // Share the slug-retry loop with deploy/link paths so server-side plan + // rejections (e.g. free-plan short-slug 403) re-prompt consistently. + // The prompt callback returns `undefined` on Ctrl+C so the helper can + // exit cleanly instead of calling process.exit(). + created = await createProjectWithSlugRetry( + client, + inferDefaultSlug(root), + async (defaultSlug) => { + const slugResult = await text({ + message: 'Choose a slug for your new Void project:', + placeholder: 'my-app', + initialValue: defaultSlug, + validate: validateProjectSlug, + }); + if (isCancel(slugResult)) { + return undefined; + } + return slugResult.trim(); + }, + ); + } catch (err: unknown) { + voidError( + `project: Failed to create project: '${err instanceof Error ? err.message : String(err)}'.`, + ); + return; + } + if (!created) { + return; } projectId = created.id; slug = created.slug; - log.success(`Project created: ${pc.blue(slug)}`); } else { const project = projects.find( (p: import('./cli/client.ts').ProjectResponse) => p.id === result, diff --git a/packages/void/test/unit/deploy-project-setup.test.ts b/packages/void/test/unit/deploy-project-setup.test.ts index 0b71ee743..e862b1543 100644 --- a/packages/void/test/unit/deploy-project-setup.test.ts +++ b/packages/void/test/unit/deploy-project-setup.test.ts @@ -120,6 +120,32 @@ describe('promptDeployProjectConfig', () => { expect(config).toEqual({ projectId: 'proj_new', slug: 'longer-app' }); }); + it('retries when 403 body mentions slug but code is generic', async () => { + const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { PlatformApiError } = await import('../../src/cli/client.ts'); + const client = createClient(); + client.listProjects.mockResolvedValueOnce([]); + // Generic top-level error code with the slug detail only in `message` — + // the retry check must inspect both fields, not short-circuit on `code`. + client.createProject + .mockRejectedValueOnce( + new PlatformApiError( + 'createProject', + 403, + JSON.stringify({ error: 'forbidden', message: 'slug too short for plan' }), + ), + ) + .mockResolvedValueOnce({ id: 'proj_new', slug: 'longer-app' }); + mockSelect.mockResolvedValueOnce('create'); + mockText.mockResolvedValueOnce('oxc').mockResolvedValueOnce('longer-app'); + + const config = await promptDeployProjectConfig(root, client); + + expect(client.createProject).toHaveBeenNthCalledWith(1, 'oxc'); + expect(client.createProject).toHaveBeenNthCalledWith(2, 'longer-app'); + expect(config).toEqual({ projectId: 'proj_new', slug: 'longer-app' }); + }); + it('does not retry on non-slug 403 errors (e.g. account suspended)', async () => { const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); const { PlatformApiError } = await import('../../src/cli/client.ts');