From e5573096646ec61310b60a1283c13184205284f5 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 28 Aug 2026 09:57:39 -0700 Subject: [PATCH 1/2] feat: bare `insta login` signs in from the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare `insta login` used to die with "--email is required" — yet the console quick-start prints it as the login step ("sign in when the browser opens"), and OAuth-signup accounts have no password to type anyway. It now rides the existing device grant and opens the console approval page locally: sign in there with whatever the account uses (email, GitHub, Google), check the code, approve, done. --device keeps the print-only link for machines without a usable browser; --email, --oauth, and --api-key are unchanged. `setup agent` flows into the same browser login instead of hardcoding GitHub OAuth, which dead-ended Google/email accounts at its prompt. --- README.md | 12 +++++++++--- install.sh | 2 +- src/commands/auth.ts | 40 ++++++++++++++++++++++++++++----------- src/commands/setup.ts | 10 +++++----- src/index.ts | 8 ++++---- test/device-login.test.ts | 32 ++++++++++++++++++++++++++++++- test/setup-agent.test.ts | 4 ++-- 7 files changed, 81 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 0caf990..c485aa9 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ off with `insta autoupdate off`. ## Quickstart ```bash -insta login --oauth github +insta login insta project create my-app insta services add postgres db insta services add compute api @@ -79,13 +79,19 @@ service; it needs a `Dockerfile`, but no local Docker. ## Authentication ```bash +insta login # sign in from the browser (any account type) insta login --email you@example.com # password from $INSTA_PASSWORD or a prompt insta login --oauth github # or google, through the browser -insta login --env staging --oauth github # log in to a specific deployment +insta login --env staging # log in to a specific deployment ``` Tokens are stored in `~/.insta/config.json` and refresh automatically. +Bare `insta login` opens the console's device-approval page in your browser: sign in there +with whatever your account uses (email, GitHub, Google), check the code matches, and approve. +On a machine that can't open a browser, `--device` prints the same link to open from any +other device. + `--oauth` starts a loopback listener on `127.0.0.1`, opens the browser at the control plane's `/auth/cli/authorize`, and receives the token back on that listener once the provider has authorized you. Nothing is pasted by hand. @@ -189,7 +195,7 @@ build never reaches a production installer. | Command | What it covers | |---|---| -| `insta login` · `logout` · `status` | Email/password or `--oauth github\|google`; `status` shows the environment, login and linked project/branch | +| `insta login` · `logout` · `status` | Browser sign-in (default), `--email` + password, or `--oauth github\|google`; `status` shows the environment, login and linked project/branch | | `insta env` | `show` · `use ` | | `insta setup` | `agent` — install the CLI (if missing), the skill, and MCP for every coding agent; targets prod, `--env staging` for staging | | `insta mcp` | `install` — register the remote MCP server only | diff --git a/install.sh b/install.sh index 364bdf5..fd631be 100644 --- a/install.sh +++ b/install.sh @@ -281,7 +281,7 @@ if [ "$ENV_NAME" = "staging" ] && [ "${ENV_APPLIED:-0}" = "1" ]; then echo fi echo "Next steps:" -echo " insta login --oauth github # connect to the cloud (or run insta-oss locally to skip)" +echo " insta login # connect to the cloud (or run insta-oss locally to skip)" echo " insta project create demo # postgres + storage + compute, provisioned in one shot" # A directory deploy needs a Dockerfile in the directory (there is no local no-Dockerfile lane), so # the banner says so rather than recommending a command that errors for a Dockerfile-less app. diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 85066b5..cfbf5f9 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -24,10 +24,16 @@ export async function login(opts: { email?: string; password?: string; apiUrl?: } if (opts.device) return loginDevice(opts) if (opts.oauth) return loginOauth(opts.oauth, opts) + if (!opts.email) { + // Bare `insta login` = sign in from the browser. The device grant is the one flow that covers + // every account type (email, GitHub, Google): the console approval page owns the signin + // round-trip, so the CLI just opens it here instead of only printing the link. + if (opts.password !== undefined || process.env.INSTA_PASSWORD !== undefined) die('a password (--password / $INSTA_PASSWORD) is only used with --email ') + return loginDevice(opts, openUrl) + } const api = await ApiClient.load() const target = targetApiUrl(opts) if (target) api.setApiUrl(target) - if (!opts.email) die('--email is required (or use --oauth ; on a headless machine, --device)') const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword()) const res = await api.request('POST', '/auth/login', { email: opts.email, password }, { auth: false }) api.setSession(res, res.user) @@ -50,15 +56,16 @@ export async function loginOauth(provider: string, opts: { apiUrl?: string; env? info(`logged in as ${me.user.email ?? me.user.id} @ ${api.apiUrl}`) } -// RFC 8628 device authorization — login from a machine with no usable browser (VM, SSH box, CI -// container). The loopback --oauth flow can never work there: its callback targets 127.0.0.1 on -// THIS machine. Here the roles invert — we mint a code, print a link the human opens on ANY -// device, and poll the platform until they approve in the console. -export async function loginDevice(opts: { apiUrl?: string; env?: string }): Promise { +// RFC 8628 device authorization — the default login (bare `insta login` passes `open` to also +// launch the browser here), and as --device the flow for a machine with no usable browser (VM, +// SSH box, CI container), where the loopback --oauth flow can never work: its callback targets +// 127.0.0.1 on THIS machine. We mint a code, hand the human a link to the console approval page +// (which owns the signin round-trip), and poll the platform until they approve. +export async function loginDevice(opts: { apiUrl?: string; env?: string }, open?: (url: string) => boolean): Promise { const api = await ApiClient.load() const target = targetApiUrl(opts) if (target) api.setApiUrl(target) - const token = await deviceGrant((path, body) => api.request('POST', path, body, { auth: false })) + const token = await deviceGrant((path, body) => api.request('POST', path, body, { auth: false }), sleepSeconds, open) api.setSession({ accessToken: token, refreshToken: token }) const me = await api.request<{ user: { id: string; email: string | null; name: string | null } }>('GET', '/me') api.setSession({ accessToken: token, refreshToken: token }, me.user) @@ -115,7 +122,9 @@ const sleepSeconds = (s: number) => new Promise((r) => setTimeout(r, s * 1 // Drives the device grant against the platform's Better Auth mount (/api/auth/device*) and // returns the approved session token. Injectable poster + wait keep this testable without a // network or real timers. Poll errors arrive as ApiError with the OAuth error code as message. -export async function deviceGrant(post: DevicePoster, wait: (s: number) => Promise = sleepSeconds): Promise { +// `open` (the default browser-login path) launches the verification link locally on top of +// printing it; without it (--device) the link is print-only, for a browser on another machine. +export async function deviceGrant(post: DevicePoster, wait: (s: number) => Promise = sleepSeconds, open?: (url: string) => boolean): Promise { const start = (await post('/api/auth/device/code', { client_id: 'insta-cli' })) as DeviceStart // A missing/garbage expires_in must fail loudly here — carried into the deadline arithmetic it // becomes NaN, every `Date.now() < deadline` is false, and login dies as a bogus instant expiry. @@ -126,8 +135,17 @@ export async function deviceGrant(post: DevicePoster, wait: (s: number) => Promi throw new Error('malformed device authorization response (missing expires_in) — is the platform up to date?') } const lifetime = Math.min(expiresIn, 3600) // no device code sensibly outlives an hour - info('to log in, open this link in a browser on any device:') - info(` ${start.verification_uri_complete ?? start.verification_uri}`) + const url = start.verification_uri_complete ?? start.verification_uri + if (open) { + info('opening your browser to sign in…') + // Always print the link too: a launcher that fails to start reports it on spawn's ASYNC + // error event, so open's return value cannot see it (same reasoning as browserOauth). + info(`if nothing opens, use this link in a browser on any device:\n ${url}`) + open(url) + } else { + info('to log in, open this link in a browser on any device:') + info(` ${url}`) + } info(`and check it shows this code: ${start.user_code}`) info(`waiting for approval… (expires in ${Math.round(lifetime / 60)}m, ctrl-c to abort)`) // Absent OR non-finite interval = the RFC 8628 §3.2 default 5s: NaN would fire the timer @@ -158,7 +176,7 @@ export async function deviceGrant(post: DevicePoster, wait: (s: number) => Promi if (!grant?.access_token) throw new Error('malformed token response (missing access_token)') return grant.access_token } - throw new Error('device login expired before it was approved — run `insta login --device` again') + throw new Error(`device login expired before it was approved — run \`insta login${open ? '' : ' --device'}\` again`) } // Start a loopback server, open the browser at the platform bridge, and await the token. diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 23550aa..df9a3fe 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -13,9 +13,9 @@ 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 { info, openUrl } from '../util.js' import { isRunnableFile, resolveSpawnable } from '../spawn.js' -import { loginOauth } from './auth.js' +import { loginDevice } from './auth.js' import { projectLink } from './project.js' import { envUse } from './env.js' import { installAgentConfigs } from './mcp.js' @@ -379,7 +379,7 @@ export async function setupAgent( switchEnv: (name: string) => Promise = (n) => envUse(n), loginFlow: LoginFlow = { ask: defaultAsk, - login: () => loginOauth('github', {}), + login: () => loginDevice({}, openUrl), stdinTty: canPromptViaTty(), stdoutTty: !!process.stdout.isTTY, }, @@ -430,14 +430,14 @@ export async function setupAgent( const stored = await readStored() let loggedIn = !!(stored.accessToken || stored.user) if (shouldOfferLogin(!!opts.yes, loggedIn, loginFlow.stdinTty, loginFlow.stdoutTty)) { - if (await loginFlow.ask('log in now with GitHub? (Y/n) ')) { + if (await loginFlow.ask('log in now in the browser? (Y/n) ')) { try { await loginFlow.login() loggedIn = true if (opts.mcpToken) claude = await registerMcp(run, mint, true, false) } 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(' run `insta login` to try again — the sign-in link it prints works from a browser on any device.') } } } diff --git a/src/index.ts b/src/index.ts index daeca31..7ecd9e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,11 +61,11 @@ function resolveVersion(): string { program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(resolveVersion()) // ---- auth ---- -program.command('login').description('Log in with email + password, --oauth (browser), --device (headless), or --api-key (headless, durable token)') - .option('--email ', 'account email') - .option('--password ', 'account password (else $INSTA_PASSWORD or prompt)') +program.command('login').description('Log in — bare: sign in from your browser (any account type); or --email + password, --oauth , --device (headless), --api-key (headless, durable token)') + .option('--email ', 'account email (email + password login)') + .option('--password ', 'account password (else $INSTA_PASSWORD or prompt; needs --email)') .option('--oauth ', 'browser OAuth login: github | google') - .option('--device', 'device-code login: approve from a browser on any other machine (VMs, SSH, CI)') + .option('--device', 'device-code login: like bare login but never opens a browser here — approve from any other machine (VMs, SSH, CI)') .option('--api-key ', 'non-interactive login with a durable insta_ API token (headless agents / CI)') .option('--api-url ', 'control-plane API base URL') .option('--env ', `deployment environment: ${ENV_NAMES.join(' | ')}`) diff --git a/test/device-login.test.ts b/test/device-login.test.ts index d7ebcef..9ca3b97 100644 --- a/test/device-login.test.ts +++ b/test/device-login.test.ts @@ -59,7 +59,7 @@ describe('deviceGrant', () => { it('stops with a retry hint when the server reports the code expired', async () => { const { post, wait } = fakeFlow(['expired_token']) - await expect(deviceGrant(post, wait)).rejects.toThrow(/expired before it was approved/) + await expect(deviceGrant(post, wait)).rejects.toThrow(/expired before it was approved — run `insta login --device` again/) }) it('rethrows unexpected errors instead of polling forever', async () => { @@ -138,4 +138,34 @@ describe('deviceGrant', () => { expect(lines.join('')).toContain('https://console.test/device') expect(lines.join('')).not.toContain('undefined') }) + + // Bare `insta login` rides this same grant with an injected opener: the browser is launched at + // the verification link, and the link is STILL printed — a launcher that fails to start reports + // it on spawn's async error event, so the opener's return value can't see the failure. + it('launches the opener at the verification link and still prints it', async () => { + const opened: string[] = [] + const lines: string[] = [] + const write = process.stdout.write.bind(process.stdout) + process.stdout.write = ((s: string) => { lines.push(String(s)); return true }) as typeof process.stdout.write + try { + const { post, wait } = fakeFlow(['token:sess-o']) + await expect(deviceGrant(post, wait, (url) => { opened.push(url); return true })).resolves.toBe('sess-o') + } finally { + process.stdout.write = write + } + expect(opened).toEqual(['https://console.test/device?user_code=ABCD1234']) + const out = lines.join('') + expect(out).toContain('if nothing opens') + expect(out).toContain('https://console.test/device?user_code=ABCD1234') + expect(out).toContain('check it shows this code: ABCD1234') // the phishing guard stays in both flows + }) + + // The retry hint must name the command that actually ran: bare login for the opener flow, + // --device for the print-only flow (pinned in the expired_token test above). Real-time wait: + // the deadline reads the real clock, so an instant fake would spin through the poll script. + it('drops --device from the expiry hint when the opener flow ran', async () => { + const { post } = fakeFlow(['authorization_pending', 'authorization_pending'], { ...START, expires_in: 0.001 }) + const wait = () => new Promise((r) => setTimeout(r, 2)) + await expect(deviceGrant(post, wait, () => true)).rejects.toThrow(/run `insta login` again/) + }) }) diff --git a/test/setup-agent.test.ts b/test/setup-agent.test.ts index 5298691..bcfcb78 100644 --- a/test/setup-agent.test.ts +++ b/test/setup-agent.test.ts @@ -140,7 +140,7 @@ test('shouldOfferLogin: only an interactive human terminal with no session', () 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 () => { +test('setup agent flows into browser login by default on a TTY when not logged in', async () => { const events: string[] = [] await setupAgent( { yes: false }, // interactive @@ -150,7 +150,7 @@ test('setup agent flows into GitHub login by default on a TTY when 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).toContain('ask:log in now in the browser? (Y/n)') expect(events[events.length - 1]).toBe('login') }) From 6b899f04c9a8a845815c9793928a0fb8cc74d342 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 28 Aug 2026 13:46:42 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20review=20feedback=20=E2=80=94=20empt?= =?UTF-8?q?y=20--email=20guard,=20neutral=20login=20hints,=20dispatch=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicitly empty --email now errors instead of falling into the bare browser flow (cubic). The two runtime hints that still said `insta login --oauth github` (api.ts 401 hint, env.ts post-switch hint) now recommend bare `insta login`, matching the account-type-neutral default (review suggestion). New login-dispatch tests pin the public default — bare login = device grant + local opener, --device print-only — and the password/empty-email guard branches, via an injectable device runner. --- src/api.ts | 2 +- src/commands/auth.ts | 9 +++-- src/commands/env.ts | 2 +- test/login-dispatch.test.ts | 70 +++++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 test/login-dispatch.test.ts diff --git a/src/api.ts b/src/api.ts index 506cc7d..7594d3a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -125,7 +125,7 @@ export async function requireProject(): Promise { }) } catch (e) { if (e instanceof ApiError && e.status === 401) { - die('not logged in — run `insta login --oauth github` (cloud) or point INSTA_API_URL at your insta-oss daemon') + die('not logged in — run `insta login` (cloud) or point INSTA_API_URL at your insta-oss daemon') } die(e instanceof Error ? e.message : String(e)) } diff --git a/src/commands/auth.ts b/src/commands/auth.ts index cfbf5f9..4f5cf7f 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -15,21 +15,24 @@ function targetApiUrl(opts: { apiUrl?: string; env?: string }): string | undefin return ENVS[want].api } -export async function login(opts: { email?: string; password?: string; apiUrl?: string; env?: string; oauth?: string; device?: boolean; apiKey?: string }): Promise { +// `device` is injectable so the dispatch itself is testable (repo pattern: DI fakes, no mocks). +export async function login(opts: { email?: string; password?: string; apiUrl?: string; env?: string; oauth?: string; device?: boolean; apiKey?: string }, device: typeof loginDevice = loginDevice): Promise { // Login modes are exclusive — pick one. Check presence (not truthiness) so an explicit // empty --api-key= is rejected by validation rather than silently falling through. if (opts.apiKey !== undefined) { if (opts.device || opts.oauth || opts.email) die('choose one login mode: --api-key, --device, --oauth, or --email') return loginApiKey(opts.apiKey, opts) } - if (opts.device) return loginDevice(opts) + if (opts.device) return device(opts) if (opts.oauth) return loginOauth(opts.oauth, opts) + // An explicitly empty --email is a mistake, not a request for the bare browser flow. + if (opts.email === '') die('--email must not be empty') if (!opts.email) { // Bare `insta login` = sign in from the browser. The device grant is the one flow that covers // every account type (email, GitHub, Google): the console approval page owns the signin // round-trip, so the CLI just opens it here instead of only printing the link. if (opts.password !== undefined || process.env.INSTA_PASSWORD !== undefined) die('a password (--password / $INSTA_PASSWORD) is only used with --email ') - return loginDevice(opts, openUrl) + return device(opts, openUrl) } const api = await ApiClient.load() const target = targetApiUrl(opts) diff --git a/src/commands/env.ts b/src/commands/env.ts index a7e6982..7e2c6bf 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -71,7 +71,7 @@ export async function envUse(name: string, opts: { json?: boolean } = {}): Promi info(`switched ${from ?? '(custom)'} → ${target}`) info(` api: ${nextApi}`) info(` mcp: ${ENVS[target].mcp} (registers as \`${mcpServerName(target)}\`)`) - if (hadSession) info(' previous session dropped (separate deployment) — run `insta login --oauth github`') + if (hadSession) info(' previous session dropped (separate deployment) — run `insta login`') // Switching the CLI does NOT re-point already-installed agents: their MCP registration and skill // files were written for the previous environment and are keyed by a different server name, so // they keep talking to it until setup is re-run. --env is REQUIRED in the hint: since 0.0.38 a diff --git a/test/login-dispatch.test.ts b/test/login-dispatch.test.ts new file mode 100644 index 0000000..a71c132 --- /dev/null +++ b/test/login-dispatch.test.ts @@ -0,0 +1,70 @@ +// `login()` picks the flow from the flags; the injectable device runner (repo pattern: DI fakes, +// no global mocks) lets these tests pin the PUBLIC default — bare login = device grant + local +// browser opener — and the guard branches, without touching config or network. die() prints the +// reason to stderr and throws CliExit('exit 1'), so rejections assert 'exit 1' and the message +// is read from a captured stderr where it matters. +import { describe, expect, it } from 'vitest' +import { login, loginDevice } from '../src/commands/auth.js' +import { openUrl } from '../src/util.js' + +type DeviceRunner = typeof loginDevice + +function fakeDevice() { + const calls: Array<{ opts: unknown; open: unknown }> = [] + const run: DeviceRunner = async (opts, open) => { calls.push({ opts, open }) } + return { run, calls } +} + +const mustNotRun: DeviceRunner = async () => { throw new Error('flow must not start') } + +async function stderrOf(fn: () => Promise): Promise { + const lines: string[] = [] + const write = process.stderr.write.bind(process.stderr) + process.stderr.write = ((s: string) => { lines.push(String(s)); return true }) as typeof process.stderr.write + try { await fn() } finally { process.stderr.write = write } + return lines.join('') +} + +describe('login dispatch', () => { + it('bare login rides the device grant with the local browser opener', async () => { + const prev = process.env.INSTA_PASSWORD + delete process.env.INSTA_PASSWORD // an ambient CI password must not divert the bare flow + try { + const { run, calls } = fakeDevice() + await login({}, run) + expect(calls).toHaveLength(1) + expect(calls[0].open).toBe(openUrl) + } finally { + if (prev !== undefined) process.env.INSTA_PASSWORD = prev + } + }) + + it('--device is the same grant, print-only (no opener)', async () => { + const { run, calls } = fakeDevice() + await login({ device: true }, run) + expect(calls).toHaveLength(1) + expect(calls[0].open).toBeUndefined() + }) + + it('a password without --email is rejected before any flow starts', async () => { + const err = await stderrOf(() => expect(login({ password: 'x' }, mustNotRun)).rejects.toThrow('exit 1')) + expect(err).toContain('only used with --email') + }) + + it('$INSTA_PASSWORD without --email is rejected too', async () => { + const prev = process.env.INSTA_PASSWORD + process.env.INSTA_PASSWORD = 'hunter2' + try { + const err = await stderrOf(() => expect(login({}, mustNotRun)).rejects.toThrow('exit 1')) + expect(err).toContain('only used with --email') + } finally { + if (prev === undefined) delete process.env.INSTA_PASSWORD + else process.env.INSTA_PASSWORD = prev + } + }) + + it('an explicitly empty --email is an error, not a bare browser login', async () => { + const err = await stderrOf(() => expect(login({ email: '' }, mustNotRun)).rejects.toThrow('exit 1')) + expect(err).toContain('--email must not be empty') + }) +})