insta 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -0
- package/dist/api.js +99 -0
- package/dist/commands/auth.js +100 -0
- package/dist/commands/billing.js +52 -0
- package/dist/commands/branch.js +40 -0
- package/dist/commands/compute.js +45 -0
- package/dist/commands/deploy.js +44 -0
- package/dist/commands/govern.js +55 -0
- package/dist/commands/manifest.js +20 -0
- package/dist/commands/metrics.js +88 -0
- package/dist/commands/observe.js +58 -0
- package/dist/commands/org.js +16 -0
- package/dist/commands/project.js +61 -0
- package/dist/commands/secrets.js +36 -0
- package/dist/commands/services.js +89 -0
- package/dist/config.js +35 -0
- package/dist/ensure-skills.js +70 -0
- package/dist/flyctl-build.js +79 -0
- package/dist/index.js +132 -0
- package/dist/observe/hook.js +62 -0
- package/dist/observe/install.js +91 -0
- package/dist/observe/report.js +70 -0
- package/dist/observe/scanner.js +189 -0
- package/dist/util.js +57 -0
- package/package.json +28 -6
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# insta-cli
|
|
2
|
+
|
|
3
|
+
InstaCloud CLI (`insta`) — a thin client of the [platform](../platform) control-plane API.
|
|
4
|
+
管理 project / branch / secrets / deploy / governance,面向开发者与 agent。
|
|
5
|
+
|
|
6
|
+
技术栈:Node 20 + TypeScript(ESM)+ commander。所有命令都是平台 API 的封装。
|
|
7
|
+
|
|
8
|
+
## 安装
|
|
9
|
+
|
|
10
|
+
**一键装(原生二进制,无需 node)** — macOS / Linux / WSL:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh
|
|
14
|
+
# 装到 ~/.insta/bin/insta(可 INSTA_INSTALL_DIR 覆盖);校验 SHA256SUMS。
|
|
15
|
+
# 固定版本:curl -fsSL .../install.sh | INSTA_VERSION=v0.1.0 sh
|
|
16
|
+
# Windows:从 releases 页下载 insta-windows-x64.exe。
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**从源码构建(需 node):**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install
|
|
23
|
+
npm run build # -> dist/index.js(bin: insta;纯 JS,运行需 node)
|
|
24
|
+
node dist/index.js --help
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 自己出二进制(Bun 交叉编译)
|
|
28
|
+
|
|
29
|
+
`install.sh` 装的二进制由 CI(tag `v*` → `.github/workflows/release.yml`)用 Bun 交叉编译并发到 GitHub
|
|
30
|
+
releases。本地也可出:需 [Bun](https://bun.sh)。npm 包仍发布 JS(`dist/index.js`)——二进制是另一条渠道。
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm run compile # 只编译当前平台 -> dist/bin/insta
|
|
34
|
+
npm run build:binaries # 交叉编译全平台 -> dist/bin/insta-<os>-<arch>(.exe) + SHA256SUMS
|
|
35
|
+
# 版本号(baked 进 `insta --version`)默认取 package.json,也可传参:bash scripts/build-binaries.sh 1.2.3
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
产物形如 `insta-darwin-arm64` / `insta-linux-x64` / `insta-windows-x64.exe`(`file` 显示 Mach-O/ELF/PE 原生可执行)。
|
|
39
|
+
`dist/` 已 gitignore;二进制不入库,交给 CI 发到 releases。
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# 指向控制面(默认 http://localhost:8080,可用 $INSTA_API_URL 或 --api-url 覆盖)
|
|
45
|
+
insta login --email you@example.com --password ****** --api-url http://localhost:8080
|
|
46
|
+
insta project create my-app # 新建空 project 并 link 当前目录(默认不含 service)
|
|
47
|
+
insta services add postgres db # 按需添加 service(postgres/storage/compute)
|
|
48
|
+
insta services add compute api # compute 用于部署镜像
|
|
49
|
+
insta secrets # 把当前 branch 的凭证写入 ./.env(secret seam)
|
|
50
|
+
insta deploy --image <registry/img> # 部署容器镜像到当前 branch 的 compute service
|
|
51
|
+
insta status # 登录态 + 已 link 的 project/branch
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 命令
|
|
55
|
+
|
|
56
|
+
| 命令 | 说明 |
|
|
57
|
+
|------|------|
|
|
58
|
+
| `insta login [--email --password --api-url]` | 登录(email/password;token 自动 refresh) |
|
|
59
|
+
| `insta login --oauth <github\|google>` | 浏览器 OAuth 登录(启本地回环端口,浏览器授权后自动带回 token) |
|
|
60
|
+
| `insta logout` / `insta status [--json]` | 登出 / 查看状态 |
|
|
61
|
+
| `insta org list [--json]` / `org create <name>` | 组织(每个用户仅可拥有一个 free org) |
|
|
62
|
+
| `insta project create <name> [--org]` | 新建空 project 并 link(默认不含任何 service) |
|
|
63
|
+
| `insta project list [--org] [--json]` / `link <id>` / `delete` | 项目管理 |
|
|
64
|
+
| `insta services add <postgres\|storage\|compute> <name>` | 按需 provision 一个 service(postgres/compute 分配默认访问域名) |
|
|
65
|
+
| `insta services list [--json]` / `services remove <type> <name>` | 列出 / 删除 service |
|
|
66
|
+
| `insta services scale compute <name> <number> [region]` | 设置 compute 机器数(付费档;free 拒绝) |
|
|
67
|
+
| `insta services upgrade <compute\|postgres> <name> <spec>` | 升级 spec(付费档;只升不降) |
|
|
68
|
+
| `insta branch create <name> [--from]` | 新建分支环境(物化 project 当前的 services;每 project 上限 10 个 branch) |
|
|
69
|
+
| `insta branch list [--json]` / `switch <name>` / `delete <name>` | 分支管理 |
|
|
70
|
+
| `insta secrets [--branch -o --print --json]` | secret seam:凭证写入 `.env` |
|
|
71
|
+
| `insta secrets list [--branch]` | 仅列出 secret 名 |
|
|
72
|
+
| `insta deploy --image <url> [--branch --group --port]` | 部署镜像 |
|
|
73
|
+
| `insta manifest [--json]` | agent 可读的环境清单 |
|
|
74
|
+
| `insta metrics <db\|compute> [group] [--branch --from --to --step --json]` | 资源指标(compute=Fly;db 受限) |
|
|
75
|
+
| `insta logs <db\|compute> [group] [--branch --limit --region --instance --json]` | 运行时日志(compute=Fly;db 受限) |
|
|
76
|
+
| `insta events [--branch --limit --json]` | 审计 + agent 事件时间线 |
|
|
77
|
+
| `insta usage [--from --to --json]` | 按 meter 聚合的资源用量(含 costUsd) |
|
|
78
|
+
| `insta billing [--org --json]` | 当前计费周期摘要(tier / 额度 / 已用 / overage / 状态) |
|
|
79
|
+
| `insta billing upgrade <pro\|enterprise> [--org --no-open --json]` | Stripe Checkout 订阅付费档,返回并打开支付链接 |
|
|
80
|
+
| `insta billing portal [--org --no-open --json]` | 打开 Stripe Customer Portal(改套餐 / 卡 / 取消) |
|
|
81
|
+
| `insta approvals list [--status] [--json]` | 治理审批列表 |
|
|
82
|
+
| `insta approvals approve <id> [--always]` / `deny <id>` | 批准 / 拒绝(admin) |
|
|
83
|
+
| `insta policy get [--json]` / `policy set <action> <decision>` | 治理策略(action 含 `service.add/remove/scale/upgrade`) |
|
|
84
|
+
|
|
85
|
+
被 governance gate 的操作(`secrets.read`/`deploy`/`project.delete`/`branch.delete`/`service.add`/`service.remove`/`service.scale`/`service.upgrade`)命中审批时,
|
|
86
|
+
CLI 会提示 `approval required — run: insta approvals approve <id>`。
|
|
87
|
+
|
|
88
|
+
## 配置位置
|
|
89
|
+
|
|
90
|
+
- 全局:`~/.insta/config.json`(apiUrl + access/refresh token + user)
|
|
91
|
+
- 项目:`./.insta/project.json`(projectId / orgId / 当前 branch)
|
|
92
|
+
|
|
93
|
+
## 本地端到端跑通
|
|
94
|
+
|
|
95
|
+
平台提供 `dev:fake` 模式(fake provider adapters,无需 Neon/Fly/Tigris 凭证):
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# 1) 起 Postgres + 平台 dev 服务(见 ../platform)
|
|
99
|
+
docker run -d --name pg -e POSTGRES_PASSWORD=insta -e POSTGRES_DB=insta_dev -p 55432:5432 postgres:16-alpine
|
|
100
|
+
cd ../platform && DATABASE_URL='postgres://postgres:insta@localhost:55432/insta_dev' PORT=8899 npm run dev:fake
|
|
101
|
+
|
|
102
|
+
# 2) 用 CLI 跑全流程(注册走 /auth/signup + /auth/verify-email,dev 模式验证码打印在服务端日志)
|
|
103
|
+
INSTA_API_URL=http://localhost:8899 insta login --email you@x.com --password ...
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## OAuth 浏览器登录
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
insta login --oauth github # 或 google
|
|
110
|
+
# CLI 起本地回环端口 → 打开浏览器到 /auth/cli/authorize → Better Auth 走 provider 授权 →
|
|
111
|
+
# 平台读会话 cookie 换出 bearer token → 带回回环端口 → CLI 存为登录态
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
> 平台侧需配置该 provider 的 OAuth 应用(`GITHUB_OAUTH_CLIENT_ID/SECRET` 或 `GOOGLE_*`),
|
|
115
|
+
> 且应用的回调 URL 必须是 **`{INSTA_API_BASE_URL}/api/auth/callback/<provider>`**(不是回环地址)。
|
|
116
|
+
|
|
117
|
+
> `metrics` / `logs` / `usage` 已支持(usage 为采集层聚合)。多 compute service(`services add compute`)、`services scale/upgrade` 已实现;镜像构建后续加入。多 postgres/storage service(每 project >1 个)暂受 credential-seam 限制,为后续工作。
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Thin API client over the platform control-plane. Handles bearer auth + one-shot refresh on 401.
|
|
2
|
+
// 2xx (including 202 approval_required) returns the parsed body; >=400 throws ApiError.
|
|
3
|
+
import { readGlobal, writeGlobal, readProject } from './config.js';
|
|
4
|
+
import { die } from './util.js';
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, msg) {
|
|
8
|
+
super(msg);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = 'ApiError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class ApiClient {
|
|
14
|
+
cfg;
|
|
15
|
+
constructor(cfg) {
|
|
16
|
+
this.cfg = cfg;
|
|
17
|
+
}
|
|
18
|
+
static async load() { return new ApiClient(await readGlobal()); }
|
|
19
|
+
get apiUrl() { return this.cfg.apiUrl; }
|
|
20
|
+
get config() { return this.cfg; }
|
|
21
|
+
async persist() { await writeGlobal(this.cfg); }
|
|
22
|
+
setApiUrl(url) { this.cfg.apiUrl = url; }
|
|
23
|
+
setSession(tokens, user) {
|
|
24
|
+
this.cfg.accessToken = tokens.accessToken;
|
|
25
|
+
this.cfg.refreshToken = tokens.refreshToken;
|
|
26
|
+
if (user)
|
|
27
|
+
this.cfg.user = user;
|
|
28
|
+
}
|
|
29
|
+
clearSession() {
|
|
30
|
+
delete this.cfg.accessToken;
|
|
31
|
+
delete this.cfg.refreshToken;
|
|
32
|
+
delete this.cfg.user;
|
|
33
|
+
}
|
|
34
|
+
// Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
|
|
35
|
+
async request(method, path, body, opts = {}) {
|
|
36
|
+
const res = await this.raw(method, path, body, opts.auth ?? true);
|
|
37
|
+
if (res.status >= 400)
|
|
38
|
+
throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`);
|
|
39
|
+
return res.body;
|
|
40
|
+
}
|
|
41
|
+
// Like request but returns {status, body} so callers can branch on 202 (approval_required).
|
|
42
|
+
async rawRequest(method, path, body, opts = {}) {
|
|
43
|
+
const res = await this.raw(method, path, body, opts.auth ?? true);
|
|
44
|
+
if (res.status >= 400)
|
|
45
|
+
throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`);
|
|
46
|
+
return res;
|
|
47
|
+
}
|
|
48
|
+
async raw(method, path, body, auth) {
|
|
49
|
+
let r = await this.fetch(method, path, body, auth);
|
|
50
|
+
if (r.status === 401 && auth && this.cfg.refreshToken) {
|
|
51
|
+
if (await this.refresh())
|
|
52
|
+
r = await this.fetch(method, path, body, auth);
|
|
53
|
+
}
|
|
54
|
+
return r;
|
|
55
|
+
}
|
|
56
|
+
async fetch(method, path, body, auth) {
|
|
57
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
58
|
+
if (auth && this.cfg.accessToken)
|
|
59
|
+
headers.Authorization = `Bearer ${this.cfg.accessToken}`;
|
|
60
|
+
const res = await fetch(this.apiUrl + path, {
|
|
61
|
+
method,
|
|
62
|
+
headers,
|
|
63
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
64
|
+
});
|
|
65
|
+
const text = await res.text();
|
|
66
|
+
let parsed = null;
|
|
67
|
+
try {
|
|
68
|
+
parsed = text ? JSON.parse(text) : null;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
parsed = { raw: text };
|
|
72
|
+
}
|
|
73
|
+
return { status: res.status, body: parsed };
|
|
74
|
+
}
|
|
75
|
+
async refresh() {
|
|
76
|
+
try {
|
|
77
|
+
const res = await this.fetch('POST', '/auth/refresh', { refreshToken: this.cfg.refreshToken }, false);
|
|
78
|
+
if (res.status >= 400)
|
|
79
|
+
return false;
|
|
80
|
+
this.cfg.accessToken = res.body.accessToken;
|
|
81
|
+
this.cfg.refreshToken = res.body.refreshToken;
|
|
82
|
+
await this.persist();
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Resolve the linked project (./.insta/project.json), or null.
|
|
91
|
+
export async function linkedProject() { return readProject(); }
|
|
92
|
+
// Resolve the linked project or exit with guidance.
|
|
93
|
+
export async function requireProject() {
|
|
94
|
+
const p = await readProject();
|
|
95
|
+
if (!p)
|
|
96
|
+
die('no linked project — run `insta project create <name>` or `insta project link <id>`');
|
|
97
|
+
return p;
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=api.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { ApiClient, linkedProject } from '../api.js';
|
|
4
|
+
import { info, die, printJson, promptPassword, openUrl } from '../util.js';
|
|
5
|
+
export async function login(opts) {
|
|
6
|
+
if (opts.oauth)
|
|
7
|
+
return loginOauth(opts.oauth, opts);
|
|
8
|
+
const api = await ApiClient.load();
|
|
9
|
+
if (opts.apiUrl)
|
|
10
|
+
api.setApiUrl(opts.apiUrl);
|
|
11
|
+
if (!opts.email)
|
|
12
|
+
die('--email is required (or use --oauth <github|google>)');
|
|
13
|
+
const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword());
|
|
14
|
+
const res = await api.request('POST', '/auth/login', { email: opts.email, password }, { auth: false });
|
|
15
|
+
api.setSession(res, res.user);
|
|
16
|
+
await api.persist();
|
|
17
|
+
info(`logged in as ${res.user.email ?? res.user.id} @ ${api.apiUrl}`);
|
|
18
|
+
}
|
|
19
|
+
// Browser OAuth (GitHub/Google) via a loopback listener. We open the platform's CLI-OAuth bridge,
|
|
20
|
+
// which runs Better Auth's social flow and bounces the resulting session token back to us.
|
|
21
|
+
export async function loginOauth(provider, opts) {
|
|
22
|
+
if (provider !== 'github' && provider !== 'google')
|
|
23
|
+
die('provider must be github or google');
|
|
24
|
+
const api = await ApiClient.load();
|
|
25
|
+
if (opts.apiUrl)
|
|
26
|
+
api.setApiUrl(opts.apiUrl);
|
|
27
|
+
const token = await browserOauth(api.apiUrl, provider);
|
|
28
|
+
api.setSession({ accessToken: token, refreshToken: token });
|
|
29
|
+
const me = await api.request('GET', '/me');
|
|
30
|
+
api.setSession({ accessToken: token, refreshToken: token }, me.user);
|
|
31
|
+
await api.persist();
|
|
32
|
+
info(`logged in as ${me.user.email ?? me.user.id} @ ${api.apiUrl}`);
|
|
33
|
+
}
|
|
34
|
+
// Start a loopback server, open the browser at the platform bridge, and await the token.
|
|
35
|
+
function browserOauth(apiUrl, provider) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const state = randomBytes(16).toString('hex');
|
|
38
|
+
let timer;
|
|
39
|
+
const server = createServer((req, res) => {
|
|
40
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
41
|
+
if (url.pathname !== '/callback') {
|
|
42
|
+
res.writeHead(404);
|
|
43
|
+
res.end();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const token = url.searchParams.get('token');
|
|
47
|
+
const err = url.searchParams.get('error');
|
|
48
|
+
const ok = !!token && !err && url.searchParams.get('state') === state;
|
|
49
|
+
res.writeHead(ok ? 200 : 400, { 'content-type': 'text/html' });
|
|
50
|
+
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>`);
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
server.close();
|
|
53
|
+
if (err)
|
|
54
|
+
return reject(new Error(`oauth failed: ${err}`));
|
|
55
|
+
if (!token)
|
|
56
|
+
return reject(new Error('no token returned'));
|
|
57
|
+
if (url.searchParams.get('state') !== state)
|
|
58
|
+
return reject(new Error('state mismatch — aborting'));
|
|
59
|
+
resolve(token);
|
|
60
|
+
});
|
|
61
|
+
server.on('error', reject);
|
|
62
|
+
server.listen(0, '127.0.0.1', () => {
|
|
63
|
+
const addr = server.address();
|
|
64
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
65
|
+
const redirect = `http://127.0.0.1:${port}/callback`;
|
|
66
|
+
const authorizeUrl = `${apiUrl}/auth/cli/authorize?provider=${encodeURIComponent(provider)}&redirect=${encodeURIComponent(redirect)}&state=${state}`;
|
|
67
|
+
info(`opening browser to authorize with ${provider}…`);
|
|
68
|
+
if (!openUrl(authorizeUrl))
|
|
69
|
+
info(`open this URL to continue:\n ${authorizeUrl}`);
|
|
70
|
+
timer = setTimeout(() => { server.close(); reject(new Error('timed out waiting for browser login (2m)')); }, 120_000);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
export async function logout() {
|
|
75
|
+
const api = await ApiClient.load();
|
|
76
|
+
if (api.config.refreshToken) {
|
|
77
|
+
try {
|
|
78
|
+
await api.request('POST', '/auth/logout', { refreshToken: api.config.refreshToken }, { auth: false });
|
|
79
|
+
}
|
|
80
|
+
catch { /* ignore */ }
|
|
81
|
+
}
|
|
82
|
+
api.clearSession();
|
|
83
|
+
await api.persist();
|
|
84
|
+
info('logged out');
|
|
85
|
+
}
|
|
86
|
+
export async function status(opts) {
|
|
87
|
+
const api = await ApiClient.load();
|
|
88
|
+
let user = null;
|
|
89
|
+
try {
|
|
90
|
+
user = (await api.request('GET', '/me')).user;
|
|
91
|
+
}
|
|
92
|
+
catch { /* not logged in */ }
|
|
93
|
+
const project = await linkedProject();
|
|
94
|
+
if (opts.json)
|
|
95
|
+
return printJson({ apiUrl: api.apiUrl, user, project });
|
|
96
|
+
info(`api: ${api.apiUrl}`);
|
|
97
|
+
info(`user: ${user ? (user.email ?? user.id) : '(not logged in)'}`);
|
|
98
|
+
info(`project: ${project ? `${project.projectId} (branch ${project.branch})` : '(none linked)'}`);
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
2
|
+
import { die, info, openUrl, printJson } from '../util.js';
|
|
3
|
+
// Resolve the target org: explicit --org, else the linked project's org.
|
|
4
|
+
async function resolveOrgId(opts) {
|
|
5
|
+
if (opts.org)
|
|
6
|
+
return opts.org;
|
|
7
|
+
return (await requireProject()).orgId;
|
|
8
|
+
}
|
|
9
|
+
// insta billing — current cycle summary for the org.
|
|
10
|
+
export async function billing(opts) {
|
|
11
|
+
const api = await ApiClient.load();
|
|
12
|
+
const orgId = await resolveOrgId(opts);
|
|
13
|
+
const s = await api.request('GET', `/orgs/${orgId}/billing`);
|
|
14
|
+
if (opts.json)
|
|
15
|
+
return printJson(s);
|
|
16
|
+
info(`tier: ${s.tier}`);
|
|
17
|
+
info(`status: ${s.status}`);
|
|
18
|
+
info(`cycle: ${String(s.cycleStart).slice(0, 10)} → ${String(s.cycleEnd).slice(0, 10)}`);
|
|
19
|
+
info(`included: $${Number(s.includedUsd).toFixed(2)}`);
|
|
20
|
+
info(`used: $${Number(s.usedUsd).toFixed(4)}`);
|
|
21
|
+
info(`overage: $${Number(s.overageUsd).toFixed(4)}`);
|
|
22
|
+
if (s.status === 'suspended')
|
|
23
|
+
info('⚠ org suspended — billing limit reached; resumes next cycle (or `insta billing upgrade pro`)');
|
|
24
|
+
}
|
|
25
|
+
// insta billing upgrade <tier> — start a Stripe Checkout to subscribe the org to a paid tier.
|
|
26
|
+
export async function billingUpgrade(tier, opts) {
|
|
27
|
+
if (tier !== 'pro' && tier !== 'enterprise')
|
|
28
|
+
die('tier must be pro|enterprise');
|
|
29
|
+
const api = await ApiClient.load();
|
|
30
|
+
const orgId = await resolveOrgId(opts);
|
|
31
|
+
const { url } = await api.request('POST', `/orgs/${orgId}/billing/checkout`, { tier });
|
|
32
|
+
if (opts.json)
|
|
33
|
+
return printJson({ url });
|
|
34
|
+
presentUrl(url, `Subscribe to ${tier} — complete checkout in your browser:`, opts.open);
|
|
35
|
+
}
|
|
36
|
+
// insta billing portal — open the Stripe Customer Portal (change plan / card / cancel).
|
|
37
|
+
export async function billingPortal(opts) {
|
|
38
|
+
const api = await ApiClient.load();
|
|
39
|
+
const orgId = await resolveOrgId(opts);
|
|
40
|
+
const { url } = await api.request('POST', `/orgs/${orgId}/billing/portal`);
|
|
41
|
+
if (opts.json)
|
|
42
|
+
return printJson({ url });
|
|
43
|
+
presentUrl(url, 'Manage billing in your browser:', opts.open);
|
|
44
|
+
}
|
|
45
|
+
// Print the URL and, unless --no-open, try to open it in the browser.
|
|
46
|
+
function presentUrl(url, label, open) {
|
|
47
|
+
info(label);
|
|
48
|
+
info(` ${url}`);
|
|
49
|
+
if (open !== false && openUrl(url))
|
|
50
|
+
info('(opened in your default browser)');
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=billing.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
2
|
+
import { writeProject } from '../config.js';
|
|
3
|
+
import { info, die, printJson, handleApproval } from '../util.js';
|
|
4
|
+
export async function branchCreate(name, opts) {
|
|
5
|
+
const api = await ApiClient.load();
|
|
6
|
+
const p = await requireProject();
|
|
7
|
+
const out = await api.request('POST', `/projects/${p.projectId}/branches`, { name, from: opts.from ?? p.branch });
|
|
8
|
+
info(`created branch ${out.branch.name} (${out.branch.id})`);
|
|
9
|
+
}
|
|
10
|
+
export async function branchList(opts) {
|
|
11
|
+
const api = await ApiClient.load();
|
|
12
|
+
const p = await requireProject();
|
|
13
|
+
const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`);
|
|
14
|
+
if (opts.json)
|
|
15
|
+
return printJson(branches);
|
|
16
|
+
for (const b of branches)
|
|
17
|
+
info(`${b.is_default ? '*' : ' '} ${b.name} [${b.status}] ${b.id}`);
|
|
18
|
+
}
|
|
19
|
+
export async function branchSwitch(name) {
|
|
20
|
+
const api = await ApiClient.load();
|
|
21
|
+
const p = await requireProject();
|
|
22
|
+
const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`);
|
|
23
|
+
if (!branches.some((b) => b.name === name))
|
|
24
|
+
die(`branch not found: ${name}`);
|
|
25
|
+
await writeProject({ ...p, branch: name });
|
|
26
|
+
info(`switched to branch ${name} — run \`insta secrets\` to refresh .env`);
|
|
27
|
+
}
|
|
28
|
+
export async function branchDelete(name) {
|
|
29
|
+
const api = await ApiClient.load();
|
|
30
|
+
const p = await requireProject();
|
|
31
|
+
const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`);
|
|
32
|
+
const b = branches.find((x) => x.name === name || x.id === name);
|
|
33
|
+
if (!b)
|
|
34
|
+
die(`branch not found: ${name}`);
|
|
35
|
+
const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/branches/${b.id}`);
|
|
36
|
+
if (handleApproval(res))
|
|
37
|
+
return;
|
|
38
|
+
info(`deleted branch ${name}`);
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=branch.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
2
|
+
import { info, printJson, handleApproval } from '../util.js';
|
|
3
|
+
// Attach a developer-owned custom domain to a branch's compute service. Fly issues the cert + routes
|
|
4
|
+
// it; the platform returns the DNS records to set in your OWN zone.
|
|
5
|
+
export async function setDomain(host, opts) {
|
|
6
|
+
const api = await ApiClient.load();
|
|
7
|
+
const p = await requireProject();
|
|
8
|
+
const res = await api.rawRequest('POST', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group });
|
|
9
|
+
if (handleApproval(res))
|
|
10
|
+
return;
|
|
11
|
+
printDomain(res.body, opts.json);
|
|
12
|
+
}
|
|
13
|
+
// Re-check a custom domain's cert status + required DNS records.
|
|
14
|
+
export async function checkDomain(host, opts) {
|
|
15
|
+
const api = await ApiClient.load();
|
|
16
|
+
const p = await requireProject();
|
|
17
|
+
const qs = new URLSearchParams({ hostname: host });
|
|
18
|
+
if (opts.branch ?? p.branch)
|
|
19
|
+
qs.set('branch', opts.branch ?? p.branch);
|
|
20
|
+
if (opts.group)
|
|
21
|
+
qs.set('group', opts.group);
|
|
22
|
+
printDomain(await api.request('GET', `/projects/${p.projectId}/compute/domain?${qs}`), opts.json);
|
|
23
|
+
}
|
|
24
|
+
export async function removeDomain(host, opts) {
|
|
25
|
+
const api = await ApiClient.load();
|
|
26
|
+
const p = await requireProject();
|
|
27
|
+
const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/compute/domain`, { hostname: host, branch: opts.branch ?? p.branch, group: opts.group });
|
|
28
|
+
if (handleApproval(res))
|
|
29
|
+
return;
|
|
30
|
+
info(`removed custom domain ${res.body.hostname} from ${res.body.flyApp}`);
|
|
31
|
+
}
|
|
32
|
+
function printDomain(r, json) {
|
|
33
|
+
if (json)
|
|
34
|
+
return printJson(r);
|
|
35
|
+
info(`${r.hostname} → ${r.flyApp}`);
|
|
36
|
+
info(` status: ${r.status}${r.configured ? ' ✓ configured' : ''}`);
|
|
37
|
+
if (r.dns?.length) {
|
|
38
|
+
info(' set these DNS records at your domain registrar:');
|
|
39
|
+
for (const d of r.dns)
|
|
40
|
+
info(` ${String(d.type).padEnd(5)} ${d.name} → ${d.value}${d.note ? ` # ${d.note}` : ''}`);
|
|
41
|
+
}
|
|
42
|
+
if (!r.configured)
|
|
43
|
+
info(' once DNS propagates, Fly issues the cert — re-check with `insta compute check-domain`');
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=compute.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolve, join } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
4
|
+
import { info, die, handleApproval } from '../util.js';
|
|
5
|
+
import { flyctlBuildAndPush, ensureFlyctl } from '../flyctl-build.js';
|
|
6
|
+
// Deploy either a prebuilt image (`--image`) or a source directory (positional `<dir>`, built
|
|
7
|
+
// remotely on Fly and pushed with a short-lived platform-minted token). Exactly one mode.
|
|
8
|
+
export async function deploy(dir, opts) {
|
|
9
|
+
if (dir && opts.image)
|
|
10
|
+
die('pick one: a source <dir> OR --image <url>, not both');
|
|
11
|
+
if (!dir && !opts.image)
|
|
12
|
+
die('usage: insta deploy <dir> | --image <url> [--branch <b>] [--group <g>] [--port <n>]');
|
|
13
|
+
const api = await ApiClient.load();
|
|
14
|
+
const p = await requireProject();
|
|
15
|
+
const branch = opts.branch ?? p.branch;
|
|
16
|
+
const image = dir ? await buildFromSource(api, p.projectId, dir, branch, opts) : opts.image;
|
|
17
|
+
const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, {
|
|
18
|
+
image,
|
|
19
|
+
branch,
|
|
20
|
+
group: opts.group,
|
|
21
|
+
port: opts.port ? Number(opts.port) : undefined,
|
|
22
|
+
});
|
|
23
|
+
if (handleApproval(res))
|
|
24
|
+
return;
|
|
25
|
+
info(`deployed ${image} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
|
|
26
|
+
}
|
|
27
|
+
// Source mode: mint a scoped Fly deploy token from the platform, then build+push <dir> (needs a
|
|
28
|
+
// Dockerfile) with flyctl's remote builder, returning the pushed image ref to deploy.
|
|
29
|
+
async function buildFromSource(api, projectId, dir, branch, opts) {
|
|
30
|
+
const absDir = resolve(process.cwd(), dir);
|
|
31
|
+
if (!existsSync(join(absDir, 'Dockerfile')))
|
|
32
|
+
die(`no Dockerfile at ${join(absDir, 'Dockerfile')} — add one, or use --image <url>`);
|
|
33
|
+
await ensureFlyctl();
|
|
34
|
+
const port = opts.port ? Number(opts.port) : 8080;
|
|
35
|
+
const tok = await api.rawRequest('POST', `/projects/${projectId}/deploy-token`, { branch, group: opts.group });
|
|
36
|
+
if (handleApproval(tok))
|
|
37
|
+
die('deploy requires approval — get it approved, then re-run');
|
|
38
|
+
const { token, flyApp } = tok.body;
|
|
39
|
+
info(`building ${dir} for ${flyApp} (remote builder)…`);
|
|
40
|
+
const { imageRef } = await flyctlBuildAndPush({ dir: absDir, flyApp, imageLabel: `insta-${Date.now()}`, token, port });
|
|
41
|
+
info(` pushed ${imageRef}`);
|
|
42
|
+
return imageRef;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=deploy.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
2
|
+
import { info, printJson } from '../util.js';
|
|
3
|
+
export async function events(opts) {
|
|
4
|
+
const api = await ApiClient.load();
|
|
5
|
+
const p = await requireProject();
|
|
6
|
+
const qs = [];
|
|
7
|
+
if (opts.branch)
|
|
8
|
+
qs.push(`branch=${encodeURIComponent(opts.branch)}`);
|
|
9
|
+
if (opts.limit)
|
|
10
|
+
qs.push(`limit=${opts.limit}`);
|
|
11
|
+
const { events } = await api.request('GET', `/projects/${p.projectId}/events${qs.length ? `?${qs.join('&')}` : ''}`);
|
|
12
|
+
if (opts.json)
|
|
13
|
+
return printJson(events);
|
|
14
|
+
for (const e of events)
|
|
15
|
+
info(`${e.created_at} [${e.source}] ${e.kind}`);
|
|
16
|
+
}
|
|
17
|
+
export async function approvalsList(opts) {
|
|
18
|
+
const api = await ApiClient.load();
|
|
19
|
+
const p = await requireProject();
|
|
20
|
+
const { approvals } = await api.request('GET', `/projects/${p.projectId}/approvals${opts.status ? `?status=${opts.status}` : ''}`);
|
|
21
|
+
if (opts.json)
|
|
22
|
+
return printJson(approvals);
|
|
23
|
+
if (!approvals.length)
|
|
24
|
+
return info('(no approvals)');
|
|
25
|
+
for (const a of approvals)
|
|
26
|
+
info(`${a.id} ${a.action} [${a.status}] ${a.requested_at}`);
|
|
27
|
+
}
|
|
28
|
+
export async function approvalsApprove(id, opts) {
|
|
29
|
+
const api = await ApiClient.load();
|
|
30
|
+
const p = await requireProject();
|
|
31
|
+
const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/approve`, { always: !!opts.always });
|
|
32
|
+
info(`approved ${out.approval.action} (${id})${opts.always ? ' — policy set to allow' : ''}`);
|
|
33
|
+
}
|
|
34
|
+
export async function approvalsDeny(id) {
|
|
35
|
+
const api = await ApiClient.load();
|
|
36
|
+
const p = await requireProject();
|
|
37
|
+
const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/deny`);
|
|
38
|
+
info(`denied ${out.approval.action} (${id})`);
|
|
39
|
+
}
|
|
40
|
+
export async function policyGet(opts) {
|
|
41
|
+
const api = await ApiClient.load();
|
|
42
|
+
const p = await requireProject();
|
|
43
|
+
const { policy } = await api.request('GET', `/projects/${p.projectId}/policy`);
|
|
44
|
+
if (opts.json)
|
|
45
|
+
return printJson(policy);
|
|
46
|
+
for (const [action, decision] of Object.entries(policy))
|
|
47
|
+
info(`${action}: ${decision}`);
|
|
48
|
+
}
|
|
49
|
+
export async function policySet(action, decision) {
|
|
50
|
+
const api = await ApiClient.load();
|
|
51
|
+
const p = await requireProject();
|
|
52
|
+
await api.request('PUT', `/projects/${p.projectId}/policy/${action}`, { decision });
|
|
53
|
+
info(`policy ${action} = ${decision}`);
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=govern.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
2
|
+
import { info, printJson } from '../util.js';
|
|
3
|
+
// Agent-legible view of each environment's databases / storage / compute.
|
|
4
|
+
export async function manifest(opts) {
|
|
5
|
+
const api = await ApiClient.load();
|
|
6
|
+
const p = await requireProject();
|
|
7
|
+
const detail = await api.request('GET', `/projects/${p.projectId}`);
|
|
8
|
+
if (opts.json)
|
|
9
|
+
return printJson(detail);
|
|
10
|
+
info(`project ${detail.project.name} (${detail.project.id}) [${detail.project.status}]`);
|
|
11
|
+
for (const b of detail.branches) {
|
|
12
|
+
info(` branch ${b.name}${b.is_default ? ' *' : ''} [${b.status}]`);
|
|
13
|
+
const rs = detail.resources.filter((r) => r.branchId === b.id || (b.is_default && r.branchId === null));
|
|
14
|
+
for (const r of rs) {
|
|
15
|
+
const where = r.ref?.url ?? r.ref?.bucket ?? r.ref?.neonProjectId ?? '';
|
|
16
|
+
info(` - ${r.kind}${r.name ? `(${r.name})` : ''} ${where} [${r.status}]`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
2
|
+
import { info, printJson } from '../util.js';
|
|
3
|
+
function qs(params) {
|
|
4
|
+
const u = new URLSearchParams();
|
|
5
|
+
for (const [k, v] of Object.entries(params))
|
|
6
|
+
if (v !== undefined && v !== '')
|
|
7
|
+
u.set(k, String(v));
|
|
8
|
+
const s = u.toString();
|
|
9
|
+
return s ? `?${s}` : '';
|
|
10
|
+
}
|
|
11
|
+
// insta metrics <db|compute> [group]
|
|
12
|
+
export async function metrics(component, group, opts) {
|
|
13
|
+
const api = await ApiClient.load();
|
|
14
|
+
const p = await requireProject();
|
|
15
|
+
const res = await api.request('GET', `/projects/${p.projectId}/metrics${qs({ component, group, branch: opts.branch ?? p.branch, from: opts.from, to: opts.to, step: opts.step })}`);
|
|
16
|
+
if (opts.json)
|
|
17
|
+
return printJson(res);
|
|
18
|
+
if (res.note)
|
|
19
|
+
info(`note: ${res.note}`);
|
|
20
|
+
if (!res.series?.length)
|
|
21
|
+
return info('(no series)');
|
|
22
|
+
for (const s of res.series) {
|
|
23
|
+
const last = s.points?.[s.points.length - 1];
|
|
24
|
+
info(`${s.name}${s.unit ? ` (${s.unit})` : ''}: ${last ? last[1] : 'n/a'} [${s.points?.length ?? 0} points]`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Customer-facing name for each internal billing dimension (the platform stores RAM as `ram`).
|
|
28
|
+
const DIMENSION_LABEL = { ram: 'memory' };
|
|
29
|
+
// Window line. Defaults to the current billing cycle; `to` is the exclusive next-cycle start, so
|
|
30
|
+
// show the inclusive last day (to − 1 day) — e.g. an org created on the 5th reads "…-05 → …next-04".
|
|
31
|
+
function cycleLine(res) {
|
|
32
|
+
const day = (sec) => new Date(sec * 1000).toISOString().slice(0, 10);
|
|
33
|
+
return `billing cycle ${day(res.from)} → ${day(res.to - 86400)}`;
|
|
34
|
+
}
|
|
35
|
+
function printDimensions(dims) {
|
|
36
|
+
for (const d of dims) {
|
|
37
|
+
const label = DIMENSION_LABEL[d.dimension] ?? d.dimension;
|
|
38
|
+
const cost = d.costUsd != null ? ` ($${Number(d.costUsd).toFixed(4)})` : '';
|
|
39
|
+
info(`${label}: ${d.quantity} ${d.unit}${cost}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// insta usage — usage across the 5 billing dimensions (cpu/memory/volume/egress/storage) for the
|
|
43
|
+
// current billing cycle. Shows the whole ORG by default (with a per-project breakdown); pass --proj
|
|
44
|
+
// [id] for a single project (the linked one, or a given id). Billed dimensions, not raw fly/neon meters.
|
|
45
|
+
export async function usage(opts) {
|
|
46
|
+
const api = await ApiClient.load();
|
|
47
|
+
const p = await requireProject();
|
|
48
|
+
if (opts.proj !== undefined && opts.proj !== false) {
|
|
49
|
+
const projectId = typeof opts.proj === 'string' ? opts.proj : p.projectId;
|
|
50
|
+
const res = await api.request('GET', `/projects/${projectId}/usage${qs({ from: opts.from, to: opts.to })}`);
|
|
51
|
+
if (opts.json)
|
|
52
|
+
return printJson(res);
|
|
53
|
+
info(cycleLine(res));
|
|
54
|
+
if (!res.dimensions?.length)
|
|
55
|
+
return info('(no usage recorded)');
|
|
56
|
+
printDimensions(res.dimensions);
|
|
57
|
+
return info(`total: $${Number(res.totalCostUsd ?? 0).toFixed(4)}`);
|
|
58
|
+
}
|
|
59
|
+
// Default: the whole org (the linked project's org), with a per-project cost breakdown.
|
|
60
|
+
const res = await api.request('GET', `/orgs/${p.orgId}/usage${qs({ from: opts.from, to: opts.to })}`);
|
|
61
|
+
if (opts.json)
|
|
62
|
+
return printJson(res);
|
|
63
|
+
info(cycleLine(res));
|
|
64
|
+
if (!res.org?.dimensions?.length)
|
|
65
|
+
return info('(no usage recorded)');
|
|
66
|
+
printDimensions(res.org.dimensions);
|
|
67
|
+
info(`total: $${Number(res.org.totalCostUsd ?? 0).toFixed(4)}`);
|
|
68
|
+
if (res.projects?.length) {
|
|
69
|
+
info('by project:');
|
|
70
|
+
for (const pr of res.projects)
|
|
71
|
+
info(` ${pr.name}: $${Number(pr.totalCostUsd ?? 0).toFixed(4)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// insta logs <db|compute> [group]
|
|
75
|
+
export async function logs(component, group, opts) {
|
|
76
|
+
const api = await ApiClient.load();
|
|
77
|
+
const p = await requireProject();
|
|
78
|
+
const res = await api.request('GET', `/projects/${p.projectId}/logs${qs({ component, group, branch: opts.branch ?? p.branch, limit: opts.limit, region: opts.region, instance: opts.instance })}`);
|
|
79
|
+
if (opts.json)
|
|
80
|
+
return printJson(res);
|
|
81
|
+
if (res.note)
|
|
82
|
+
info(`note: ${res.note}`);
|
|
83
|
+
if (!res.lines?.length)
|
|
84
|
+
return info('(no logs)');
|
|
85
|
+
for (const l of res.lines)
|
|
86
|
+
info(`${l.ts} ${(l.level ?? '').padEnd(5)} ${l.message}`);
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=metrics.js.map
|