Skip to content

Commit dfd013a

Browse files
tonychang04claude
andauthored
fix(project create): never block on a name prompt — auto-generate when the dir name is useless (#46)
`insta project create` (no arg) dropped into an interactive `project name [projects]:` prompt — so `curl … | sh && insta project create` and any run from ~/projects just HANGS, with a nonsense default (the cwd basename 'projects'). Railway-grade onboarding shouldn't stall on input. Now name resolution never prompts: - explicit arg wins (slugified) - else the cwd basename IF it's a real project-dir name (e.g. ~/my-app → 'my-app') - else an auto-generated friendly name (Vercel/Render style, e.g. 'swift-meadow-482') for generic dirs (projects, tmp, ~, src, code, …) and the home dir itself Removed the readline prompt path entirely (stdin no longer touched). Pass a name or rename later if you want something specific. 77/77 tests green, tsc clean. Claude-Session: https://claude.ai/code/session_01GMbAe5K1RfwaAcge5inX7P Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 398c5e4 commit dfd013a

2 files changed

Lines changed: 53 additions & 24 deletions

File tree

src/commands/project.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
1-
import { createInterface } from 'node:readline/promises'
1+
import { homedir } from 'node:os'
22
import { ApiClient, requireProject } from '../api.js'
33
import { writeProject } from '../config.js'
44
import { info, die, printJson, handleApproval, renderNextActions } from '../util.js'
55
import { installObserve } from '../observe/install.js'
66
import { installSkills } from '../ensure-skills.js'
77

8-
// Interactive name prompt (stderr, so piped stdout stays clean). Enter accepts the default.
9-
async function promptName(question: string, def: string): Promise<string> {
10-
const rl = createInterface({ input: process.stdin, output: process.stderr })
11-
try { return (await rl.question(`${question} [${def}]: `)).trim() } finally { rl.close() }
8+
// Generic directory names that make a useless project name ("projects", "~", "tmp", …). When the
9+
// cwd basename is one of these we auto-generate a friendly name instead of using it.
10+
const GENERIC_DIRS = new Set([
11+
'projects', 'project', 'home', 'tmp', 'temp', 'desktop', 'documents', 'downloads',
12+
'src', 'source', 'code', 'dev', 'work', 'workspace', 'repos', 'repo', 'git',
13+
'app', 'apps', 'users', 'user', 'bin', 'new', 'test', 'tests',
14+
])
15+
const ADJ = ['swift', 'brave', 'calm', 'bright', 'bold', 'quiet', 'warm', 'keen', 'wise',
16+
'lucky', 'sunny', 'cosmic', 'gentle', 'rapid', 'vivid', 'amber', 'crisp', 'noble']
17+
const NOUN = ['otter', 'falcon', 'maple', 'river', 'harbor', 'meadow', 'comet', 'cedar', 'lark',
18+
'delta', 'summit', 'ember', 'willow', 'pixel', 'forge', 'harbor', 'atlas', 'quartz']
19+
20+
/** A friendly auto-generated name like `swift-meadow-482` (Vercel/Render style). */
21+
export function generateProjectName(rand: () => number = Math.random): string {
22+
const pick = (a: string[]) => a[Math.floor(rand() * a.length)]
23+
return `${pick(ADJ)}-${pick(NOUN)}-${100 + Math.floor(rand() * 900)}`
1224
}
1325

1426
// Best-effort: wire the credential-audit hook into the project (no-op if assets aren't built).
@@ -31,23 +43,26 @@ export function slugifyName(raw: string): string {
3143
return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
3244
}
3345

34-
/** Name resolution so the recommended one-liner (`insta project create`, no arg) is paste-and-run:
35-
* explicit arg wins; else prompt with the cwd basename as default (TTY); else use the basename. */
46+
/** Name resolution so `insta project create` (no arg) NEVER blocks on a prompt — the whole point
47+
* of a paste-and-run one-liner. Explicit arg wins; else use the cwd basename when it's a real
48+
* project-dir name; else auto-generate a friendly name (e.g. in ~/projects, where the basename
49+
* "projects" is useless). You can always pass a name or rename later. */
3650
export async function resolveProjectName(
3751
nameArg: string | undefined,
3852
cwd = process.cwd(),
39-
prompt?: (question: string, def: string) => Promise<string>,
53+
generate: () => string = generateProjectName,
4054
): Promise<string> {
41-
const fromDir = slugifyName(cwd.split('/').filter(Boolean).pop() ?? 'app') || 'app'
4255
if (nameArg) return slugifyName(nameArg)
43-
if (prompt && process.stdin.isTTY) return slugifyName((await prompt('project name', fromDir)) || fromDir) || fromDir
44-
return fromDir
56+
const base = slugifyName(cwd.split('/').filter(Boolean).pop() ?? '')
57+
const home = slugifyName(homedir().split('/').filter(Boolean).pop() ?? '')
58+
if (base && base !== home && !GENERIC_DIRS.has(base)) return base
59+
return generate()
4560
}
4661

4762
export async function projectCreate(name: string | undefined, opts: { org?: string }): Promise<void> {
4863
const api = await ApiClient.load()
4964
const orgId = await resolveOrg(api, opts.org)
50-
const resolved = await resolveProjectName(name, process.cwd(), promptName)
65+
const resolved = await resolveProjectName(name, process.cwd())
5166
const out = await api.request('POST', `/orgs/${orgId}/projects`, { name: resolved })
5267
await writeProject({ projectId: out.project.id, orgId, branch: out.defaultBranch.name })
5368
info(`created project ${out.project.id} (${resolved})`)

test/create-name.test.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,36 @@
1-
import { test, expect, afterEach } from 'vitest'
2-
import { slugifyName, resolveProjectName } from '../src/commands/project.js'
3-
4-
const realTTY = process.stdin.isTTY
5-
afterEach(() => { Object.defineProperty(process.stdin, 'isTTY', { value: realTTY, configurable: true }) })
1+
import { test, expect } from 'vitest'
2+
import { slugifyName, resolveProjectName, generateProjectName } from '../src/commands/project.js'
63

74
test('slugify makes a valid project name', () => {
85
expect(slugifyName('My Cool App!')).toBe('my-cool-app')
96
expect(slugifyName('linkbox')).toBe('linkbox')
107
})
8+
119
test('explicit arg wins (slugified)', async () => {
1210
expect(await resolveProjectName('LinkBox', '/x/whatever')).toBe('linkbox')
1311
})
14-
test('no arg, non-TTY → directory basename (so the pasted one-liner runs unedited)', async () => {
15-
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true })
16-
expect(await resolveProjectName(undefined, '/Users/me/my-project')).toBe('my-project')
12+
13+
test('no arg, real project dir → the dir basename (intuitive, no prompt)', async () => {
14+
expect(await resolveProjectName(undefined, '/Users/me/my-project', () => 'gen-name-1')).toBe('my-project')
15+
})
16+
17+
test('no arg, GENERIC dir → auto-generated name, never the useless basename', async () => {
18+
// ~/projects, /tmp, etc. must NOT become a project literally named "projects"/"tmp".
19+
expect(await resolveProjectName(undefined, '/Users/gary/projects', () => 'swift-otter-482')).toBe('swift-otter-482')
20+
expect(await resolveProjectName(undefined, '/tmp', () => 'swift-otter-482')).toBe('swift-otter-482')
21+
})
22+
23+
test('no arg NEVER blocks on a prompt (no stdin/TTY dependency)', async () => {
24+
// Regression: create must resolve a name with zero interaction so `curl|sh && insta project create`
25+
// and `insta project create` in ~/projects never hang waiting for input.
26+
const name = await resolveProjectName(undefined, '/Users/gary/projects', () => 'auto-name-1')
27+
expect(name).toBe('auto-name-1')
1728
})
18-
test('no arg, TTY → prompt, Enter accepts the dir-name default', async () => {
19-
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true })
20-
expect(await resolveProjectName(undefined, '/Users/me/cool-thing', async () => '')).toBe('cool-thing')
21-
expect(await resolveProjectName(undefined, '/Users/me/cool-thing', async () => 'chosen name')).toBe('chosen-name')
29+
30+
test('generated names are valid slugs shaped adjective-noun-NNN', () => {
31+
// deterministic rand → first adjective, first noun, floor(0*900)=0 → "swift-otter-100"
32+
const n = generateProjectName(() => 0)
33+
expect(n).toBe('swift-otter-100')
34+
expect(slugifyName(n)).toBe(n) // already a valid project name
35+
expect(n).toMatch(/^[a-z]+-[a-z]+-\d{3}$/)
2236
})

0 commit comments

Comments
 (0)