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
60 changes: 57 additions & 3 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { spawn } from 'node:child_process'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { dirname, join } from 'node:path'
import os from 'node:os'
import { createInterface } from 'node:readline'
import { ApiClient } from '../api.js'
import { readPersistedGlobal, resolveEnv, type GlobalConfig } from '../config.js'
import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, envFromEnvVar, isEnvName, mcpServerName, type EnvName } from '../env.js'
import { info } from '../util.js'
import { loginOauth } from './auth.js'
import { envUse } from './env.js'
import { installAgentConfigs } from './mcp.js'
import { detectChannel, type Channel } from './upgrade.js'
Expand Down Expand Up @@ -370,6 +372,36 @@ export function planSetupEnv(
return { target, switch: persisted !== target }
}

/** 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
* login as its own step (relaying the sign-in link to the human). */
export function shouldOfferLogin(yes: boolean, loggedIn: boolean, stdinTty: boolean, stdoutTty: boolean): boolean {
return !yes && !loggedIn && stdinTty && stdoutTty
}

// One Enter continues into the browser login; only an explicit n/no declines. Matches the
// curl-installer feel: the single command carries you as far as automation can go, and the one
// genuinely human step (authorizing in the browser) starts itself instead of being homework.
const defaultAsk = async (question: string): Promise<boolean> => {
const rl = createInterface({ input: process.stdin, output: process.stdout })
// EOF (Ctrl-D) closes the interface without ever answering the question — resolve that as a
// decline instead of hanging forever after the checkmarks.
const answer: string = await new Promise((resolve) => {
rl.on('close', () => resolve('n'))
rl.question(question, resolve)
})
rl.close()
return !/^n/i.test(answer.trim())
}

export type LoginFlow = {
ask: (question: string) => Promise<boolean>
login: () => Promise<void>
stdinTty: boolean
stdoutTty: boolean
}

export async function setupAgent(
opts: { yes?: boolean; mcpToken?: boolean; env?: string },
run: Runner = defaultRunner,
Expand All @@ -378,6 +410,12 @@ export async function setupAgent(
ensure: (run: Runner) => Promise<void> = (r) => ensureCliInstalled(r),
readStored: () => Promise<GlobalConfig> = readPersistedGlobal,
switchEnv: (name: string) => Promise<void> = (n) => envUse(n),
loginFlow: LoginFlow = {
ask: defaultAsk,
login: () => loginOauth('github', {}),
stdinTty: !!process.stdin.isTTY,
stdoutTty: !!process.stdout.isTTY,
},
): Promise<void> {
if (!opts.yes && !process.stdout.isTTY) {
info('non-interactive shell — assuming -y')
Expand Down Expand Up @@ -423,7 +461,23 @@ export async function setupAgent(
// login/create commands in it are env-aware at runtime), and staging serves no equivalent.
const stored = await readStored()
const loggedIn = !!(stored.accessToken || stored.user)
info(loggedIn
? 'next: `insta project create <name>` in your app repo — or tell your agent: "Fetch https://instacloud.com/prompt.md and follow it"'
: 'next: `insta login --oauth github` (headless: `insta login --device`), then `insta project create <name>` — or tell your agent: "Fetch https://instacloud.com/prompt.md and follow it"')
const nextCreate = 'next: `insta project create <name>` in your app repo — or tell your agent: "Fetch https://instacloud.com/prompt.md and follow it"'
if (loggedIn) return info(nextCreate)
// Default into login on an interactive terminal — see shouldOfferLogin. Best-effort: a declined
// prompt or a failed browser flow leaves a completed setup plus the manual hint, never an error.
if (shouldOfferLogin(!!opts.yes, loggedIn, loginFlow.stdinTty, loginFlow.stdoutTty)) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (await loginFlow.ask('log in now with GitHub? (Y/n) ')) {
try {
await loginFlow.login()
// --mcp-token needs a session to mint: the registration above ran logged-out and skipped
// itself with the login hint — now that the session exists, do the token registration.
if (opts.mcpToken) await registerMcp(run, mint, true)
return info(nextCreate)
} catch (e) {
info(` login did not complete (${e instanceof Error ? e.message : String(e)}) — no problem, setup itself is done.`)
info(' on a remote/SSH machine the browser flow cannot call back here — use `insta login --device` instead.')
}
}
}
info('next: `insta login --oauth github` (headless: `insta login --device`), then `insta project create <name>` — or tell your agent: "Fetch https://instacloud.com/prompt.md and follow it"')
}
72 changes: 71 additions & 1 deletion test/setup-agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, beforeEach, afterEach } from 'vitest'
import { planSetupEnv, setupAgent, registerMcp, SETUP_ARGS, MCP_SERVER_NAME, DEFAULT_MCP_URL } from '../src/commands/setup.js'
import { planSetupEnv, setupAgent, shouldOfferLogin, registerMcp, SETUP_ARGS, MCP_SERVER_NAME, DEFAULT_MCP_URL } from '../src/commands/setup.js'
import { ENVS } from '../src/env.js'

// setupAgent's default planner inputs read $INSTA_ENV / $INSTA_API_URL — a developer or CI shell
Expand Down Expand Up @@ -96,6 +96,76 @@ test('setupAgent surfaces the --env/$INSTA_API_URL conflict before touching anyt
expect(runs).toHaveLength(0) // nothing installed, nothing switched
})

