Skip to content

Commit dea0ff4

Browse files
authored
Merge pull request #3 from InsForge/devel
usage/billing commands, browser OAuth login, Bun native binaries
2 parents a036c34 + 1e2c2b6 commit dea0ff4

9 files changed

Lines changed: 241 additions & 10 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@ dist/
33
*.log
44
coverage/
55
.DS_Store
6+
.env
7+
.env.*
8+
.insta/
9+
.claude/
10+
.codex/

README.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,24 @@ InstaCloud CLI (`insta`) — a thin client of the [platform](../platform) contro
99

1010
```bash
1111
npm install
12-
npm run build # -> dist/index.js(bin: insta)
12+
npm run build # -> dist/index.js(bin: insta;纯 JS,运行需 node
1313
node dist/index.js --help
1414
```
1515

16+
### 原生二进制(Bun,运行时无需 node)
17+
18+
用 Bun 把 CLI 编译成各平台**独立可执行文件**(内嵌运行时,像 Railway CLI 那样)——npm 包仍发布 JS
19+
`dist/index.js`),这些二进制是另一条下载渠道(GitHub releases / curl 安装器)。需要 [Bun](https://bun.sh)
20+
21+
```bash
22+
npm run compile # 只编译当前平台 -> dist/bin/insta
23+
npm run build:binaries # 交叉编译全平台 -> dist/bin/insta-<os>-<arch>(.exe) + SHA256SUMS
24+
# 版本号(baked 进 `insta --version`)默认取 package.json,也可传参:bash scripts/build-binaries.sh 1.2.3
25+
```
26+
27+
产物形如 `insta-darwin-arm64` / `insta-linux-x64` / `insta-windows-x64.exe``file` 显示 Mach-O/ELF/PE 原生可执行)。
28+
`dist/` 已 gitignore;二进制不入库,交给 CI 发到 releases。
29+
1630
## Quickstart
1731

1832
```bash
@@ -29,6 +43,7 @@ insta status # 登录态 + 已 link 的 project/branch
2943
| 命令 | 说明 |
3044
|------|------|
3145
| `insta login [--email --password --api-url]` | 登录(email/password;token 自动 refresh) |
46+
| `insta login --oauth <github\|google>` | 浏览器 OAuth 登录(启本地回环端口,浏览器授权后自动带回 token) |
3247
| `insta logout` / `insta status [--json]` | 登出 / 查看状态 |
3348
| `insta org list [--json]` / `org create <name>` | 组织 |
3449
| `insta project create <name> [--org]` | provision 新 project 并 link |
@@ -42,6 +57,10 @@ insta status # 登录态 + 已 link 的 project/branch
4257
| `insta metrics <db\|compute> [group] [--branch --from --to --step --json]` | 资源指标(compute=Fly;db 受限) |
4358
| `insta logs <db\|compute> [group] [--branch --limit --region --instance --json]` | 运行时日志(compute=Fly;db 受限) |
4459
| `insta events [--branch --limit --json]` | 审计 + agent 事件时间线 |
60+
| `insta usage [--from --to --json]` | 按 meter 聚合的资源用量(含 costUsd) |
61+
| `insta billing [--org --json]` | 当前计费周期摘要(tier / 额度 / 已用 / overage / 状态) |
62+
| `insta billing upgrade <pro\|enterprise> [--org --no-open --json]` | Stripe Checkout 订阅付费档,返回并打开支付链接 |
63+
| `insta billing portal [--org --no-open --json]` | 打开 Stripe Customer Portal(改套餐 / 卡 / 取消) |
4564
| `insta approvals list [--status] [--json]` | 治理审批列表 |
4665
| `insta approvals approve <id> [--always]` / `deny <id>` | 批准 / 拒绝(admin) |
4766
| `insta policy get [--json]` / `policy set <action> <decision>` | 治理策略 |
@@ -67,5 +86,15 @@ cd ../platform && DATABASE_URL='postgres://postgres:insta@localhost:55432/insta_
6786
INSTA_API_URL=http://localhost:8899 insta login --email you@x.com --password ...
6887
```
6988

