diff --git a/packages/void/src/cli/create-project.ts b/packages/void/src/cli/create-project.ts index 04880c29b..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,44 @@ export async function promptAndCreateProject( } } -function isRetryableCreateError(error: PlatformApiError): boolean { - return error.status === 400 || error.status === 409 || error.status === 422; +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; + } + // 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 { + // 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/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/src/index.ts b/packages/void/src/index.ts index df6d91f72..a4771a181 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -514,29 +514,41 @@ 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; - } - + const { createProjectWithSlugRetry } = await import('./cli/create-project.ts'); + let created; try { - const created = await client.createProject(slugResult.trim()); - projectId = created.id; - slug = created.slug; + // 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; } - log.success(`Project created: ${pc.blue(slug)}`); + if (!created) { + return; + } + projectId = created.id; + slug = created.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 a29659832..e862b1543 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,81 @@ 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('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'); + 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 () => {