diff --git a/src/commands/setup.ts b/src/commands/setup.ts index df9a3fe..be7ed7f 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -16,7 +16,7 @@ import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, envFromEnvVar, isEnvName, m import { info, openUrl } from '../util.js' import { isRunnableFile, resolveSpawnable } from '../spawn.js' import { loginDevice } from './auth.js' -import { projectLink } from './project.js' +import { projectCreate, projectLink, slugifyName } from './project.js' import { envUse } from './env.js' import { installAgentConfigs } from './mcp.js' import { detectChannel, type Channel } from './upgrade.js' @@ -305,6 +305,19 @@ export function planSetupEnv( return { target, switch: persisted !== target } } +type ProjectStep = { kind: 'none' } | { kind: 'link'; id: string } | { kind: 'create'; name?: string } + +/** The project step. Only a contradictory flag pair throws: rejecting a nameless `--create` here + * would abort the whole setup, and `projectCreate` already guides that case. */ +export function planProject(opts: { project?: string; create?: string | boolean }): ProjectStep { + if (opts.create !== undefined && opts.project !== undefined) { + throw new Error('--create and --project are mutually exclusive — create a new project, or link an existing one') + } + if (opts.project) return { kind: 'link', id: opts.project } + if (opts.create === undefined) return { kind: 'none' } + return { kind: 'create', name: typeof opts.create === 'string' ? opts.create : undefined } +} + /** Whether setup should flow straight into login: an interactive human terminal with no session. * Pure. Non-TTY (agents, CI, pipes) and -y runs never prompt — a browser OAuth flow cannot work * there anyway; they get the printed `next:` hint instead, and prompt.md walks agents through @@ -370,7 +383,7 @@ export type LoginFlow = { } export async function setupAgent( - opts: { yes?: boolean; mcpToken?: boolean; env?: string; project?: string }, + opts: { yes?: boolean; mcpToken?: boolean; env?: string; project?: string; create?: string | boolean }, run: Runner = defaultRunner, mint?: TokenMinter, installConfigs: (agent?: string) => Promise = installAgentConfigs, @@ -384,10 +397,13 @@ export async function setupAgent( stdoutTty: !!process.stdout.isTTY, }, link: (id: string) => Promise = projectLink, + create: (name?: string) => Promise = (n) => projectCreate(n, {}), ): Promise { if (!opts.yes && !process.stdout.isTTY) { info('non-interactive shell — assuming -y') } + // Reject an impossible --project/--create request here, while the machine is still untouched. + const project = planProject(opts) // Pin the environment BEFORE anything is installed (see planSetupEnv). A required switch goes // through `env use` — the one path that persists the choice and drops the now-foreign session — // and announces itself, so the machine can never end up with its CLI on one deployment and its @@ -441,24 +457,29 @@ export async function setupAgent( } } } - // --project: link this directory inside the SAME process. The console's connect panel used to - // print `setup agent && insta project link ` as one paste — but no shell joiner survives - // every Windows shell, and in shells without bracketed paste the queued link line is eaten as - // the answer to the login prompt above (console PR #290). Carrying the id as a flag is the one - // form where "one line" is safe. Linking needs the session: without one the manual command is - // the hint, never a hang; a failed link (bad id, no access) is a REAL error — the link is the - // entire point of the flag — so it sets the exit code instead of pretending setup succeeded. - if (opts.project) { + // --project / --create: bind this directory to a project inside the SAME process. Never split + // this back into `setup agent && insta project ` as one paste: no shell joiner survives + // every Windows shell, and in shells without bracketed paste the queued second line is eaten + // as the answer to the login prompt above (console PR #290). Both need the session: without + // one the manual command is the hint, never a hang; a failure (bad id, no access, name taken) + // is a REAL error — binding a project is the entire point of the flag — so it sets the exit + // code instead of pretending setup succeeded. + if (project.kind !== 'none') { + const linking = project.kind === 'link' + const retry = linking + ? `insta project link ${project.id}` + : `insta project create${project.name ? ` ${slugifyName(project.name)}` : ''}` if (!loggedIn) { - info(` not logged in — project not linked; run \`insta login\`, then \`insta project link ${opts.project}\``) + info(` not logged in — project not ${linking ? 'linked' : 'created'}; run \`insta login\`, then \`${retry}\``) } else { try { - await link(opts.project) + if (project.kind === 'link') await link(project.id) + else await create(project.name) } catch (e) { // Stop here — like the skill-install failure above, finishing with the success summary // and a cheerful `next:` after an error is mixed messaging. Setup itself did succeed, // so say exactly that alongside the retry command. - info(` project link failed (${e instanceof Error ? e.message : String(e)}) — agent setup itself is done; run \`insta project link ${opts.project}\` to retry the link`) + info(` project ${linking ? 'link' : 'create'} failed (${e instanceof Error ? e.message : String(e)}) — agent setup itself is done; run \`${retry}\` to retry the ${linking ? 'link' : 'create'}`) process.exitCode = 1 return } diff --git a/src/index.ts b/src/index.ts index 64752ae..df186f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,6 +93,7 @@ setupCmd.command('agent').description('Install the insta CLI (if missing), the i .option('--env ', 'deployment to set this machine up for (default: prod — switches and persists, like `insta env use`)') .option('--mcp-token', 'register the MCP server with a minted insta_ API token instead of OAuth (headless machines / CI)') .option('--project ', 'also link this directory to an existing project after setup (flows through login first if needed)') + .option('--create [name]', 'also create a new project and link this directory after setup (default name: this directory; mutually exclusive with --project)') .action(guard((o) => setup.setupAgent(o))) // ---- MCP server integration ---- diff --git a/test/setup-agent.test.ts b/test/setup-agent.test.ts index bcfcb78..e60d402 100644 --- a/test/setup-agent.test.ts +++ b/test/setup-agent.test.ts @@ -2,7 +2,7 @@ import { test, expect, beforeEach, afterEach, vi } from 'vitest' import { mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { makePromptSource, planSetupEnv, setupAgent, shouldOfferLogin, registerMcp, SETUP_ARGS, MCP_SERVER_NAME, DEFAULT_MCP_URL } from '../src/commands/setup.js' +import { makePromptSource, planProject, planSetupEnv, setupAgent, shouldOfferLogin, registerMcp, SETUP_ARGS, MCP_SERVER_NAME, DEFAULT_MCP_URL } from '../src/commands/setup.js' import { ENVS } from '../src/env.js' // Setup resolves its API environment, MCP URL, and skill source from INSTA_ENV, INSTA_API_URL, @@ -275,6 +275,7 @@ const expectLinkSkipped = async ( opts: Parameters[0], flow: ReturnType, expectedEvents: string[], + hint = 'run `insta login`, then `insta project link proj_123`', ) => { const prev = process.exitCode let out = '' @@ -288,10 +289,11 @@ const expectLinkSkipped = async ( async () => ({ apiUrl: ENVS.prod.api }), noSwitch, // no session { ...flow, ask: async () => { events.push('ask'); return false } }, async (id) => { events.push(`link:${id}`) }, + async (name) => { events.push(`create:${name}`) }, ) } finally { spy.mockRestore() } - expect(events).toEqual(expectedEvents) // never a link - expect(out).toContain('run `insta login`, then `insta project link proj_123`') + expect(events).toEqual(expectedEvents) // never a link, never a create + expect(out).toContain(hint) expect(process.exitCode).toBe(prev) } @@ -333,6 +335,73 @@ test('--project link failure (bad id / no access) exits 1 and STOPS — no succe process.exitCode = prev }) +// ---- --create: the same one-liner, for a project that does not exist yet ---- + +test('planProject: only a contradictory flag pair is rejected — a nameless --create is projectCreate\'s call', () => { + expect(planProject({ create: true })).toEqual({ kind: 'create', name: undefined }) + expect(planProject({ create: 'my-app' })).toEqual({ kind: 'create', name: 'my-app' }) + expect(planProject({ project: 'proj_1' })).toEqual({ kind: 'link', id: 'proj_1' }) + expect(planProject({})).toEqual({ kind: 'none' }) + expect(() => planProject({ create: true, project: 'proj_1' })).toThrow(/mutually exclusive/) +}) + +test('setupAgent rejects --create + --project before touching anything', async () => { + const order: string[] = [] + await expect(callSetup( + { yes: true, create: true, project: 'proj_1' }, + async (_cmd, args) => { order.push(`run:${args[0]}`); return { ok: true, output: '' } }, + async () => { order.push('ensure') }, + { readStored: async () => ({ apiUrl: ENVS.staging.api }), switchEnv: async (n) => { order.push(`switch:${n}`) } }, + )).rejects.toThrow(/mutually exclusive/) + // A staging-persisted machine would be switched (dropping its session) and the CLI installed + // before the first `run` — the reject has to precede all three, not just the skill install. + expect(order).toEqual([]) +}) + +test('--create provisions after login on a fresh interactive machine (login → create, same process)', async () => { + const events: string[] = [] + await setupAgent( + { yes: false, create: 'my-app' }, + async () => ({ ok: true, output: '' }), + undefined, async () => [], async () => {}, + async () => ({ apiUrl: ENVS.prod.api }), noSwitch, // no session + linkFlow({ ask: true }, events), + async (id) => { events.push(`link:${id}`) }, + async (name) => { events.push(`create:${name}`) }, + ) + expect(events).toEqual(['ask', 'login', 'create:my-app']) +}) + +test('--create with no session skips the create with its own manual hint, exit 0', async () => { + await expectLinkSkipped( + { yes: true, create: 'My App' }, linkFlow({ ask: false }, []), [], + 'project not created; run `insta login`, then `insta project create my-app`', + ) +}) + +test('--create failure (name taken / no quota) exits 1 and STOPS — no success summary after the error', async () => { + const prev = process.exitCode + let out = '' + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((c) => { out += String(c); return true }) + try { + await setupAgent( + { yes: true, create: 'my-app' }, + async () => ({ ok: true, output: '' }), + undefined, async () => [], async () => {}, + async () => ({ apiUrl: ENVS.prod.api, user: { id: 'u', email: 't@e.com', name: 'T' } }), noSwitch, + linkFlow({ ask: true }, []), + async () => { throw new Error('should not link') }, + async () => { throw new Error('name already in use') }, + ) + } finally { spy.mockRestore() } + expect(out).toContain('project create failed (name already in use) — agent setup itself is done; run `insta project create my-app` to retry the create') + expect(process.exitCode).toBe(1) + // Guards the create arm specifically: splitting the shared block would leave the link test green. + expect(out).not.toContain('ready to use InstaCloud') + expect(out).not.toContain('next:') + process.exitCode = prev +}) + // ---- output copy: one combined MCP line + a next: that makes sense (user feedback 2026-08-20) ---- const captureSetupOutput = async (installConfigs: () => Promise): Promise => {