70-
> `metrics` / `logs` 已支持(compute 实时;db 受 provider 限制)。`usage`(billing)待平台 #5 完成后补充;
71-
> OAuth 浏览器登录、`compute add-group/scale/set-domain`、镜像构建后续加入。
89+
## OAuth 浏览器登录
90+
91+
```bash
92+
insta login --oauth github # 或 google
93+
# CLI 起本地回环端口 → 打开浏览器到 /auth/cli/authorize → Better Auth 走 provider 授权 →
94+
# 平台读会话 cookie 换出 bearer token → 带回回环端口 → CLI 存为登录态
95+
```
96+
97+
> 平台侧需配置该 provider 的 OAuth 应用(`GITHUB_OAUTH_CLIENT_ID/SECRET``GOOGLE_*`),
98+
> 且应用的回调 URL 必须是 **`{INSTA_API_BASE_URL}/api/auth/callback/<provider>`**(不是回环地址)。
99+
100+
> `metrics` / `logs` / `usage` 已支持(usage 为采集层聚合)。`compute add-group/scale/set-domain`、镜像构建后续加入。

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "insta-cli",
3-
"version": "0.0.0",
3+
"version": "0.0.1",
44
"private": true,
55
"type": "module",
66
"description": "InstaCloud CLI — a thin client of the platform control-plane API.",
@@ -10,7 +10,9 @@
1010
"build": "tsc -p tsconfig.json",
1111
"typecheck": "tsc -p tsconfig.json --noEmit",
1212
"dev": "tsx src/index.ts",
13-
"test": "vitest run"
13+
"test": "vitest run",
14+
"compile": "bun build ./src/index.ts --compile --minify --outfile dist/bin/insta",
15+
"build:binaries": "bash scripts/build-binaries.sh"
1416
},
1517
"dependencies": {
1618
"commander": "^12.1.0"

scripts/build-binaries.sh

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env bash
2+
# Build standalone `insta` binaries with Bun — no Node needed at runtime (like the Railway CLI).
3+
# Cross-compiles for macOS/Linux/Windows and writes to dist/bin/ + a SHA256SUMS file.
4+
#
5+
# bash scripts/build-binaries.sh [version]
6+
#
7+
# `version` defaults to package.json's version; it's baked into `insta --version`.
8+
# Requires Bun (https://bun.sh). The npm package still ships the JS bin (dist/index.js) — these
9+
# binaries are a separate download channel (GitHub releases / curl installer).
10+
set -euo pipefail
11+
12+
cd "$(dirname "$0")/.." # cli/ root
13+
ENTRY="src/index.ts"
14+
OUT="dist/bin"
15+
VERSION="${1:-$(node -p "require('./package.json').version" 2>/dev/null || echo dev)}"
16+
17+
command -v bun >/dev/null 2>&1 || { echo "error: bun not found — install from https://bun.sh"; exit 1; }
18+
19+
# Bun compile target : output name suffix. Add -musl (Alpine) or -baseline (pre-AVX2 x64) variants
20+
# here if needed, e.g. "bun-linux-x64-musl:linux-x64-musl".
21+
targets=(
22+
"bun-darwin-arm64:darwin-arm64"
23+
"bun-darwin-x64:darwin-x64"
24+
"bun-linux-arm64:linux-arm64"
25+
"bun-linux-x64:linux-x64"
26+
"bun-windows-x64:windows-x64.exe"
27+
)
28+
29+
rm -rf "$OUT"; mkdir -p "$OUT"
30+
echo "building insta v$VERSION$OUT"
31+
for entry in "${targets[@]}"; do
32+
triple="${entry%%:*}"
33+
outfile="$OUT/insta-${entry##*:}"
34+
echo "$triple"
35+
bun build "$ENTRY" \
36+
--compile --minify \
37+
--target="$triple" \
38+
--define "process.env.INSTA_CLI_VERSION=\"$VERSION\"" \
39+
--outfile "$outfile"
40+
done
41+
42+
# Checksums for release integrity / installer verification.
43+
( cd "$OUT" && { command -v shasum >/dev/null 2>&1 && shasum -a 256 insta-* || sha256sum insta-*; } > SHA256SUMS )
44+
45+
echo "done:"
46+
ls -lh "$OUT"

src/commands/auth.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,67 @@
1+
import { createServer } from 'node:http'
2+
import { randomBytes } from 'node:crypto'
13
import { ApiClient, linkedProject } from '../api.js'
2-
import { info, die, printJson, promptPassword } from '../util.js'
4+
import { info, die, printJson, promptPassword, openUrl } from '../util.js'
35

4-
export async function login(opts: { email?: string; password?: string; apiUrl?: string }): Promise<void> {
6+
export async function login(opts: { email?: string; password?: string; apiUrl?: string; oauth?: string }): Promise<void> {
7+
if (opts.oauth) return loginOauth(opts.oauth, opts)
58
const api = await ApiClient.load()
69
if (opts.apiUrl) api.setApiUrl(opts.apiUrl)
7-
if (!opts.email) die('--email is required')
10+
if (!opts.email) die('--email is required (or use --oauth <github|google>)')
811
const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword())
912
const res = await api.request('POST', '/auth/login', { email: opts.email, password }, { auth: false })
1013
api.setSession(res, res.user)
1114
await api.persist()
1215
info(`logged in as ${res.user.email ?? res.user.id} @ ${api.apiUrl}`)
1316
}
1417

18+
// Browser OAuth (GitHub/Google) via a loopback listener. We open the platform's CLI-OAuth bridge,
19+
// which runs Better Auth's social flow and bounces the resulting session token back to us.
20+
export async function loginOauth(provider: string, opts: { apiUrl?: string }): Promise<void> {
21+
if (provider !== 'github' && provider !== 'google') die('provider must be github or google')
22+
const api = await ApiClient.load()
23+
if (opts.apiUrl) api.setApiUrl(opts.apiUrl)
24+
const token = await browserOauth(api.apiUrl, provider)
25+
api.setSession({ accessToken: token, refreshToken: token })
26+
const me = await api.request<{ user: { id: string; email: string | null; name: string | null } }>('GET', '/me')
27+
api.setSession({ accessToken: token, refreshToken: token }, me.user)
28+
await api.persist()
29+
info(`logged in as ${me.user.email ?? me.user.id} @ ${api.apiUrl}`)
30+
}
31+
32+
// Start a loopback server, open the browser at the platform bridge, and await the token.
33+
function browserOauth(apiUrl: string, provider: string): Promise<string> {
34+
return new Promise<string>((resolve, reject) => {
35+
const state = randomBytes(16).toString('hex')
36+
let timer: NodeJS.Timeout
37+
const server = createServer((req, res) => {
38+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
39+
if (url.pathname !== '/callback') { res.writeHead(404); res.end(); return }
40+
const token = url.searchParams.get('token')
41+
const err = url.searchParams.get('error')
42+
const ok = !!token && !err && url.searchParams.get('state') === state
43+
res.writeHead(ok ? 200 : 400, { 'content-type': 'text/html' })
44+
res.end(`<!doctype html><meta charset=utf-8><body style="font-family:system-ui;text-align:center;margin-top:4rem"><h2>InstaCloud</h2><p>${ok ? '✓ Login complete — you can close this tab.' : '✗ Login failed' + (err ? ` (${err})` : '')}</p></body>`)
45+
clearTimeout(timer)
46+
server.close()
47+
if (err) return reject(new Error(`oauth failed: ${err}`))
48+
if (!token) return reject(new Error('no token returned'))
49+
if (url.searchParams.get('state') !== state) return reject(new Error('state mismatch — aborting'))
50+
resolve(token)
51+
})
52+
server.on('error', reject)
53+
server.listen(0, '127.0.0.1', () => {
54+
const addr = server.address()
55+
const port = typeof addr === 'object' && addr ? addr.port : 0
56+
const redirect = `http://127.0.0.1:${port}/callback`
57+
const authorizeUrl = `${apiUrl}/auth/cli/authorize?provider=${encodeURIComponent(provider)}&redirect=${encodeURIComponent(redirect)}&state=${state}`
58+
info(`opening browser to authorize with ${provider}…`)
59+
if (!openUrl(authorizeUrl)) info(`open this URL to continue:\n ${authorizeUrl}`)
60+
timer = setTimeout(() => { server.close(); reject(new Error('timed out waiting for browser login (2m)')) }, 120_000)
61+
})
62+
})
63+
}
64+
1565
export async function logout(): Promise<void> {
1666
const api = await ApiClient.load()
1767
if (api.config.refreshToken) {

src/commands/billing.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { ApiClient, requireProject } from '../api.js'
2+
import { die, info, openUrl, printJson } from '../util.js'
3+
4+
type OrgOpt = { org?: string }
5+
6+
// Resolve the target org: explicit --org, else the linked project's org.
7+
async function resolveOrgId(opts: OrgOpt): Promise<string> {
8+
if (opts.org) return opts.org
9+
return (await requireProject()).orgId
10+
}
11+
12+
// insta billing — current cycle summary for the org.
13+
export async function billing(opts: OrgOpt & { json?: boolean }): Promise<void> {
14+
const api = await ApiClient.load()
15+
const orgId = await resolveOrgId(opts)
16+
const s = await api.request('GET', `/orgs/${orgId}/billing`)
17+
if (opts.json) return printJson(s)
18+
info(`tier: ${s.tier}`)
19+
info(`status: ${s.status}`)
20+
info(`cycle: ${String(s.cycleStart).slice(0, 10)}${String(s.cycleEnd).slice(0, 10)}`)
21+
info(`included: $${Number(s.includedUsd).toFixed(2)}`)
22+
info(`used: $${Number(s.usedUsd).toFixed(4)}`)
23+
info(`overage: $${Number(s.overageUsd).toFixed(4)}`)
24+
if (s.status === 'suspended') info('⚠ org suspended — billing limit reached; resumes next cycle (or `insta billing upgrade pro`)')
25+
}
26+
27+
// insta billing upgrade <tier> — start a Stripe Checkout to subscribe the org to a paid tier.
28+
export async function billingUpgrade(tier: string, opts: OrgOpt & { open?: boolean; json?: boolean }): Promise<void> {
29+
if (tier !== 'pro' && tier !== 'enterprise') die('tier must be pro|enterprise')
30+
const api = await ApiClient.load()
31+
const orgId = await resolveOrgId(opts)
32+
const { url } = await api.request<{ url: string }>('POST', `/orgs/${orgId}/billing/checkout`, { tier })
33+
if (opts.json) return printJson({ url })
34+
presentUrl(url, `Subscribe to ${tier} — complete checkout in your browser:`, opts.open)
35+
}
36+
37+
// insta billing portal — open the Stripe Customer Portal (change plan / card / cancel).
38+
export async function billingPortal(opts: OrgOpt & { open?: boolean; json?: boolean }): Promise<void> {
39+
const api = await ApiClient.load()
40+
const orgId = await resolveOrgId(opts)
41+
const { url } = await api.request<{ url: string }>('POST', `/orgs/${orgId}/billing/portal`)
42+
if (opts.json) return printJson({ url })
43+
presentUrl(url, 'Manage billing in your browser:', opts.open)
44+
}
45+
46+
// Print the URL and, unless --no-open, try to open it in the browser.
47+
function presentUrl(url: string, label: string, open?: boolean): void {
48+
info(label)
49+
info(` ${url}`)
50+
if (open !== false && openUrl(url)) info('(opened in your default browser)')
51+
}

src/commands/metrics.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ export async function metrics(component: string, group: string | undefined, opts
2222
}
2323
}
2424

25+
// insta usage — aggregated usage by meter over a window
26+
export async function usage(opts: { from?: string; to?: string; json?: boolean }): Promise<void> {
27+
const api = await ApiClient.load()
28+
const p = await requireProject()
29+
const res = await api.request('GET', `/projects/${p.projectId}/usage${qs({ from: opts.from, to: opts.to })}`)
30+
if (opts.json) return printJson(res)
31+
info(`usage ${new Date(res.from * 1000).toISOString().slice(0, 10)}${new Date(res.to * 1000).toISOString().slice(0, 10)}`)
32+
if (!res.usage?.length) return info('(no usage recorded)')
33+
let total = 0
34+
for (const u of res.usage) {
35+
const dims = u.dimensions && Object.keys(u.dimensions).length ? ` ${JSON.stringify(u.dimensions)}` : ''
36+
const cost = u.costUsd != null ? ` ($${Number(u.costUsd).toFixed(4)})` : ''
37+
total += Number(u.costUsd ?? 0)
38+
info(`${u.meter}${dims}: ${u.quantity} ${u.unit}${cost}`)
39+
}
40+
info(`total: $${total.toFixed(4)}`)
41+
}
42+
2543
// insta logs <db|compute> [group]
2644
export async function logs(component: string, group: string | undefined, opts: { branch?: string; limit?: string; region?: string; instance?: string; json?: boolean }): Promise<void> {
2745
const api = await ApiClient.load()

src/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { manifest } from './commands/manifest.js'
1212
import * as govern from './commands/govern.js'
1313
import * as observe from './commands/observe.js'
1414
import * as obs from './commands/metrics.js'
15+
import { billing, billingUpgrade, billingPortal } from './commands/billing.js'
1516

1617
function onError(e: unknown): never {
1718
if (e instanceof ApiError) die(`${e.message} (HTTP ${e.status})`)
@@ -23,12 +24,16 @@ const guard = (fn: (...a: any[]) => Promise<unknown>) => (...a: any[]): Promise<
2324
fn(...a).then(() => undefined).catch(onError)
2425

2526
const program = new Command()
26-
program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version('0.0.0')
27+
// Baked in at compile time for the standalone binary (bun build --define); falls back to 0.0.0 for
28+
// the plain node/npm build.
29+
const VERSION = process.env.INSTA_CLI_VERSION ?? '0.0.0'
30+
program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(VERSION)
2731

2832
// ---- auth ----
29-
program.command('login').description('Log in with email + password')
33+
program.command('login').description('Log in with email + password, or --oauth <github|google> (browser)')
3034
.option('--email <email>', 'account email')
3135
.option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt)')
36+
.option('--oauth <provider>', 'browser OAuth login: github | google')
3237
.option('--api-url <url>', 'control-plane API base URL')
3338
.action(guard((o) => auth.login(o)))
3439
program.command('logout').description('Log out and clear local tokens').action(guard(() => auth.logout()))
@@ -74,6 +79,18 @@ program.command('metrics <component> [group]').description('Resource metrics (co
7479
program.command('logs <component> [group]').description('Runtime logs (component: db|compute)')
7580
.option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--json')
7681
.action(guard((component, group, o) => obs.logs(component, group, o)))
82+
program.command('usage').description('Resource usage aggregated by meter (with cost)')
83+
.option('--from <unix>').option('--to <unix>').option('--json')
84+
.action(guard((o) => obs.usage(o)))
85+
const bill = program.command('billing').description('Current billing cycle summary (tier / credit / used / overage)')
86+
.option('--org <id>', 'target org (default: linked project\'s org)').option('--json')
87+
.action(guard((o) => billing(o)))
88+
bill.command('upgrade <tier>').description('Subscribe the org to a paid tier (pro|enterprise) via Stripe Checkout')
89+
.option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
90+
.action(guard((tier, o) => billingUpgrade(tier, o)))
91+
bill.command('portal').description('Open the Stripe Customer Portal (change plan / card / cancel)')
92+
.option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
93+
.action(guard((o) => billingPortal(o)))
7794

7895
// ---- events (audit timeline) ----
7996
program.command('events').description('Show the audit + agent-event timeline').option('--branch <b>').option('--limit <n>').option('--json').action(guard((o) => govern.events(o)))

src/util.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
// Output + small pure helpers (env serialization is unit-tested).
22
import { createInterface } from 'node:readline'
3+
import { spawn } from 'node:child_process'
4+
5+
// Best-effort: open a URL in the user's default browser. Returns false if we couldn't launch.
6+
export function openUrl(url: string): boolean {
7+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
8+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
9+
try {
10+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true })
11+
child.on('error', () => {})
12+
child.unref()
13+
return true
14+
} catch { return false }
15+
}
316

417
export function die(msg: string): never {
518
process.stderr.write(`error: ${msg}\n`)

0 commit comments

Comments
 (0)