diff --git a/src/commands/project.ts b/src/commands/project.ts index cabe5fa..f190b7e 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,9 +1,16 @@ +import { createInterface } from 'node:readline/promises' import { ApiClient, requireProject } from '../api.js' import { writeProject } from '../config.js' import { info, die, printJson, handleApproval, renderNextActions } from '../util.js' import { installObserve } from '../observe/install.js' import { installSkills } from '../ensure-skills.js' +// Interactive name prompt (stderr, so piped stdout stays clean). Enter accepts the default. +async function promptName(question: string, def: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }) + try { return (await rl.question(`${question} [${def}]: `)).trim() } finally { rl.close() } +} + // Best-effort: wire the credential-audit hook into the project (no-op if assets aren't built). function tryInstallObserve(): void { try { @@ -19,12 +26,31 @@ async function resolveOrg(api: ApiClient, given?: string): Promise { return orgs[0].id } -export async function projectCreate(name: string, opts: { org?: string }): Promise { +/** A valid project name from a raw string: lowercase, non-alnum → hyphen, trimmed. */ +export function slugifyName(raw: string): string { + return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) +} + +/** Name resolution so the recommended one-liner (`insta project create`, no arg) is paste-and-run: + * explicit arg wins; else prompt with the cwd basename as default (TTY); else use the basename. */ +export async function resolveProjectName( + nameArg: string | undefined, + cwd = process.cwd(), + prompt?: (question: string, def: string) => Promise, +): Promise { + const fromDir = slugifyName(cwd.split('/').filter(Boolean).pop() ?? 'app') || 'app' + if (nameArg) return slugifyName(nameArg) + if (prompt && process.stdin.isTTY) return slugifyName((await prompt('project name', fromDir)) || fromDir) || fromDir + return fromDir +} + +export async function projectCreate(name: string | undefined, opts: { org?: string }): Promise { const api = await ApiClient.load() const orgId = await resolveOrg(api, opts.org) - const out = await api.request('POST', `/orgs/${orgId}/projects`, { name }) + const resolved = await resolveProjectName(name, process.cwd(), promptName) + const out = await api.request('POST', `/orgs/${orgId}/projects`, { name: resolved }) await writeProject({ projectId: out.project.id, orgId, branch: out.defaultBranch.name }) - info(`created project ${out.project.id} (${name})`) + info(`created project ${out.project.id} (${resolved})`) info(` resources: ${out.resources.map((r: any) => r.kind).join(', ')}`) info(` linked ./.insta/project.json (branch ${out.defaultBranch.name})`) renderNextActions(out.nextActions) diff --git a/src/index.ts b/src/index.ts index ca5c4c5..04cf6ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,7 +77,7 @@ orgCmd.command('create ').action(guard((name) => org.orgCreate(name))) // ---- project ---- const pj = program.command('project').description('Manage projects') -pj.command('create ').option('--org ', 'org to create under (default: personal)').action(guard((name, o) => project.projectCreate(name, o))) +pj.command('create [name]').option('--org ', 'org to create under (default: personal)').action(guard((name, o) => project.projectCreate(name, o))) pj.command('list').option('--org ').option('--json').action(guard((o) => project.projectList(o))) pj.command('link ').description('Link a project to this directory').action(guard((id) => project.projectLink(id))) pj.command('delete').option('--project ').action(guard((o) => project.projectDelete(o))) diff --git a/test/create-name.test.ts b/test/create-name.test.ts new file mode 100644 index 0000000..140e847 --- /dev/null +++ b/test/create-name.test.ts @@ -0,0 +1,22 @@ +import { test, expect, afterEach } from 'vitest' +import { slugifyName, resolveProjectName } from '../src/commands/project.js' + +const realTTY = process.stdin.isTTY +afterEach(() => { Object.defineProperty(process.stdin, 'isTTY', { value: realTTY, configurable: true }) }) + +test('slugify makes a valid project name', () => { + expect(slugifyName('My Cool App!')).toBe('my-cool-app') + expect(slugifyName('linkbox')).toBe('linkbox') +}) +test('explicit arg wins (slugified)', async () => { + expect(await resolveProjectName('LinkBox', '/x/whatever')).toBe('linkbox') +}) +test('no arg, non-TTY → directory basename (so the pasted one-liner runs unedited)', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }) + expect(await resolveProjectName(undefined, '/Users/me/my-project')).toBe('my-project') +}) +test('no arg, TTY → prompt, Enter accepts the dir-name default', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) + expect(await resolveProjectName(undefined, '/Users/me/cool-thing', async () => '')).toBe('cool-thing') + expect(await resolveProjectName(undefined, '/Users/me/cool-thing', async () => 'chosen name')).toBe('chosen-name') +})