Skip to content
Merged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@insforge/cli",

"version": "0.1.43",
"version": "0.1.44",
"description": "InsForge CLI - Command line tool for InsForge platform",
"type": "module",
"bin": {
Expand Down
142 changes: 142 additions & 0 deletions src/commands/create.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import * as path from 'node:path';

/**
* Unit tests for create command logic extracted into pure functions.
* These test the validation and normalization rules without needing
* to mock the full CLI command handler.
*/

// Mirrors the sanitization logic in create.ts line ~281
function sanitizeDirName(input: string): string {
return path.basename(input).replace(/[^a-zA-Z0-9._-]/g, '-');
}

// Mirrors the validation logic in create.ts lines ~274-278
function validateDirInput(v: string): string | undefined {
if (v.length < 1) return 'Directory name is required';
const normalized = path.basename(v).replace(/[^a-zA-Z0-9._-]/g, '-');
if (!normalized || normalized === '.' || normalized === '..') return 'Invalid directory name';
return undefined;
}

// Mirrors the post-normalization check in create.ts lines ~283-285
function isValidNormalizedDir(dirName: string): boolean {
return !!dirName && dirName !== '.' && dirName !== '..';
}

describe('create command: directory name validation', () => {
it('accepts a simple name', () => {
expect(validateDirInput('my-app')).toBeUndefined();
expect(sanitizeDirName('my-app')).toBe('my-app');
});

it('accepts names with dots and underscores', () => {
expect(validateDirInput('my_app.v2')).toBeUndefined();
expect(sanitizeDirName('my_app.v2')).toBe('my_app.v2');
});

it('rejects empty input', () => {
expect(validateDirInput('')).toBe('Directory name is required');
});

it('rejects . and ..', () => {
expect(validateDirInput('.')).toBe('Invalid directory name');
expect(validateDirInput('..')).toBe('Invalid directory name');
});

it('rejects / which normalizes to empty string', () => {
// path.basename('/') returns '' which becomes '' after sanitization
expect(validateDirInput('/')).toBe('Invalid directory name');
});

it('rejects ./ which normalizes to .', () => {
expect(validateDirInput('./')).toBe('Invalid directory name');
});

it('sanitizes special characters to hyphens', () => {
expect(sanitizeDirName('my app!')).toBe('my-app-');
expect(sanitizeDirName('hello@world')).toBe('hello-world');
});

it('extracts basename from path input', () => {
expect(sanitizeDirName('/some/path/my-app')).toBe('my-app');
expect(sanitizeDirName('../../my-app')).toBe('my-app');
});

it('post-normalization check rejects empty and dot names', () => {
expect(isValidNormalizedDir('')).toBe(false);
expect(isValidNormalizedDir('.')).toBe(false);
expect(isValidNormalizedDir('..')).toBe(false);
});

it('post-normalization check accepts valid names', () => {
expect(isValidNormalizedDir('my-app')).toBe(true);
expect(isValidNormalizedDir('test.project')).toBe(true);
});
});

describe('create command: org auto-select logic', () => {
// Mirrors the org selection logic in create.ts
function selectOrg(
orgs: Array<{ id: string; name: string }>,
json: boolean,
providedOrgId?: string,
): { orgId: string | null; error: string | null; autoSelected: boolean } {
if (providedOrgId) {
return { orgId: providedOrgId, error: null, autoSelected: false };
}
if (orgs.length === 0) {
return { orgId: null, error: 'No organizations found.', autoSelected: false };
}
if (orgs.length === 1) {
return { orgId: orgs[0].id, error: null, autoSelected: true };
}
// Multiple orgs
if (json) {
return { orgId: null, error: 'Multiple organizations found. Specify --org-id.', autoSelected: false };
}
// Would prompt interactively
return { orgId: null, error: null, autoSelected: false };
}

it('uses provided org-id directly', () => {
const result = selectOrg([{ id: 'org1', name: 'Org 1' }], false, 'org-provided');
expect(result.orgId).toBe('org-provided');
expect(result.autoSelected).toBe(false);
});

it('errors when no orgs exist', () => {
const result = selectOrg([], false);
expect(result.error).toBe('No organizations found.');
});

it('auto-selects single org in interactive mode', () => {
const result = selectOrg([{ id: 'org1', name: 'My Org' }], false);
expect(result.orgId).toBe('org1');
expect(result.autoSelected).toBe(true);
});

it('auto-selects single org in JSON mode', () => {
const result = selectOrg([{ id: 'org1', name: 'My Org' }], true);
expect(result.orgId).toBe('org1');
expect(result.autoSelected).toBe(true);
});

it('errors in JSON mode with multiple orgs', () => {
const result = selectOrg(
[{ id: 'org1', name: 'Org 1' }, { id: 'org2', name: 'Org 2' }],
true,
);
expect(result.error).toBe('Multiple organizations found. Specify --org-id.');
});

it('returns null orgId for multiple orgs in interactive mode (would prompt)', () => {
const result = selectOrg(
[{ id: 'org1', name: 'Org 1' }, { id: 'org2', name: 'Org 2' }],
false,
);
expect(result.orgId).toBeNull();
expect(result.error).toBeNull();
});
});
124 changes: 102 additions & 22 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,25 +167,30 @@ export function registerCreateCommand(program: Command): void {
clack.intro("Let's build something great");
}

// 1. Select organization
// 1. Select organization (auto-select if only one)
let orgId = opts.orgId;
if (!orgId) {
const orgs = await listOrganizations(apiUrl);
if (orgs.length === 0) {
throw new CLIError('No organizations found.');
}
if (json) {
throw new CLIError('Specify --org-id in JSON mode.');
if (orgs.length === 1) {
orgId = orgs[0].id;
if (!json) clack.log.info(`Using organization: ${orgs[0].name}`);
} else {
if (json) {
throw new CLIError('Multiple organizations found. Specify --org-id.');
}
const selected = await clack.select({
message: 'Select an organization:',
options: orgs.map((o) => ({
value: o.id,
label: o.name,
})),
});
if (clack.isCancel(selected)) process.exit(0);
orgId = selected as string;
}
const selected = await clack.select({
message: 'Select an organization:',
options: orgs.map((o) => ({
value: o.id,
label: o.name,
})),
});
if (clack.isCancel(selected)) process.exit(0);
orgId = selected as string;
}

// Save default org
Expand Down Expand Up @@ -260,16 +265,56 @@ export function registerCreateCommand(program: Command): void {
approach: template === 'empty' ? 'blank' : 'template',
});

// 4. Create project via Platform API
// 4. Choose directory (templates need a subdirectory, blank uses cwd)
const hasTemplate = template !== 'empty';
let dirName: string | null = null;
const originalCwd = process.cwd();
let projectDir = originalCwd;

if (hasTemplate) {
dirName = projectName;
if (!json) {
const inputDir = await clack.text({
message: 'Directory name:',
initialValue: projectName,
validate: (v) => {
if (v.length < 1) return 'Directory name is required';
const normalized = path.basename(v).replace(/[^a-zA-Z0-9._-]/g, '-');
if (!normalized || normalized === '.' || normalized === '..') return 'Invalid directory name';
return undefined;
},
});
if (clack.isCancel(inputDir)) process.exit(0);
dirName = path.basename(inputDir as string).replace(/[^a-zA-Z0-9._-]/g, '-');
}

// Validate normalized dirName
if (!dirName || dirName === '.' || dirName === '..') {
throw new CLIError('Invalid directory name.');
}

// Create the project directory and switch into it
projectDir = path.resolve(originalCwd, dirName);
const dirExists = await fs.stat(projectDir).catch(() => null);
if (dirExists) {
throw new CLIError(`Directory "${dirName}" already exists.`);
}
await fs.mkdir(projectDir);
process.chdir(projectDir);
}

// 5. Create project via Platform API
let projectLinked = false;
const s = !json ? clack.spinner() : null;
s?.start('Creating project...');
try {
s?.start('Creating project...');

const project = await createProject(orgId, projectName, opts.region, apiUrl);

s?.message('Waiting for project to become active...');
await waitForProjectActive(project.id, apiUrl);

// 5. Fetch API key and link project
// 6. Fetch API key and link project
const apiKey = await getProjectApiKey(project.id, apiUrl);
const projectConfig: ProjectConfig = {
project_id: project.id,
Expand All @@ -281,11 +326,11 @@ export function registerCreateCommand(program: Command): void {
oss_host: buildOssHost(project.appkey, project.region),
};
saveProjectConfig(projectConfig);
projectLinked = true;

s?.stop(`Project "${project.name}" created and linked`);

// 6. Download template or seed env for blank projects
const hasTemplate = template !== 'empty';
// 7. Download template or seed env for blank projects
const githubTemplates = ['chatbot', 'crm', 'e-commerce', 'nextjs', 'react'];
if (githubTemplates.includes(template!)) {
await downloadGitHubTemplate(template!, projectConfig, json);
Expand Down Expand Up @@ -327,8 +372,12 @@ export function registerCreateCommand(program: Command): void {
trackCommand('create', orgId);
await reportCliUsage('cli.create', true, 6);

// 7. Install npm dependencies (template projects only)
if (hasTemplate) {
// 8. Install npm dependencies (template projects only, if download succeeded)
const templateDownloaded = hasTemplate
? await fs.stat(path.join(process.cwd(), 'package.json')).catch(() => null)
: null;

if (templateDownloaded) {
const installSpinner = !json ? clack.spinner() : null;
installSpinner?.start('Installing dependencies...');
try {
Expand All @@ -343,9 +392,9 @@ export function registerCreateCommand(program: Command): void {
}
}

// 8. Offer to deploy (template projects, interactive mode only)
// 9. Offer to deploy (template projects, interactive mode only)
let liveUrl: string | null = null;
if (hasTemplate && !json) {
if (templateDownloaded && !json) {
const shouldDeploy = await clack.confirm({
message: 'Would you like to deploy now?',
});
Expand Down Expand Up @@ -381,14 +430,15 @@ export function registerCreateCommand(program: Command): void {
}
}

// 9. Show links
// 10. Show links and next steps
const dashboardUrl = `${getFrontendUrl()}/dashboard/project/${project.id}`;

if (json) {
outputJson({
success: true,
project: { id: project.id, name: project.name, appkey: project.appkey, region: project.region },
template,
...(dirName ? { directory: dirName } : {}),
urls: {
dashboard: dashboardUrl,
...(liveUrl ? { liveSite: liveUrl } : {}),
Expand All @@ -399,8 +449,38 @@ export function registerCreateCommand(program: Command): void {
if (liveUrl) {
clack.log.success(`Live site: ${liveUrl}`);
}

// Next steps
if (templateDownloaded) {
const steps = [
`cd ${dirName}`,
'npm run dev',
];
clack.note(steps.join('\n'), 'Next steps');
clack.note('Open your coding agent (Claude Code, Codex, Cursor, etc.) to add new features.', 'Keep building');
} else if (hasTemplate && !templateDownloaded) {
clack.log.warn('Template download failed. You can retry or set up manually.');
} else {
const prompts = [
'Build a todo app with Google OAuth sign-in',
'Build an Instagram clone where users can upload photos, like, and comment',
'Build an AI chatbot with conversation history',
];
clack.note(
`Open your coding agent (Claude Code, Codex, Cursor, etc.) and try:\n\n${prompts.map((p) => `• "${p}"`).join('\n')}`,
'Start building',
);
}
clack.outro('Done!');
}
} catch (err) {
// Clean up the project directory if it was created but linking failed
if (!projectLinked && hasTemplate && projectDir !== originalCwd) {
process.chdir(originalCwd);
await fs.rm(projectDir, { recursive: true, force: true }).catch(() => {});
}
throw err;
}
} catch (err) {
handleError(err, json);
} finally {
Expand Down
Loading
Loading