From 13eaf34a8df2ca3bc3c06fc7ddf8fb40dcc00535 Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Wed, 15 Jul 2026 09:13:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20insta=20project=20create=20name=20is=20o?= =?UTF-8?q?ptional=20=E2=80=94=20recommended=20one-liner=20is=20paste-run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console's empty-state onboarding recommended 'curl … | sh && insta project create ', but the placeholder is unrunnable: zsh reads <…> as redirection → 'parse error near \n'. The RECOMMENDED command couldn't be pasted. Name is now optional: explicit arg wins; else prompt with the cwd dir-name default (TTY); else use the dir name (non-TTY/CI). So 'insta project create' runs clean. TDD: slugify + resolver, 4 cases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GMbAe5K1RfwaAcge5inX7P --- src/commands/project.ts | 32 +++++++++++++++++++++++++++++--- src/index.ts | 2 +- test/create-name.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 test/create-name.test.ts 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') +})