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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 <prod\|staging>` |
| `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 |
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export async function requireProject(): Promise<ProjectConfig> {
})
} 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))
}
Expand Down
47 changes: 34 additions & 13 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,28 @@ 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<void> {
// `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<void> {
// 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) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// 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 <email>')
return device(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 <github|google>; 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)
Expand All @@ -50,15 +59,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<void> {
// 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<void> {
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)
Expand Down Expand Up @@ -115,7 +125,9 @@ const sleepSeconds = (s: number) => new Promise<void>((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<void> = sleepSeconds): Promise<string> {
// `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<void> = sleepSeconds, open?: (url: string) => boolean): Promise<string> {
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.
Expand All @@ -126,8 +138,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
Expand Down Expand Up @@ -158,7 +179,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.
Expand Down
2 changes: 1 addition & 1 deletion src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -379,7 +379,7 @@ export async function setupAgent(
switchEnv: (name: string) => Promise<void> = (n) => envUse(n),
loginFlow: LoginFlow = {
ask: defaultAsk,
login: () => loginOauth('github', {}),
login: () => loginDevice({}, openUrl),
stdinTty: canPromptViaTty(),
stdoutTty: !!process.stdout.isTTY,
},
Expand Down Expand Up @@ -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.')
}
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <github|google> (browser), --device (headless), or --api-key <insta_…> (headless, durable token)')
.option('--email <email>', 'account email')
.option('--password <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 <email> + password, --oauth <github|google>, --device (headless), --api-key <insta_…> (headless, durable token)')
.option('--email <email>', 'account email (email + password login)')
.option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt; needs --email)')
.option('--oauth <provider>', '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 <key>', 'non-interactive login with a durable insta_ API token (headless agents / CI)')
.option('--api-url <url>', 'control-plane API base URL')
.option('--env <name>', `deployment environment: ${ENV_NAMES.join(' | ')}`)
Expand Down
32 changes: 31 additions & 1 deletion test/device-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<void>((r) => setTimeout(r, 2))
await expect(deviceGrant(post, wait, () => true)).rejects.toThrow(/run `insta login` again/)
})
})
70 changes: 70 additions & 0 deletions test/login-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<string> {
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')
})
})
Loading
Loading