diff --git a/package.json b/package.json index 0aad093..2807a32 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/commands/create.test.ts b/src/commands/create.test.ts new file mode 100644 index 0000000..f48e0ea --- /dev/null +++ b/src/commands/create.test.ts @@ -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(); + }); +}); diff --git a/src/commands/create.ts b/src/commands/create.ts index f6ba783..ded2e16 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -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 @@ -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, @@ -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); @@ -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 { @@ -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?', }); @@ -381,7 +430,7 @@ export function registerCreateCommand(program: Command): void { } } - // 9. Show links + // 10. Show links and next steps const dashboardUrl = `${getFrontendUrl()}/dashboard/project/${project.id}`; if (json) { @@ -389,6 +438,7 @@ export function registerCreateCommand(program: Command): void { 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 } : {}), @@ -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 { diff --git a/src/commands/projects/link.ts b/src/commands/projects/link.ts index 5fa2726..9b86bcc 100644 --- a/src/commands/projects/link.ts +++ b/src/commands/projects/link.ts @@ -11,41 +11,17 @@ import { getProjectApiKey, reportAgentConnected, } from '../../lib/api/platform.js'; -import { getAnonKey } from '../../lib/api/oss.js'; import { getGlobalConfig, saveGlobalConfig, saveProjectConfig, getFrontendUrl } from '../../lib/config.js'; import { requireAuth } from '../../lib/credentials.js'; import { handleError, getRootOpts, CLIError } from '../../lib/errors.js'; import { outputJson, outputSuccess } from '../../lib/output.js'; -import { readEnvFile } from '../../lib/env.js'; import { installSkills, reportCliUsage } from '../../lib/skills.js'; import { captureEvent, trackCommand, shutdownAnalytics } from '../../lib/analytics.js'; -import { deployProject } from '../deployments/deploy.js'; import { downloadGitHubTemplate, downloadTemplate, type Framework } from '../create.js'; import type { ProjectConfig } from '../../types.js'; const execAsync = promisify(exec); -/** Files that indicate real project content exists */ -const PROJECT_MARKERS = new Set([ - 'package.json', - 'index.html', - 'tsconfig.json', - 'next.config.js', - 'next.config.ts', - 'next.config.mjs', - 'vite.config.ts', - 'vite.config.js', - 'src', - 'app', - 'pages', - 'public', -]); - -async function isDirEmpty(dir: string): Promise { - const entries = await fs.readdir(dir); - return !entries.some((e) => PROJECT_MARKERS.has(e)); -} - function buildOssHost(appkey: string, region: string): string { return `https://${appkey}.${region}.insforge.app`; } @@ -56,6 +32,7 @@ export function registerProjectLinkCommand(program: Command): void { .description('Link current directory to an InsForge project') .option('--project-id ', 'Project ID to link') .option('--org-id ', 'Organization ID') + .option('--template