test('shouldOfferLogin: only an interactive human terminal with no session', () => {
expect(shouldOfferLogin(false, false, true, true)).toBe(true)
expect(shouldOfferLogin(true, false, true, true)).toBe(false) // -y = non-interactive by request
expect(shouldOfferLogin(false, true, true, true)).toBe(false) // already logged in
expect(shouldOfferLogin(false, false, false, true)).toBe(false) // piped stdin (agents, CI)
expect(shouldOfferLogin(false, false, true, false)).toBe(false) // redirected stdout
})

test('setup agent flows into GitHub login by default on a TTY when not logged in', async () => {
const events: string[] = []
await setupAgent(
{ yes: false }, // interactive
async (_cmd, args) => { events.push(`run:${args[0]}`); return { ok: true, output: '' } },
undefined, async () => [], async () => {},
async () => ({ apiUrl: ENVS.prod.api }), // no session → not logged in
noSwitch,
{ ask: async (q) => { events.push(`ask:${q.trim()}`); return true }, login: async () => { events.push('login') }, stdinTty: true, stdoutTty: true },
)
expect(events).toContain('ask:log in now with GitHub? (Y/n)')
expect(events[events.length - 1]).toBe('login')
})

test('declined prompt and failed login both end with the manual hint, never an error', async () => {
const prev = process.exitCode
// Declined: login never runs.
let loggedIn = 0
await setupAgent({ yes: false }, async () => ({ ok: true, output: '' }), undefined, async () => [], async () => {},
storedProd, noSwitch,
{ ask: async () => false, login: async () => { loggedIn++ }, stdinTty: true, stdoutTty: true })
expect(loggedIn).toBe(0)
// Failed: swallowed — setup already succeeded.
await setupAgent({ yes: false }, async () => ({ ok: true, output: '' }), undefined, async () => [], async () => {},
storedProd, noSwitch,
{ ask: async () => true, login: async () => { throw new Error('browser exploded') }, stdinTty: true, stdoutTty: true })
expect(process.exitCode).toBe(prev)
})

test('non-TTY (agents/CI) never prompts for login, even without -y', async () => {
// yes:false so this exercises the TTY gate itself, not the -y gate (covered above).
let asked = 0
await setupAgent({ yes: false }, async () => ({ ok: true, output: '' }), undefined, async () => [], async () => {},
storedProd, noSwitch,
{ ask: async () => { asked++; return true }, login: async () => { asked++ }, stdinTty: false, stdoutTty: false })
expect(asked).toBe(0)
})

test('--mcp-token + interactive login registers MCP with the token exactly once, AFTER the session exists', async () => {
const events: string[] = []
let sessionExists = false // production defaultMinter returns null while logged out
await setupAgent(
{ yes: false, mcpToken: true },
async (cmd, args) => {
if (args[0] === 'mcp' && args[1] === 'add') events.push('mcp-add')
else events.push(`${cmd}:${args[0]}`)
return { ok: !(args[0] === 'mcp' && args[1] === 'get'), output: '' }
},
async () => { events.push('mint'); return sessionExists ? 'insta_tok' : null },
async () => [], async () => {},
async () => ({ apiUrl: ENVS.prod.api }), noSwitch,
{ ask: async () => true, login: async () => { sessionExists = true; events.push('login') }, stdinTty: true, stdoutTty: true },
)
const loginAt = events.indexOf('login')
expect(loginAt).toBeGreaterThan(-1)
// The production contract: the logged-out pre-login registration mints null and adds NOTHING;
// the one and only `mcp add` happens after login.
expect(events.slice(0, loginAt)).not.toContain('mcp-add')
expect(events.filter((e) => e === 'mcp-add')).toHaveLength(1)
expect(events.indexOf('mcp-add')).toBeGreaterThan(loginAt)
})

test('setupAgent surfaces a disagreeing $INSTA_ENV the same way (INSTA_ENV=staging + --env prod)', async () => {
process.env.INSTA_ENV = 'staging'
const runs: string[][] = []
Expand Down
Loading