Skip to content
Merged
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
74 changes: 61 additions & 13 deletions packages/void/src/cli/create-project.ts
Original file line number Diff line number Diff line change
@@ -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<ProjectConfig> {
let defaultSlug = inferDefaultSlug(root);
initialDefaultSlug: string,
promptSlug: (defaultSlug: string) => Promise<string | undefined>,
): Promise<CreateProjectResponse | undefined> {
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}`;
Expand All @@ -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<ProjectConfig> {
// 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');
}
6 changes: 3 additions & 3 deletions packages/void/src/cli/project-slug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
cursor[bot] marked this conversation as resolved.
return undefined;
}
42 changes: 27 additions & 15 deletions packages/void/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
cursor[bot] marked this conversation as resolved.
} else {
const project = projects.find(
(p: import('./cli/client.ts').ProjectResponse) => p.id === result,
Expand Down
78 changes: 74 additions & 4 deletions packages/void/test/unit/deploy-project-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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 () => {
Expand Down