From ec4f56405599e3f04a1743fcf22809d3ed95e1ff Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Thu, 20 Aug 2026 16:13:23 -0700 Subject: [PATCH 1/4] feat(setup): default into GitHub login when setup finishes logged-out on a TTY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One command now carries a human as far as automation can go (curl-installer feel): after the checkmarks, an interactive terminal with no session flows straight into `insta login --oauth github` — Enter continues, n skips. Non-TTY (agents, CI, pipes) and -y runs never prompt: a browser OAuth flow cannot work there, so they keep the printed next: hint and prompt.md walks agents through login as its own step. Best-effort: a declined prompt or a failed browser flow leaves a completed setup plus the manual hint, never an error. shouldOfferLogin is pure + tested; the flow is injected (LoginFlow) so tests never touch a real TTY or browser. Co-Authored-By: Claude Fable 5 --- src/commands/setup.ts | 51 +++++++++++++++++++++++++++++++++++++--- test/setup-agent.test.ts | 47 +++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index c066598..33bd092 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -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' @@ -370,6 +372,31 @@ 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 => { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + const answer: string = await new Promise((resolve) => rl.question(question, resolve)) + rl.close() + return !/^n/i.test(answer.trim()) +} + +export type LoginFlow = { + ask: (question: string) => Promise + login: () => Promise + stdinTty: boolean + stdoutTty: boolean +} + export async function setupAgent( opts: { yes?: boolean; mcpToken?: boolean; env?: string }, run: Runner = defaultRunner, @@ -378,6 +405,12 @@ export async function setupAgent( ensure: (run: Runner) => Promise = (r) => ensureCliInstalled(r), readStored: () => Promise = readPersistedGlobal, switchEnv: (name: string) => Promise = (n) => envUse(n), + loginFlow: LoginFlow = { + ask: defaultAsk, + login: () => loginOauth('github', {}), + stdinTty: !!process.stdin.isTTY, + stdoutTty: !!process.stdout.isTTY, + }, ): Promise { if (!opts.yes && !process.stdout.isTTY) { info('non-interactive shell — assuming -y') @@ -423,7 +456,19 @@ 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 ` 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 ` — or tell your agent: "Fetch https://instacloud.com/prompt.md and follow it"') + const nextCreate = 'next: `insta project create ` 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)) { + if (await loginFlow.ask('log in now with GitHub? (Y/n) ')) { + try { + await loginFlow.login() + return info(nextCreate) + } catch (e) { + info(` login did not complete (${e instanceof Error ? e.message : String(e)}) — no problem, setup itself is done.`) + } + } + } + info('next: `insta login --oauth github` (headless: `insta login --device`), then `insta project create ` — or tell your agent: "Fetch https://instacloud.com/prompt.md and follow it"') } diff --git a/test/setup-agent.test.ts b/test/setup-agent.test.ts index dabff85..2d78535 100644 --- a/test/setup-agent.test.ts +++ b/test/setup-agent.test.ts @@ -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 @@ -96,6 +96,51 @@ 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', async () => { + let asked = 0 + await setupAgent({ yes: true }, 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('setupAgent surfaces a disagreeing $INSTA_ENV the same way (INSTA_ENV=staging + --env prod)', async () => { process.env.INSTA_ENV = 'staging' const runs: string[][] = [] From 97cf3e619e3e4fa3368e1df2351f1ef01ee1f0d1 Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Thu, 20 Aug 2026 16:26:39 -0700 Subject: [PATCH 2/4] fix(setup): EOF-safe login prompt; --mcp-token registers after interactive login; TTY-gate test - defaultAsk resolves EOF (Ctrl-D closes readline) as a decline instead of hanging after the checkmarks (john-bot suggestion) - --mcp-token + interactive login: the pre-login registration skipped itself logged-out; re-run registerMcp with the token once the session exists (cubic P2) - non-TTY test now uses yes:false so it exercises the TTY gate, not the -y gate (cubic) Co-Authored-By: Claude Fable 5 --- src/commands/setup.ts | 10 +++++++++- test/setup-agent.test.ts | 22 ++++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 33bd092..874af86 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -385,7 +385,12 @@ export function shouldOfferLogin(yes: boolean, loggedIn: boolean, stdinTty: bool // genuinely human step (authorizing in the browser) starts itself instead of being homework. const defaultAsk = async (question: string): Promise => { const rl = createInterface({ input: process.stdin, output: process.stdout }) - const answer: string = await new Promise((resolve) => rl.question(question, resolve)) + // 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()) } @@ -464,6 +469,9 @@ export async function setupAgent( 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.`) diff --git a/test/setup-agent.test.ts b/test/setup-agent.test.ts index 2d78535..51db436 100644 --- a/test/setup-agent.test.ts +++ b/test/setup-agent.test.ts @@ -133,14 +133,32 @@ test('declined prompt and failed login both end with the manual hint, never an e expect(process.exitCode).toBe(prev) }) -test('non-TTY (agents/CI) never prompts for login', async () => { +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: true }, async () => ({ ok: true, output: '' }), undefined, async () => [], async () => {}, + 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 AFTER the session exists', async () => { + const events: string[] = [] + await setupAgent( + { yes: false, mcpToken: true }, + async (cmd, args) => { events.push(`${cmd}:${args[0]}`); return { ok: !(args[0] === 'mcp' && args[1] === 'get'), output: '' } }, + async () => { events.push('mint'); return 'insta_tok' }, + async () => [], async () => {}, + async () => ({ apiUrl: ENVS.prod.api }), noSwitch, + { ask: async () => true, login: async () => { events.push('login') }, stdinTty: true, stdoutTty: true }, + ) + const loginAt = events.indexOf('login') + expect(loginAt).toBeGreaterThan(-1) + // A token mint + `mcp add` happen after login — the pre-login registration ran logged-out. + expect(events.slice(loginAt)).toContain('mint') + expect(events.slice(loginAt).some((e) => e === 'claude:mcp')).toBe(true) +}) + test('setupAgent surfaces a disagreeing $INSTA_ENV the same way (INSTA_ENV=staging + --env prod)', async () => { process.env.INSTA_ENV = 'staging' const runs: string[][] = [] From 785e4540c0e408fa1cfaa117a14cee32cb3074f7 Mon Sep 17 00:00:00 2001 From: Tony Chang Date: Thu, 20 Aug 2026 16:30:09 -0700 Subject: [PATCH 3/4] docs(setup): point the failed-login catch at insta login --device (SSH escape hatch) Co-Authored-By: Claude Fable 5 --- src/commands/setup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 874af86..bce7f60 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -475,6 +475,7 @@ export async function setupAgent( 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.') } } } From c4ee1f08af98a67429f21d3d201b5bae72955e81 Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Thu, 20 Aug 2026 16:34:21 -0700 Subject: [PATCH 4/4] =?UTF-8?q?test(setup):=20pin=20the=20--mcp-token=20co?= =?UTF-8?q?ntract=20=E2=80=94=20register=20exactly=20once,=20after=20the?= =?UTF-8?q?=20session=20exists=20(r2d2=20r3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mint mock now returns null until login (matching production defaultMinter while logged out), and the test asserts no mcp add before login and exactly one after. Co-Authored-By: Claude Fable 5 --- test/setup-agent.test.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/test/setup-agent.test.ts b/test/setup-agent.test.ts index 51db436..3c206ca 100644 --- a/test/setup-agent.test.ts +++ b/test/setup-agent.test.ts @@ -142,21 +142,28 @@ test('non-TTY (agents/CI) never prompts for login, even without -y', async () => expect(asked).toBe(0) }) -test('--mcp-token + interactive login registers MCP with the token AFTER the session exists', async () => { +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) => { events.push(`${cmd}:${args[0]}`); return { ok: !(args[0] === 'mcp' && args[1] === 'get'), output: '' } }, - async () => { events.push('mint'); return 'insta_tok' }, + 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 () => { events.push('login') }, stdinTty: true, stdoutTty: true }, + { ask: async () => true, login: async () => { sessionExists = true; events.push('login') }, stdinTty: true, stdoutTty: true }, ) const loginAt = events.indexOf('login') expect(loginAt).toBeGreaterThan(-1) - // A token mint + `mcp add` happen after login — the pre-login registration ran logged-out. - expect(events.slice(loginAt)).toContain('mint') - expect(events.slice(loginAt).some((e) => e === 'claude:mcp')).toBe(true) + // 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 () => {