Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { createInterface } from 'node:readline/promises'
import { homedir } from 'node:os'
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<string> {
const rl = createInterface({ input: process.stdin, output: process.stderr })
try { return (await rl.question(`${question} [${def}]: `)).trim() } finally { rl.close() }
// Generic directory names that make a useless project name ("projects", "~", "tmp", …). When the
// cwd basename is one of these we auto-generate a friendly name instead of using it.
const GENERIC_DIRS = new Set([
'projects', 'project', 'home', 'tmp', 'temp', 'desktop', 'documents', 'downloads',
'src', 'source', 'code', 'dev', 'work', 'workspace', 'repos', 'repo', 'git',
'app', 'apps', 'users', 'user', 'bin', 'new', 'test', 'tests',
])
const ADJ = ['swift', 'brave', 'calm', 'bright', 'bold', 'quiet', 'warm', 'keen', 'wise',
'lucky', 'sunny', 'cosmic', 'gentle', 'rapid', 'vivid', 'amber', 'crisp', 'noble']
const NOUN = ['otter', 'falcon', 'maple', 'river', 'harbor', 'meadow', 'comet', 'cedar', 'lark',
'delta', 'summit', 'ember', 'willow', 'pixel', 'forge', 'harbor', 'atlas', 'quartz']

/** A friendly auto-generated name like `swift-meadow-482` (Vercel/Render style). */
export function generateProjectName(rand: () => number = Math.random): string {
const pick = (a: string[]) => a[Math.floor(rand() * a.length)]
return `${pick(ADJ)}-${pick(NOUN)}-${100 + Math.floor(rand() * 900)}`
}

// Best-effort: wire the credential-audit hook into the project (no-op if assets aren't built).
Expand All @@ -31,23 +43,26 @@ 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. */
/** Name resolution so `insta project create` (no arg) NEVER blocks on a prompt — the whole point
* of a paste-and-run one-liner. Explicit arg wins; else use the cwd basename when it's a real
* project-dir name; else auto-generate a friendly name (e.g. in ~/projects, where the basename
* "projects" is useless). You can always pass a name or rename later. */
export async function resolveProjectName(
nameArg: string | undefined,
cwd = process.cwd(),
prompt?: (question: string, def: string) => Promise<string>,
generate: () => string = generateProjectName,
): Promise<string> {
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
const base = slugifyName(cwd.split('/').filter(Boolean).pop() ?? '')
const home = slugifyName(homedir().split('/').filter(Boolean).pop() ?? '')
if (base && base !== home && !GENERIC_DIRS.has(base)) return base
return generate()
}

export async function projectCreate(name: string | undefined, opts: { org?: string }): Promise<void> {
const api = await ApiClient.load()
const orgId = await resolveOrg(api, opts.org)
const resolved = await resolveProjectName(name, process.cwd(), promptName)
const resolved = await resolveProjectName(name, process.cwd())
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} (${resolved})`)
Expand Down
38 changes: 26 additions & 12 deletions test/create-name.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
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 }) })
import { test, expect } from 'vitest'
import { slugifyName, resolveProjectName, generateProjectName } from '../src/commands/project.js'

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, real project dir → the dir basename (intuitive, no prompt)', async () => {
expect(await resolveProjectName(undefined, '/Users/me/my-project', () => 'gen-name-1')).toBe('my-project')
})

test('no arg, GENERIC dir → auto-generated name, never the useless basename', async () => {
// ~/projects, /tmp, etc. must NOT become a project literally named "projects"/"tmp".
expect(await resolveProjectName(undefined, '/Users/gary/projects', () => 'swift-otter-482')).toBe('swift-otter-482')
expect(await resolveProjectName(undefined, '/tmp', () => 'swift-otter-482')).toBe('swift-otter-482')
})

test('no arg NEVER blocks on a prompt (no stdin/TTY dependency)', async () => {
// Regression: create must resolve a name with zero interaction so `curl|sh && insta project create`
// and `insta project create` in ~/projects never hang waiting for input.
const name = await resolveProjectName(undefined, '/Users/gary/projects', () => 'auto-name-1')
expect(name).toBe('auto-name-1')
})
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')

test('generated names are valid slugs shaped adjective-noun-NNN', () => {
// deterministic rand → first adjective, first noun, floor(0*900)=0 → "swift-otter-100"
const n = generateProjectName(() => 0)
expect(n).toBe('swift-otter-100')
expect(slugifyName(n)).toBe(n) // already a valid project name
expect(n).toMatch(/^[a-z]+-[a-z]+-\d{3}$/)
})
Loading