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
47 changes: 34 additions & 13 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string[]> = installAgentConfigs,
Expand All @@ -384,10 +397,13 @@ export async function setupAgent(
stdoutTty: !!process.stdout.isTTY,
},
link: (id: string) => Promise<void> = projectLink,
create: (name?: string) => Promise<void> = (n) => projectCreate(n, {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On native Windows, bare --create provisions a name derived from the entire drive path rather than this directory because projectCreate receives undefined. Make the default-name resolution platform-safe before using this callback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/setup.ts, line 400:

<comment>On native Windows, bare `--create` provisions a name derived from the entire drive path rather than this directory because `projectCreate` receives `undefined`. Make the default-name resolution platform-safe before using this callback.</comment>

<file context>
@@ -384,10 +397,13 @@ export async function setupAgent(
     stdoutTty: !!process.stdout.isTTY,
   },
   link: (id: string) => Promise<void> = projectLink,
+  create: (name?: string) => Promise<void> = (n) => projectCreate(n, {}),
 ): Promise<void> {
   if (!opts.yes && !process.stdout.isTTY) {
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining — pre-existing, and unchanged in kind by this PR.

resolveProjectName splits on / only on origin/main too: git show origin/main:src/commands/project.ts line 51 is cwd.split('/').filter(Boolean).pop(), and every case in test/create-name.test.ts uses a POSIX path. So insta project create with no argument already produces c-users-me-my-app on native Windows; --create reaches the same resolver with the same result.

What this PR changes is the number of entry points, not the outcome or its severity. Fixing it means changing resolveProjectName for insta project create as well — including how GENERIC_DIRS and the home-dir comparison behave on Windows paths — which is a wider change than this PR should carry silently.

): Promise<void> {
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
Expand Down Expand Up @@ -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 <id>` 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 <cmd>` 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If projectCreate fails after its POST, this catch tells the user to create the project again even though the server-side project already exists. Preserve or surface the created project ID and direct recovery to linking it, rather than retrying a non-idempotent create.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/setup.ts, line 475:

<comment>If `projectCreate` fails after its POST, this catch tells the user to create the project again even though the server-side project already exists. Preserve or surface the created project ID and direct recovery to linking it, rather than retrying a non-idempotent create.</comment>

<file context>
@@ -441,24 +457,27 @@ export async function setupAgent(
       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
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining — the window is one non-idempotent line, and it is identical on insta project create.

After the POST, projectCreate calls writeProject, then tryInstallObserve (wrapped in try/catch, project.ts:19-27) and installSkills (whose entire body is inside a try/catch, ensure-skills.ts:80-102) — neither can throw. Only writeProject can, so the reachable case is a project created server-side with ./.insta/project.json unwritten.

That case predates this PR: insta project create my-app fails the same way, and rerunning it is the same second project. Distinguishing pre- from post-POST failure means restructuring projectCreate, which belongs with that command rather than with a flag that calls it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When projectCreate reaches the API but local link persistence fails, this catch treats the project as uncreated and advises rerunning create. Report the existing project and recovery path instead, because the retry can fail with name-taken while the first project remains unlinked.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/setup.ts, line 477:

<comment>When `projectCreate` reaches the API but local link persistence fails, this catch treats the project as uncreated and advises rerunning create. Report the existing project and recovery path instead, because the retry can fail with name-taken while the first project remains unlinked.</comment>

<file context>
@@ -441,24 +457,29 @@ export async function setupAgent(
       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
</file context>

} 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
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ setupCmd.command('agent').description('Install the insta CLI (if missing), the i
.option('--env <prod|staging>', '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 <id>', '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 ----
Expand Down
75 changes: 72 additions & 3 deletions test/setup-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -275,6 +275,7 @@ const expectLinkSkipped = async (
opts: Parameters<typeof setupAgent>[0],
flow: ReturnType<typeof linkFlow>,
expectedEvents: string[],
hint = 'run `insta login`, then `insta project link proj_123`',
) => {
const prev = process.exitCode
let out = ''
Expand All @@ -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)
}

Expand Down Expand Up @@ -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')
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
expect(process.exitCode).toBe(1)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// 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<string[]>): Promise<string> => {
Expand Down
Loading