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: 17 additions & 22 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,13 @@ import { installObserve } from '../observe/install.js'
import { installSkills } from '../ensure-skills.js'

// 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.
// cwd basename is one of these we DON'T invent a name — we guide the user to name it (or let their
// skill-equipped agent do it), rather than provisioning real resources under a junk name.
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).
function tryInstallObserve(): void {
Expand All @@ -43,26 +34,30 @@ export function slugifyName(raw: string): string {
return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
}

/** 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(),
generate: () => string = generateProjectName,
): Promise<string> {
/** Name resolution — NEVER prompts (a paste-and-run one-liner must not block on input). Returns
* the name to create, or `null` meaning "no sensible name; guide the user instead of inventing
* one". Explicit arg wins; else the cwd basename when it's a real project-dir name; else null
* (generic dir like ~/projects or /tmp, or the home dir itself). Agents pass a name via the skill,
* so null is only reached when a human runs bare `insta project create` somewhere generic. */
export function resolveProjectName(nameArg: string | undefined, cwd = process.cwd()): string | null {
if (nameArg) return slugifyName(nameArg)
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()
return null
}

export async function projectCreate(name: string | undefined, opts: { org?: string }): Promise<void> {
const resolved = resolveProjectName(name, process.cwd())
if (!resolved) {
// No name given and the cwd name is generic — don't provision resources under a junk name.
// Guide instead (no hang, no error): name it explicitly, or just ask the skill-equipped agent.
info('name your project: insta project create <name>')
info(' (or just ask your coding agent — it has the insta skill and will do this for you)')
return
}
const api = await ApiClient.load()
const orgId = await resolveOrg(api, opts.org)
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: 18 additions & 20 deletions test/create-name.test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,34 @@
import { test, expect } from 'vitest'
import { slugifyName, resolveProjectName, generateProjectName } from '../src/commands/project.js'
import { slugifyName, resolveProjectName } 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('explicit arg wins (slugified)', () => {
expect(resolveProjectName('LinkBox', '/x/whatever')).toBe('linkbox')
})

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, real project dir → the dir basename (intuitive, no prompt)', () => {
expect(resolveProjectName(undefined, '/Users/me/my-project')).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, GENERIC dir → null (caller guides; no junk-named project)', () => {
// ~/projects, /tmp, etc. must NOT become a project named "projects"/"tmp", and must NOT
// auto-invent one either — return null so the command guides the user to name it.
expect(resolveProjectName(undefined, '/Users/gary/projects')).toBeNull()
expect(resolveProjectName(undefined, '/tmp')).toBeNull()
expect(resolveProjectName(undefined, '/Users/me/src')).toBeNull()
})

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, the HOME dir itself → null (not the username)', () => {
const home = process.env.HOME || '/Users/me'
expect(resolveProjectName(undefined, home)).toBeNull()
})

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}$/)
test('resolution NEVER blocks on a prompt (pure, no stdin/TTY dependency)', () => {
// Regression: `curl|sh && insta project create` and bare create in ~/projects must never hang.
expect(resolveProjectName(undefined, '/Users/gary/projects')).toBeNull()
expect(resolveProjectName('demo', '/Users/gary/projects')).toBe('demo')
})
Loading