insta 0.0.22 → 0.0.24
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 +185 -83
- package/dist/commands/auth.js +25 -5
- package/dist/commands/compute.js +17 -0
- package/dist/commands/db.js +27 -0
- package/dist/commands/env.js +63 -0
- package/dist/commands/mcp.js +14 -14
- package/dist/commands/metrics.js +23 -0
- package/dist/commands/services.js +3 -0
- package/dist/commands/setup.js +35 -12
- package/dist/config.js +63 -5
- package/dist/ensure-skills.js +19 -3
- package/dist/env.js +52 -0
- package/dist/index.js +20 -2
- package/dist/observe/install.js +4 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,118 +1,220 @@
|
|
|
1
1
|
# insta-cli
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/insta)
|
|
4
|
+
[](LICENSE)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
The InstaCloud CLI. Provision Postgres, object storage and compute, fork the whole
|
|
7
|
+
environment per branch, and give your coding agent scoped credentials without pasting
|
|
8
|
+
secrets into a chat window.
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
`insta` is a thin client over the InstaCloud control-plane API. Every command is one API
|
|
11
|
+
call, so anything you can do, an agent can do.
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Native binary, no Node required (macOS / Linux / WSL). Installs to `~/.insta/bin` and
|
|
16
|
+
verifies the download against `SHA256SUMS`:
|
|
11
17
|
|
|
12
18
|
```bash
|
|
13
19
|
curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh
|
|
14
|
-
# Installs to ~/.insta/bin/insta (override with INSTA_INSTALL_DIR); verifies SHA256SUMS.
|
|
15
|
-
# Pin a version: curl -fsSL .../install.sh | INSTA_VERSION=v0.1.0 sh
|
|
16
|
-
# Windows: download insta-windows-x64.exe from the releases page.
|
|
17
20
|
```
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
From npm:
|
|
20
23
|
|
|
21
24
|
```bash
|
|
22
|
-
npm install
|
|
23
|
-
npm run build # -> dist/index.js (bin: insta; pure JS, requires node to run)
|
|
24
|
-
node dist/index.js --help
|
|
25
|
+
npm install -g insta
|
|
25
26
|
```
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
The binaries that `install.sh` installs are cross-compiled with Bun by CI (tag `v*` → `.github/workflows/release.yml`)
|
|
30
|
-
and published to GitHub releases. You can also build them locally: requires [Bun](https://bun.sh). The npm package still
|
|
31
|
-
ships JS (`dist/index.js`) — binaries are a separate distribution channel.
|
|
28
|
+
For coding agents. Installs the CLI, the `insta` skill for every agent on the machine, and
|
|
29
|
+
registers the MCP server:
|
|
32
30
|
|
|
33
31
|
```bash
|
|
34
|
-
|
|
35
|
-
npm run build:binaries # cross-compile all platforms -> dist/bin/insta-<os>-<arch>(.exe) + SHA256SUMS
|
|
36
|
-
# The version (baked into `insta --version`) defaults to package.json, or pass it: bash scripts/build-binaries.sh 1.2.3
|
|
32
|
+
curl -fsSL agents.instacloud.com | sh
|
|
37
33
|
```
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
On Windows, download `insta-windows-x64.exe` from the
|
|
36
|
+
[releases page](https://github.com/InsForge/insta-cli/releases).
|
|
37
|
+
|
|
38
|
+
Pin a version with `INSTA_VERSION=v0.0.22`; change the install directory with
|
|
39
|
+
`INSTA_INSTALL_DIR`. While the CLI is pre-1.0 it updates itself on new releases. Turn that
|
|
40
|
+
off with `insta autoupdate off`.
|
|
41
41
|
|
|
42
42
|
## Quickstart
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
|
|
46
|
-
insta
|
|
47
|
-
insta
|
|
48
|
-
insta services add
|
|
49
|
-
insta
|
|
50
|
-
insta
|
|
51
|
-
insta deploy --image <registry/img> # deploy a container image to the current branch's compute service
|
|
52
|
-
insta status # login state + linked project/branch
|
|
45
|
+
insta login --oauth github
|
|
46
|
+
insta project create my-app
|
|
47
|
+
insta services add postgres db
|
|
48
|
+
insta services add compute api
|
|
49
|
+
insta secrets
|
|
50
|
+
insta deploy .
|
|
53
51
|
```
|
|
54
52
|
|
|
55
|
-
|
|
53
|
+
`project create` makes an empty project and links the current directory. Services are
|
|
54
|
+
opt-in, so you add only what you need. `secrets` writes the current branch's credentials to
|
|
55
|
+
`./.env`. `deploy .` builds the directory remotely and ships it to the branch's compute
|
|
56
|
+
service; it needs a `Dockerfile`, but no local Docker.
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|------|------|
|
|
59
|
-
| `insta login [--email --password --api-url]` | Log in (email/password; tokens auto-refresh) |
|
|
60
|
-
| `insta login --oauth <github\|google>` | Browser OAuth login (starts a local loopback port; the token is carried back automatically after browser authorization) |
|
|
61
|
-
| `insta logout` / `insta status [--json]` | Log out / show status |
|
|
62
|
-
| `insta org list [--json]` / `org create <name>` | Organizations (each user may own only one free org) |
|
|
63
|
-
| `insta project create <name> [--org]` | Create an empty project and link it (no services by default) |
|
|
64
|
-
| `insta project list [--org] [--json]` / `link <id>` / `delete` | Project management |
|
|
65
|
-
| `insta services add <postgres\|storage\|compute> <name>` | Provision a service on demand (postgres/compute get a default access domain) |
|
|
66
|
-
| `insta services list [--json]` / `services remove <type> <name>` | List / remove services |
|
|
67
|
-
| `insta services scale compute <name> <number> [region]` | Set the compute machine count (paid tiers; rejected on free) |
|
|
68
|
-
| `insta services upgrade <compute\|postgres> <name> <spec>` | Upgrade the spec (paid tiers; upgrade only, no downgrade) |
|
|
69
|
-
| `insta branch create <name> [--from]` | Create a branch environment (materializes the project's current services; up to 10 branches per project) |
|
|
70
|
-
| `insta branch list [--json]` / `switch <name>` / `delete <name>` | Branch management |
|
|
71
|
-
| `insta secrets [--branch -o --print --json]` | Secret seam: write credentials to `.env` |
|
|
72
|
-
| `insta secrets list [--branch]` | List secret names only |
|
|
73
|
-
| `insta deploy --image <url> [--branch --group --port]` | Deploy an image |
|
|
74
|
-
| `insta manifest [--json]` | Agent-readable environment manifest |
|
|
75
|
-
| `insta metrics <db\|compute> [group] [--branch --from --to --step --json]` | Resource metrics (compute=Fly; db limited) |
|
|
76
|
-
| `insta logs <db\|compute> [group] [--branch --limit --region --instance --json]` | Runtime logs (compute=Fly; db limited) |
|
|
77
|
-
| `insta events [--branch --limit --json]` | Audit + agent event timeline |
|
|
78
|
-
| `insta usage [--from --to --json]` | Resource usage aggregated by meter (includes costUsd) |
|
|
79
|
-
| `insta billing [--org --json]` | Current billing-cycle summary (tier / quota / used / overage / status) |
|
|
80
|
-
| `insta billing upgrade <pro\|enterprise> [--org --no-open --json]` | Subscribe to a paid tier via Stripe Checkout; returns and opens the payment link |
|
|
81
|
-
| `insta billing portal [--org --no-open --json]` | Open the Stripe Customer Portal (change plan / card / cancel) |
|
|
82
|
-
| `insta approvals list [--status] [--json]` | Governance approval list |
|
|
83
|
-
| `insta approvals approve <id> [--always]` / `deny <id>` | Approve / deny (admin) |
|
|
84
|
-
| `insta policy get [--json]` / `policy set <action> <decision>` | Governance policy (actions include `service.add/remove/scale/upgrade`) |
|
|
85
|
-
|
|
86
|
-
When a governance-gated operation (`secrets.read`/`deploy`/`project.delete`/`branch.delete`/`service.add`/`service.remove`/`service.scale`/`service.upgrade`) hits an approval,
|
|
87
|
-
the CLI prompts `approval required — run: insta approvals approve <id>`.
|
|
88
|
-
|
|
89
|
-
## Configuration locations
|
|
90
|
-
|
|
91
|
-
- Global: `~/.insta/config.json` (apiUrl + access/refresh token + user)
|
|
92
|
-
- Project: `./.insta/project.json` (projectId / orgId / current branch)
|
|
93
|
-
|
|
94
|
-
## Local end-to-end run
|
|
95
|
-
|
|
96
|
-
The platform provides a `dev:fake` mode (fake provider adapters, no Neon/Fly/Tigris credentials required):
|
|
58
|
+
## Authentication
|
|
97
59
|
|
|
98
60
|
```bash
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
61
|
+
insta login --email you@example.com # password from $INSTA_PASSWORD or a prompt
|
|
62
|
+
insta login --oauth github # or google, through the browser
|
|
63
|
+
insta login --env staging --oauth github # log in to a specific deployment
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Tokens are stored in `~/.insta/config.json` and refresh automatically.
|
|
67
|
+
|
|
68
|
+
`--oauth` starts a loopback listener on `127.0.0.1`, opens the browser at the control
|
|
69
|
+
plane's `/auth/cli/authorize`, and receives the token back on that listener once the
|
|
70
|
+
provider has authorized you. Nothing is pasted by hand.
|
|
71
|
+
|
|
72
|
+
If you operate your own control plane, the provider's OAuth app needs
|
|
73
|
+
`GITHUB_OAUTH_CLIENT_ID` / `GITHUB_OAUTH_CLIENT_SECRET` (or the `GOOGLE_*` equivalents), and
|
|
74
|
+
its callback URL must be `{INSTA_API_BASE_URL}/api/auth/callback/<provider>` — the control
|
|
75
|
+
plane's address, not the CLI's loopback address.
|
|
76
|
+
|
|
77
|
+
## How it works
|
|
78
|
+
|
|
79
|
+
### Services are branch-scoped
|
|
80
|
+
|
|
81
|
+
A project holds services (`postgres`, `storage`, `compute`) and each branch owns its own
|
|
82
|
+
set. `insta branch create feature-x` forks the parent's services: a Neon branch per
|
|
83
|
+
Postgres, a copy-on-write bucket per storage, a clone of every compute service. From there
|
|
84
|
+
the two branches diverge independently. A project is capped at 10 branches.
|
|
85
|
+
|
|
86
|
+
### Credentials come from the secret seam, not a file you maintain
|
|
87
|
+
|
|
88
|
+
`insta secrets` fetches the current branch's bundle and writes `./.env`. `insta run <cmd>`
|
|
89
|
+
does the same without touching disk, injecting the bundle into the child process only.
|
|
90
|
+
Credential names are per service — `DATABASE_URL`, `BUCKET_NAME`, `AWS_ACCESS_KEY_ID` —
|
|
91
|
+
suffixed with the service name when a project has more than one service of a type.
|
|
92
|
+
|
|
93
|
+
### Destructive actions can require approval
|
|
94
|
+
|
|
95
|
+
Reading secrets, deploying, deleting a project or branch, and changing services are
|
|
96
|
+
governed by a per-project policy. Where the policy says `approve`, the command stops and
|
|
97
|
+
prints an approval id for an admin to grant with `insta approvals approve <id>`. Run
|
|
98
|
+
`insta policy get` for the live policy.
|
|
99
|
+
|
|
100
|
+
### Agents get the same surface
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
`insta manifest` prints an agent-legible view of every branch and its URLs. `insta setup
|
|
103
|
+
agent` installs the InstaCloud skill and registers the remote MCP server for the coding
|
|
104
|
+
agents on the machine.
|
|
105
|
+
|
|
106
|
+
## Environments
|
|
107
|
+
|
|
108
|
+
`prod` and `staging` are separate deployments, in different regions, with different
|
|
109
|
+
databases and different auth. A session minted by one cannot authenticate against the
|
|
110
|
+
other, so switching environments drops the stored session and you log in again.
|
|
111
|
+
|
|
112
|
+
| | `prod` (default) | `staging` |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| control plane | `api.instacloud.com` | `api.staging.instacloud.com` |
|
|
115
|
+
| MCP server | `mcp.instacloud.com/mcp` | `mcp.staging.instacloud.com/mcp` |
|
|
116
|
+
| MCP registers as | `insta-cloud` | `insta-cloud-staging` |
|
|
117
|
+
| agent skills | `InsForge/insta-skills` | `InsForge/insta-skills#devel` |
|
|
118
|
+
| CLI channel | latest stable release | newest prerelease, else stable |
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
insta env # current environment and everything derived from it
|
|
122
|
+
insta env use staging # switch; persisted to ~/.insta/config.json
|
|
105
123
|
```
|
|
106
124
|
|
|
107
|
-
|
|
125
|
+
The control plane, the MCP host and the skill source all resolve from that one switch, so a
|
|
126
|
+
machine cannot end up running staging while its agents read production's skill text. The
|
|
127
|
+
two MCP registrations use different names, so both environments can be installed side by
|
|
128
|
+
side.
|
|
129
|
+
|
|
130
|
+
To install against staging directly:
|
|
108
131
|
|
|
109
132
|
```bash
|
|
110
|
-
|
|
111
|
-
# CLI starts a local loopback port → opens the browser to /auth/cli/authorize → Better Auth runs provider authorization →
|
|
112
|
-
# the platform reads the session cookie to exchange for a bearer token → carries it back to the loopback port → CLI stores it as login state
|
|
133
|
+
curl -fsSL agents.staging.instacloud.com | sh
|
|
113
134
|
```
|
|
114
135
|
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
That host is a CloudFront cache, so after a change to the installer it can serve the
|
|
137
|
+
previous copy for up to about a day. This form is equivalent and always current:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh -s -- --agents --staging -y
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
If the environment cannot be applied — an installed CLI older than 0.0.23 has no `insta
|
|
144
|
+
env` — the installer exits non-zero and says so, rather than leaving you silently pointed
|
|
145
|
+
at production. The canonical usage is `curl … | sh && insta project create`, often run
|
|
146
|
+
unattended by an agent, and a silent fallback there would provision real production
|
|
147
|
+
infrastructure.
|
|
148
|
+
|
|
149
|
+
Resolution order, most specific first:
|
|
150
|
+
|
|
151
|
+
1. `INSTA_API_URL` — a literal URL, and the only way to reach a host no environment name
|
|
152
|
+
covers, such as a local daemon or a preview deployment. `INSTA_MCP_URL` and
|
|
153
|
+
`INSTA_SKILLS_REPO` do the same for the MCP host and the skill source.
|
|
154
|
+
2. `INSTA_ENV` — `prod` or `staging`. An unrecognised value is an error, never a fallback.
|
|
155
|
+
3. The `apiUrl` persisted in `~/.insta/config.json`.
|
|
156
|
+
4. `prod`.
|
|
157
|
+
|
|
158
|
+
Prereleases publish with `--prerelease` on GitHub and under npm's `next` tag, so a staging
|
|
159
|
+
build never reaches a production installer.
|
|
160
|
+
|
|
161
|
+
## Commands
|
|
117
162
|
|
|
118
|
-
|
|
163
|
+
`insta --help` is the authoritative list. For flags, approval gates and plan limits, see the
|
|
164
|
+
[full command reference](https://github.com/InsForge/insta-skills/blob/main/insta/cli-reference.md).
|
|
165
|
+
|
|
166
|
+
| Command | What it covers |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `insta login` · `logout` · `status` | Email/password or `--oauth github\|google`; `status` shows the environment, login and linked project/branch |
|
|
169
|
+
| `insta env` | `show` · `use <prod\|staging>` |
|
|
170
|
+
| `insta setup` | `agent` — install the skill and register MCP for every coding agent |
|
|
171
|
+
| `insta mcp` | `install` — register the remote MCP server only |
|
|
172
|
+
| `insta org` | `list` · `create` (one free org per user) |
|
|
173
|
+
| `insta project` | `create` · `list` · `link` · `delete` |
|
|
174
|
+
| `insta branch` | `create` · `list` · `switch` · `delete` · `merge` |
|
|
175
|
+
| `insta services` | `add` · `list` · `remove` · `rename` · `set-access` · `scale` · `upgrade` · `secrets` |
|
|
176
|
+
| `insta secrets` | Write `.env`, plus `list` · `set` · `unset` · `tree` |
|
|
177
|
+
| `insta run <cmd>` | Run a command with the branch bundle injected, nothing written to disk |
|
|
178
|
+
| `insta deploy [dir]` | Deploy a source directory (built remotely) or `--image <url>` |
|
|
179
|
+
| `insta compute` | `start` · `stop` · `suspend` · `status` · `set-domain` · `check-domain` · `remove-domain` |
|
|
180
|
+
| `insta regions` | Regions available for postgres and compute |
|
|
181
|
+
| `insta manifest` | Agent-legible view of every branch and its URLs |
|
|
182
|
+
| `insta metrics` · `logs` · `events` | Service metrics; runtime logs (`--deploy` for deploy events); audit timeline |
|
|
183
|
+
| `insta usage` · `billing` | Usage by billing dimension; `billing upgrade` · `billing portal` |
|
|
184
|
+
| `insta approvals` | `list` · `approve` · `deny` |
|
|
185
|
+
| `insta policy` | `get` · `set <action> <decision>` |
|
|
186
|
+
| `insta observe` | `install` · `uninstall` · `report` · `sync` — local credential audit |
|
|
187
|
+
| `insta upgrade` · `autoupdate` | Update the CLI; show or set auto-update |
|
|
188
|
+
|
|
189
|
+
## Configuration
|
|
190
|
+
|
|
191
|
+
| Location | Contents |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `~/.insta/config.json` | API URL, access and refresh tokens, user, auto-update preference |
|
|
194
|
+
| `./.insta/project.json` | Project id, org id, current branch |
|
|
195
|
+
|
|
196
|
+
| Variable | Effect |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `INSTA_API_URL` | Control-plane URL; outranks every other source |
|
|
199
|
+
| `INSTA_ENV` | `prod` or `staging` |
|
|
200
|
+
| `INSTA_MCP_URL` · `INSTA_SKILLS_REPO` | Override the MCP host and the agent-skill source |
|
|
201
|
+
| `INSTA_PROJECT_ID` · `INSTA_ORG_ID` · `INSTA_BRANCH` | Target a project, org or branch without linking |
|
|
202
|
+
| `INSTA_PASSWORD` | Password for non-interactive login |
|
|
203
|
+
| `INSTA_NO_AUTOUPDATE` | Disable self-update |
|
|
204
|
+
|
|
205
|
+
## Agent skills
|
|
206
|
+
|
|
207
|
+
The `insta` skill and its task guides live in
|
|
208
|
+
[InsForge/insta-skills](https://github.com/InsForge/insta-skills). `insta setup agent`
|
|
209
|
+
installs it user-globally for every coding agent on the machine. `insta project create` and
|
|
210
|
+
`insta project link` additionally install the stack skills (Neon Postgres, Tigris, Better
|
|
211
|
+
Auth) into the project, along with the `insta observe` credential-audit hook.
|
|
212
|
+
|
|
213
|
+
## Contributing
|
|
214
|
+
|
|
215
|
+
Dev loop, architecture, cross-compilation and the release process are in
|
|
216
|
+
[CONTRIBUTING.md](CONTRIBUTING.md). Issues and pull requests are welcome.
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
Apache 2.0. See [LICENSE](LICENSE).
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { ApiClient, linkedProject } from '../api.js';
|
|
4
|
+
import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js';
|
|
4
5
|
import { info, die, printJson, promptPassword, openUrl } from '../util.js';
|
|
6
|
+
/** --api-url and --env both set the target host; --api-url wins (more specific), matching the
|
|
7
|
+
* INSTA_API_URL > INSTA_ENV precedence in config.ts. Returns the URL to point at, or undefined
|
|
8
|
+
* to leave whatever is already resolved alone. */
|
|
9
|
+
function targetApiUrl(opts) {
|
|
10
|
+
if (opts.apiUrl)
|
|
11
|
+
return opts.apiUrl;
|
|
12
|
+
if (!opts.env)
|
|
13
|
+
return undefined;
|
|
14
|
+
const want = opts.env.trim().toLowerCase();
|
|
15
|
+
if (!isEnvName(want))
|
|
16
|
+
die(`unknown --env "${opts.env}" — expected one of: ${ENV_NAMES.join(', ')}`);
|
|
17
|
+
return ENVS[want].api;
|
|
18
|
+
}
|
|
5
19
|
export async function login(opts) {
|
|
6
20
|
if (opts.oauth)
|
|
7
21
|
return loginOauth(opts.oauth, opts);
|
|
8
22
|
const api = await ApiClient.load();
|
|
9
|
-
|
|
10
|
-
|
|
23
|
+
const target = targetApiUrl(opts);
|
|
24
|
+
if (target)
|
|
25
|
+
api.setApiUrl(target);
|
|
11
26
|
if (!opts.email)
|
|
12
27
|
die('--email is required (or use --oauth <github|google>)');
|
|
13
28
|
const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword());
|
|
@@ -22,8 +37,9 @@ export async function loginOauth(provider, opts) {
|
|
|
22
37
|
if (provider !== 'github' && provider !== 'google')
|
|
23
38
|
die('provider must be github or google');
|
|
24
39
|
const api = await ApiClient.load();
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
const target = targetApiUrl(opts);
|
|
41
|
+
if (target)
|
|
42
|
+
api.setApiUrl(target);
|
|
27
43
|
const token = await browserOauth(api.apiUrl, provider);
|
|
28
44
|
api.setSession({ accessToken: token, refreshToken: token });
|
|
29
45
|
const me = await api.request('GET', '/me');
|
|
@@ -91,8 +107,12 @@ export async function status(opts) {
|
|
|
91
107
|
}
|
|
92
108
|
catch { /* not logged in */ }
|
|
93
109
|
const project = await linkedProject();
|
|
110
|
+
// Surface the environment name alongside the URL: "api: https://api.staging.instacloud.com" is
|
|
111
|
+
// easy to skim past, and mistaking staging for prod is the mistake worth making loud.
|
|
112
|
+
const env = envForApiUrl(api.apiUrl);
|
|
94
113
|
if (opts.json)
|
|
95
|
-
return printJson({ apiUrl: api.apiUrl, user, project });
|
|
114
|
+
return printJson({ env, apiUrl: api.apiUrl, user, project });
|
|
115
|
+
info(`env: ${env ?? '(custom)'}`);
|
|
96
116
|
info(`api: ${api.apiUrl}`);
|
|
97
117
|
info(`user: ${user ? (user.email ?? user.id) : '(not logged in)'}`);
|
|
98
118
|
info(`project: ${project ? `${project.projectId} (branch ${project.branch})` : '(none linked)'}`);
|
package/dist/commands/compute.js
CHANGED
|
@@ -70,4 +70,21 @@ export async function computeStatus(serviceName, opts) {
|
|
|
70
70
|
return printJson(r);
|
|
71
71
|
info(`compute ${serviceName ?? id}: desired=${r.desiredState} live=${r.state}`);
|
|
72
72
|
}
|
|
73
|
+
// ---- always-on (opt out of scale-to-zero; all plans; billing is actual usage either way) ----
|
|
74
|
+
export async function computeAlwaysOn(mode, serviceName, opts) {
|
|
75
|
+
if (mode !== 'on' && mode !== 'off')
|
|
76
|
+
throw new Error('mode must be on|off');
|
|
77
|
+
const api = await ApiClient.load();
|
|
78
|
+
const p = await requireProject();
|
|
79
|
+
const branch = opts.branch ?? p.branch;
|
|
80
|
+
const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
|
|
81
|
+
const id = resolveComputeServiceId(services, serviceName);
|
|
82
|
+
const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/always-on`, { enabled: mode === 'on' });
|
|
83
|
+
if (handleApproval(res))
|
|
84
|
+
return;
|
|
85
|
+
if (opts.json)
|
|
86
|
+
return printJson(res.body);
|
|
87
|
+
const on = res.body.service?.always_on;
|
|
88
|
+
info(`compute ${res.body.service?.name ?? id}: always-on ${on ? 'ENABLED — machines stay warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (default)'}`);
|
|
89
|
+
}
|
|
73
90
|
//# sourceMappingURL=compute.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
2
|
+
import { info, printJson, handleApproval } from '../util.js';
|
|
3
|
+
// Toggle a postgres service between scale-to-zero (the default: instance suspends when idle,
|
|
4
|
+
// cold-starts on the next connection) and always-on (instance stays warm; idle RAM bills at
|
|
5
|
+
// actual usage). Thin wrapper over PATCH /database/settings {scaleToZero} — insta-db-backed
|
|
6
|
+
// postgres only; Neon-backed services manage their own autosuspend and the platform returns an
|
|
7
|
+
// error for them.
|
|
8
|
+
export async function dbAlwaysOn(mode, opts) {
|
|
9
|
+
if (mode !== 'on' && mode !== 'off')
|
|
10
|
+
throw new Error('mode must be on|off');
|
|
11
|
+
const api = await ApiClient.load();
|
|
12
|
+
const p = await requireProject();
|
|
13
|
+
const qs = new URLSearchParams();
|
|
14
|
+
const branch = opts.branch ?? p.branch;
|
|
15
|
+
if (branch)
|
|
16
|
+
qs.set('branch', branch);
|
|
17
|
+
if (opts.group)
|
|
18
|
+
qs.set('group', opts.group);
|
|
19
|
+
const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${qs.toString() ? `?${qs}` : ''}`, { scaleToZero: mode !== 'on' });
|
|
20
|
+
if (handleApproval(res))
|
|
21
|
+
return;
|
|
22
|
+
if (opts.json)
|
|
23
|
+
return printJson(res.body);
|
|
24
|
+
const s2z = res.body?.scaleToZero;
|
|
25
|
+
info(`postgres ${opts.group ?? 'default'}: always-on ${s2z === false ? 'ENABLED — instance stays warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (default; first connection after idle cold-starts)'}`);
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=db.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `insta env` — show or switch the deployment environment (prod | staging).
|
|
2
|
+
//
|
|
3
|
+
// This exists because the canonical install is a pipe: `curl -fsSL agents.staging.instacloud.com | sh`.
|
|
4
|
+
// A piped script cannot export anything into the parent shell, so the staging one-liner has no way
|
|
5
|
+
// to make `INSTA_ENV=staging` stick for the `insta project create` the user runs next. Persisting
|
|
6
|
+
// the choice into ~/.insta/config.json is the only mechanism that survives the pipe — and it is the
|
|
7
|
+
// same file `login --api-url` already writes, so this adds a surface, not a concept.
|
|
8
|
+
import { readPersistedGlobal, resolveEnv, writeGlobal } from '../config.js';
|
|
9
|
+
import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, isEnvName, mcpServerName, normalizeUrl } from '../env.js';
|
|
10
|
+
import { die, info, printJson } from '../util.js';
|
|
11
|
+
export async function envShow(opts) {
|
|
12
|
+
const { apiUrl, env, mcpUrl, skills } = await resolveEnv();
|
|
13
|
+
const mcpServer = mcpServerName(env ?? DEFAULT_ENV);
|
|
14
|
+
if (opts.json)
|
|
15
|
+
return printJson({ env, apiUrl, mcpUrl, mcpServer, skills });
|
|
16
|
+
info(`env: ${env ?? '(custom)'}`);
|
|
17
|
+
info(`api: ${apiUrl}`);
|
|
18
|
+
info(`mcp: ${mcpUrl} (${mcpServer})`);
|
|
19
|
+
info(`skills: ${skills}`);
|
|
20
|
+
if (!env)
|
|
21
|
+
info(' (custom apiUrl — `insta env use <name>` to switch to a named environment)');
|
|
22
|
+
}
|
|
23
|
+
export async function envUse(name) {
|
|
24
|
+
const want = name.trim().toLowerCase();
|
|
25
|
+
if (!isEnvName(want))
|
|
26
|
+
die(`unknown environment "${name}" — expected one of: ${ENV_NAMES.join(', ')}`);
|
|
27
|
+
const target = want;
|
|
28
|
+
const nextApi = ENVS[target].api;
|
|
29
|
+
// The PERSISTED config, deliberately not the override-resolved view — see readPersistedGlobal.
|
|
30
|
+
const stored = await readPersistedGlobal();
|
|
31
|
+
const from = envForApiUrl(stored.apiUrl);
|
|
32
|
+
// Compare normalised, so a stored trailing slash is recognised as the same environment (which is
|
|
33
|
+
// how envForApiUrl already treats it) instead of being rewritten as a "switch" that needlessly
|
|
34
|
+
// drops a perfectly good session.
|
|
35
|
+
if (normalizeUrl(stored.apiUrl) === normalizeUrl(nextApi)) {
|
|
36
|
+
info(`already on ${target} (${nextApi})`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// A real switch, so drop the stored session unconditionally. prod and staging are separate
|
|
40
|
+
// deployments: the old token cannot authenticate here, and keeping it is actively unsafe because
|
|
41
|
+
// api.ts's 401 path POSTs the refresh token to whatever apiUrl now resolves to, handing one
|
|
42
|
+
// deployment's credential to another. Same reasoning as the retired-host path in config.ts.
|
|
43
|
+
//
|
|
44
|
+
// "Any field" rather than accessToken alone: a config holding only a refreshToken (an interrupted
|
|
45
|
+
// login, a hand-edited file) would otherwise keep that token and post it to the new host.
|
|
46
|
+
const hadSession = !!(stored.accessToken || stored.refreshToken || stored.user);
|
|
47
|
+
const next = { ...stored, apiUrl: nextApi };
|
|
48
|
+
delete next.accessToken;
|
|
49
|
+
delete next.refreshToken;
|
|
50
|
+
delete next.user;
|
|
51
|
+
await writeGlobal(next);
|
|
52
|
+
info(`switched ${from ?? '(custom)'} → ${target}`);
|
|
53
|
+
info(` api: ${nextApi}`);
|
|
54
|
+
info(` mcp: ${ENVS[target].mcp} (registers as \`${mcpServerName(target)}\`)`);
|
|
55
|
+
if (hadSession)
|
|
56
|
+
info(' previous session dropped (separate deployment) — run `insta login --oauth github`');
|
|
57
|
+
// Switching the CLI does NOT re-point already-installed agents: their MCP registration and skill
|
|
58
|
+
// files were written for the previous environment and are keyed by a different server name, so
|
|
59
|
+
// they keep talking to it until setup is re-run. (The installer path is fine — install.sh runs
|
|
60
|
+
// `env use` before `setup agent`.)
|
|
61
|
+
info(' re-point this machine\'s agents at it with: insta setup agent');
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=env.js.map
|
package/dist/commands/mcp.js
CHANGED
|
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
|
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { info } from '../util.js';
|
|
11
|
-
import {
|
|
11
|
+
import { MCP_SERVER_NAME, registerMcp, resolveMcpTarget } from './setup.js';
|
|
12
12
|
export const MCP_AGENT_TARGETS = ['cursor', 'codex', 'opencode', 'copilot', 'factory-droid'];
|
|
13
13
|
export function configPath(slug, home) {
|
|
14
14
|
switch (slug) {
|
|
@@ -26,7 +26,7 @@ export function detectAgents(home) {
|
|
|
26
26
|
}
|
|
27
27
|
// Merge our entry into existing JSON config. Returns null (skip, leave file alone) when the
|
|
28
28
|
// existing content isn't valid JSON — never clobber a config we can't parse.
|
|
29
|
-
export function renderJsonConfig(slug, existing, url) {
|
|
29
|
+
export function renderJsonConfig(slug, existing, url, name = MCP_SERVER_NAME) {
|
|
30
30
|
let root = {};
|
|
31
31
|
if (existing && existing.trim()) {
|
|
32
32
|
try {
|
|
@@ -40,28 +40,28 @@ export function renderJsonConfig(slug, existing, url) {
|
|
|
40
40
|
}
|
|
41
41
|
if (slug === 'opencode') {
|
|
42
42
|
// OpenCode: `mcp` key, `type: "remote"` schema (docs.opencode.ai).
|
|
43
|
-
root.mcp = { ...(root.mcp ?? {}), [
|
|
43
|
+
root.mcp = { ...(root.mcp ?? {}), [name]: { type: 'remote', url, enabled: true } };
|
|
44
44
|
root.$schema ??= 'https://opencode.ai/config.json';
|
|
45
45
|
}
|
|
46
46
|
else {
|
|
47
47
|
const entry = slug === 'cursor' ? { url } // Cursor auto-detects HTTP from `url`
|
|
48
48
|
: slug === 'copilot' ? { type: 'http', url, tools: ['*'] }
|
|
49
49
|
: { type: 'http', url, disabled: false }; // factory-droid
|
|
50
|
-
root.mcpServers = { ...(root.mcpServers ?? {}), [
|
|
50
|
+
root.mcpServers = { ...(root.mcpServers ?? {}), [name]: entry };
|
|
51
51
|
}
|
|
52
52
|
return JSON.stringify(root, null, 2) + '\n';
|
|
53
53
|
}
|
|
54
54
|
// Codex config is TOML. Appending a complete `[mcp_servers.<name>]` table is always valid at
|
|
55
55
|
// EOF, so we avoid a TOML parser: string-detect for idempotency, append for install.
|
|
56
|
-
export function renderCodexConfig(existing, url) {
|
|
56
|
+
export function renderCodexConfig(existing, url, name = MCP_SERVER_NAME) {
|
|
57
57
|
const base = existing ?? '';
|
|
58
|
-
if (base.includes(`[mcp_servers.${
|
|
58
|
+
if (base.includes(`[mcp_servers.${name}]`))
|
|
59
59
|
return null; // already configured
|
|
60
60
|
const sep = base.length && !base.endsWith('\n') ? '\n' : '';
|
|
61
|
-
return `${base}${sep}\n[mcp_servers.${
|
|
61
|
+
return `${base}${sep}\n[mcp_servers.${name}]\nurl = "${url}"\n`;
|
|
62
62
|
}
|
|
63
63
|
// Install for one agent. Returns 'installed' | 'already' | 'skipped' (unparseable config).
|
|
64
|
-
export async function installFor(slug, home, url) {
|
|
64
|
+
export async function installFor(slug, home, url, name = MCP_SERVER_NAME) {
|
|
65
65
|
const file = configPath(slug, home);
|
|
66
66
|
let existing = null;
|
|
67
67
|
try {
|
|
@@ -71,7 +71,7 @@ export async function installFor(slug, home, url) {
|
|
|
71
71
|
existing = null;
|
|
72
72
|
}
|
|
73
73
|
if (slug === 'codex') {
|
|
74
|
-
const next = renderCodexConfig(existing, url);
|
|
74
|
+
const next = renderCodexConfig(existing, url, name);
|
|
75
75
|
if (next === null)
|
|
76
76
|
return 'already';
|
|
77
77
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
@@ -81,13 +81,13 @@ export async function installFor(slug, home, url) {
|
|
|
81
81
|
if (existing) {
|
|
82
82
|
try {
|
|
83
83
|
const root = JSON.parse(existing);
|
|
84
|
-
const entry = slug === 'opencode' ? root?.mcp?.[
|
|
84
|
+
const entry = slug === 'opencode' ? root?.mcp?.[name] : root?.mcpServers?.[name];
|
|
85
85
|
if (entry)
|
|
86
86
|
return 'already';
|
|
87
87
|
}
|
|
88
88
|
catch { /* fall through to renderJsonConfig, which refuses to clobber */ }
|
|
89
89
|
}
|
|
90
|
-
const next = renderJsonConfig(slug, existing, url);
|
|
90
|
+
const next = renderJsonConfig(slug, existing, url, name);
|
|
91
91
|
if (next === null)
|
|
92
92
|
return 'skipped';
|
|
93
93
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
@@ -100,7 +100,7 @@ const AGENT_LABELS = {
|
|
|
100
100
|
// Configure every detected config-file agent (or one forced via `agent`). Returns the labels of
|
|
101
101
|
// agents now configured (installed or already present) for the caller's summary line.
|
|
102
102
|
export async function installAgentConfigs(agent, home = os.homedir()) {
|
|
103
|
-
const url =
|
|
103
|
+
const { name, url } = await resolveMcpTarget();
|
|
104
104
|
const targets = agent
|
|
105
105
|
? MCP_AGENT_TARGETS.includes(agent) ? [agent] : []
|
|
106
106
|
: detectAgents(home);
|
|
@@ -110,9 +110,9 @@ export async function installAgentConfigs(agent, home = os.homedir()) {
|
|
|
110
110
|
}
|
|
111
111
|
const done = [];
|
|
112
112
|
for (const slug of targets) {
|
|
113
|
-
const result = await installFor(slug, home, url);
|
|
113
|
+
const result = await installFor(slug, home, url, name);
|
|
114
114
|
if (result === 'skipped')
|
|
115
|
-
info(` ${AGENT_LABELS[slug]}: existing config at ${configPath(slug, home)} isn't valid JSON — add ${
|
|
115
|
+
info(` ${AGENT_LABELS[slug]}: existing config at ${configPath(slug, home)} isn't valid JSON — add ${name} manually`);
|
|
116
116
|
else
|
|
117
117
|
done.push(AGENT_LABELS[slug]);
|
|
118
118
|
}
|
package/dist/commands/metrics.js
CHANGED
|
@@ -77,10 +77,33 @@ export async function usage(opts) {
|
|
|
77
77
|
info(` ${pr.name}: $${Number(pr.totalCostUsd ?? 0).toFixed(4)}`);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
+
// pure: platform path for a compute deploy-events request (used by `insta logs --deploy`).
|
|
81
|
+
export function deployEventsPath(projectId, opts) {
|
|
82
|
+
return `/projects/${projectId}/deploy-events${qs({ group: opts.group, branch: opts.branch, limit: opts.limit, instance: opts.instance })}`;
|
|
83
|
+
}
|
|
84
|
+
// pure: render one deploy event as a log-style line.
|
|
85
|
+
export function deployEventLine(ev) {
|
|
86
|
+
const inst = ev.instance ? ` (${ev.instance})` : '';
|
|
87
|
+
return `${ev.ts ?? ''} [${ev.origin ?? ''}] ${ev.type ?? ''}: ${ev.status ?? ''}${inst}`;
|
|
88
|
+
}
|
|
80
89
|
// insta logs <db|compute> [group]
|
|
81
90
|
export async function logs(component, group, opts) {
|
|
82
91
|
const api = await ApiClient.load();
|
|
83
92
|
const p = await requireProject();
|
|
93
|
+
if (opts.deploy) {
|
|
94
|
+
if (component !== 'compute')
|
|
95
|
+
return info('deploy events are only available for compute');
|
|
96
|
+
const res = await api.request('GET', deployEventsPath(p.projectId, { group, branch: opts.branch ?? p.branch, limit: opts.limit, instance: opts.instance }));
|
|
97
|
+
if (opts.json)
|
|
98
|
+
return printJson(res);
|
|
99
|
+
if (res.note)
|
|
100
|
+
info(`note: ${res.note}`);
|
|
101
|
+
if (!res.events?.length)
|
|
102
|
+
return info('(no deploy events)');
|
|
103
|
+
for (const ev of res.events)
|
|
104
|
+
info(deployEventLine(ev));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
84
107
|
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 })}`);
|
|
85
108
|
if (opts.json)
|
|
86
109
|
return printJson(res);
|
|
@@ -53,6 +53,7 @@ export function servicesAddRequestBody(type, name, branch, opts) {
|
|
|
53
53
|
type, name, ...(branch ? { branch } : {}), public: !!opts.public,
|
|
54
54
|
...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: Number(opts.port) } : {}),
|
|
55
55
|
...(opts.region ? { region: opts.region } : {}),
|
|
56
|
+
...(opts.alwaysOn ? { alwaysOn: true } : {}),
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
export async function servicesAdd(type, name, opts = {}) {
|
|
@@ -65,6 +66,8 @@ export async function servicesAdd(type, name, opts = {}) {
|
|
|
65
66
|
throw new Error('--image is only valid for compute services');
|
|
66
67
|
if (opts.port && type !== 'compute')
|
|
67
68
|
throw new Error('--port is only valid for compute services');
|
|
69
|
+
if (opts.alwaysOn && type !== 'compute')
|
|
70
|
+
throw new Error('--always-on is only valid for compute services (for postgres, use `insta db always-on on` after creation)');
|
|
68
71
|
const api = await ApiClient.load();
|
|
69
72
|
const p = await requireProject();
|
|
70
73
|
const branch = opts.branch ?? p.branch;
|
package/dist/commands/setup.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import { ApiClient } from '../api.js';
|
|
11
|
+
import { resolveEnv } from '../config.js';
|
|
12
|
+
import { DEFAULT_ENV, ENVS, mcpServerName } from '../env.js';
|
|
11
13
|
import { info } from '../util.js';
|
|
12
14
|
import { installAgentConfigs } from './mcp.js';
|
|
13
15
|
// The `skills` tool we shell out to prints a clack UI: a frame-by-frame clone spinner, an
|
|
@@ -91,10 +93,25 @@ const defaultRunner = (cmd, args) => new Promise((resolve) => {
|
|
|
91
93
|
});
|
|
92
94
|
// -g = user-level (machine-global); -a '*' = every agent dir the skills tool supports
|
|
93
95
|
// (Claude Code, Codex, Cursor, OpenCode, Copilot, …); --copy = real files, not cache symlinks.
|
|
94
|
-
|
|
96
|
+
// `spec` is the skill source for the resolved environment (`owner/repo` or `owner/repo@ref`), so a
|
|
97
|
+
// staging install reads the staging skill text rather than what's published on main.
|
|
98
|
+
export const setupArgs = (spec) => ['skills', 'add', spec, '-s', 'insta', '-a', '*', '-g', '-y', '--copy'];
|
|
99
|
+
/** Production's args. Kept as a named export because it is the installed-base default and is
|
|
100
|
+
* asserted directly by tests; runtime goes through `setupArgs(resolveEnv().skills)`. */
|
|
101
|
+
export const SETUP_ARGS = setupArgs(ENVS[DEFAULT_ENV].skills);
|
|
95
102
|
// ---- remote MCP registration ----
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
// Prod's name/URL, kept as named exports because they are the installed-base defaults and are
|
|
104
|
+
// asserted directly by tests. Everything at runtime goes through `resolveMcpTarget()` instead, so
|
|
105
|
+
// a staging install registers staging's MCP server under its own name rather than reusing prod's.
|
|
106
|
+
export const MCP_SERVER_NAME = mcpServerName(DEFAULT_ENV);
|
|
107
|
+
export const DEFAULT_MCP_URL = ENVS[DEFAULT_ENV].mcp;
|
|
108
|
+
/** The MCP server this machine should register, derived from the SAME resolved environment as the
|
|
109
|
+
* control-plane API. Returning name and url together is deliberate: they must never be chosen
|
|
110
|
+
* independently, or a staging machine ends up registering prod's URL under prod's name. */
|
|
111
|
+
export async function resolveMcpTarget() {
|
|
112
|
+
const { env, mcpUrl } = await resolveEnv();
|
|
113
|
+
return { name: mcpServerName(env ?? DEFAULT_ENV), url: mcpUrl };
|
|
114
|
+
}
|
|
98
115
|
const defaultMinter = async () => {
|
|
99
116
|
try {
|
|
100
117
|
const api = await ApiClient.load();
|
|
@@ -115,14 +132,14 @@ const defaultMinter = async () => {
|
|
|
115
132
|
// Idempotent — an existing registration is left alone. Best-effort: the skill install is the
|
|
116
133
|
// primary outcome; agents without an MCP registry are covered by the skill alone.
|
|
117
134
|
export async function registerMcp(run = defaultRunner, mint = defaultMinter, useToken = false) {
|
|
118
|
-
const url =
|
|
135
|
+
const { name, url } = await resolveMcpTarget();
|
|
119
136
|
if (!(await run('claude', ['--version'])).ok)
|
|
120
137
|
return; // no Claude Code on this machine
|
|
121
|
-
if ((await run('claude', ['mcp', 'get',
|
|
122
|
-
info(`✓ MCP — ${
|
|
138
|
+
if ((await run('claude', ['mcp', 'get', name])).ok) {
|
|
139
|
+
info(`✓ MCP — ${name} already registered with Claude Code`);
|
|
123
140
|
return;
|
|
124
141
|
}
|
|
125
|
-
const args = ['mcp', 'add', '--transport', 'http', '--scope', 'user',
|
|
142
|
+
const args = ['mcp', 'add', '--transport', 'http', '--scope', 'user', name, url];
|
|
126
143
|
if (useToken) {
|
|
127
144
|
const token = await mint();
|
|
128
145
|
if (!token) {
|
|
@@ -133,23 +150,29 @@ export async function registerMcp(run = defaultRunner, mint = defaultMinter, use
|
|
|
133
150
|
}
|
|
134
151
|
const res = await run('claude', args);
|
|
135
152
|
if (res.ok) {
|
|
136
|
-
info(`✓ MCP — ${
|
|
153
|
+
info(`✓ MCP — ${name} registered with Claude Code (\`claude mcp list\` to verify)`);
|
|
137
154
|
if (!useToken)
|
|
138
155
|
info(' first use: run `/mcp` in Claude Code and authorize in the browser (headless machines: `insta setup agent --mcp-token`)');
|
|
139
156
|
}
|
|
140
157
|
else {
|
|
141
|
-
info(` MCP registration failed — add manually:\n claude mcp add --transport http ${
|
|
158
|
+
info(` MCP registration failed — add manually:\n claude mcp add --transport http ${name} ${url}`);
|
|
142
159
|
}
|
|
143
160
|
}
|
|
144
161
|
export async function setupAgent(opts, run = defaultRunner, mint, installConfigs = installAgentConfigs) {
|
|
145
162
|
if (!opts.yes && !process.stdout.isTTY) {
|
|
146
163
|
info('non-interactive shell — assuming -y');
|
|
147
164
|
}
|
|
148
|
-
|
|
149
|
-
|
|
165
|
+
// One resolve for the whole step, so the skills and the MCP registration below cannot disagree
|
|
166
|
+
// about which environment this machine belongs to.
|
|
167
|
+
const { env, skills } = await resolveEnv();
|
|
168
|
+
const args = setupArgs(skills);
|
|
169
|
+
info(env && env !== DEFAULT_ENV
|
|
170
|
+
? `setting up coding-agent skills (${env}) …`
|
|
171
|
+
: 'setting up coding-agent skills …');
|
|
172
|
+
const res = await run('npx', args);
|
|
150
173
|
if (!res.ok) {
|
|
151
174
|
info(' skill install failed — install manually with:');
|
|
152
|
-
info(
|
|
175
|
+
info(` npx ${args.map((a) => (a === '*' ? '"*"' : a)).join(' ')}`);
|
|
153
176
|
// Surface the REAL error: the captured tail, minus the expected no-global-support noise.
|
|
154
177
|
const tail = (res.output ?? '')
|
|
155
178
|
.split('\n')
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { DEFAULT_ENV, ENVS, envForApiUrl, envFromEnvVar, normalizeUrl } from './env.js';
|
|
5
6
|
const GLOBAL_DIR = join(homedir(), '.insta');
|
|
6
7
|
const GLOBAL_FILE = join(GLOBAL_DIR, 'config.json');
|
|
7
8
|
const PROJECT_DIR = '.insta';
|
|
@@ -9,17 +10,74 @@ const PROJECT_FILE = 'project.json';
|
|
|
9
10
|
// The cloud API default. Uses the instacloud.com brand domain (matches the agents.instacloud.com
|
|
10
11
|
// onboarding), NOT the legacy beta-api.insta.insforge.dev host — same backend, branded domain.
|
|
11
12
|
// Only affects fresh installs: a persisted apiUrl (from a prior login) or INSTA_API_URL wins below.
|
|
12
|
-
const DEFAULT_API =
|
|
13
|
+
const DEFAULT_API = ENVS[DEFAULT_ENV].api;
|
|
13
14
|
export async function readGlobal() {
|
|
14
|
-
//
|
|
15
|
-
//
|
|
15
|
+
// Precedence, most explicit first:
|
|
16
|
+
// 1. INSTA_API_URL — a literal URL. Overrides the persisted apiUrl, not just the default,
|
|
17
|
+
// otherwise the env var is silently ignored as soon as any login has written a config file.
|
|
18
|
+
// It also outranks INSTA_ENV: a hand-written URL is the more specific instruction, and it
|
|
19
|
+
// is the only way to reach a host no environment name covers (insta-oss, a preview).
|
|
20
|
+
// 2. INSTA_ENV — a named environment (see env.ts), resolved to its api host.
|
|
21
|
+
// 3. the persisted apiUrl, written by `insta login --env|--api-url` or `insta env use`.
|
|
22
|
+
// 4. DEFAULT_API.
|
|
16
23
|
const envApi = process.env.INSTA_API_URL;
|
|
24
|
+
const named = envFromEnvVar();
|
|
25
|
+
const override = envApi ?? (named ? ENVS[named].api : undefined);
|
|
17
26
|
try {
|
|
18
27
|
const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8'));
|
|
19
|
-
|
|
28
|
+
const persisted = parsed.apiUrl ?? DEFAULT_API;
|
|
29
|
+
// An override that points at a DIFFERENT deployment than the stored session was minted for
|
|
30
|
+
// must not carry that session along. `env use` already drops it on an explicit switch; without
|
|
31
|
+
// this, `INSTA_ENV=staging insta …` on a prod-logged-in machine sends prod's bearer to staging
|
|
32
|
+
// and then — on the 401 — POSTs prod's REFRESH token to staging's /auth/refresh (api.ts), which
|
|
33
|
+
// is the cross-deployment credential leak env.ts's header calls out as never allowed.
|
|
34
|
+
//
|
|
35
|
+
// In-memory only: the file keeps the real login, so unsetting the override restores it. A
|
|
36
|
+
// custom host (insta-oss, a preview) is treated the same way — its session is equally foreign.
|
|
37
|
+
if (override && normalizeUrl(override) !== normalizeUrl(persisted)) {
|
|
38
|
+
const scrubbed = { ...parsed, apiUrl: override };
|
|
39
|
+
delete scrubbed.accessToken;
|
|
40
|
+
delete scrubbed.refreshToken;
|
|
41
|
+
delete scrubbed.user;
|
|
42
|
+
return scrubbed;
|
|
43
|
+
}
|
|
44
|
+
return { ...parsed, apiUrl: override ?? persisted };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { apiUrl: override ?? DEFAULT_API };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** The environment the CLI is currently pointed at, plus everything derived from it. `env` is null
|
|
51
|
+
* when apiUrl is a custom host (insta-oss, a preview deployment) — deliberate, and left alone.
|
|
52
|
+
*
|
|
53
|
+
* API host, MCP host, and skill source are resolved from ONE environment on purpose: the failure
|
|
54
|
+
* mode of picking them independently is silent (a machine whose CLI talks to staging while its
|
|
55
|
+
* agents are wired to prod and reading prod's skill text). */
|
|
56
|
+
export async function resolveEnv() {
|
|
57
|
+
const { apiUrl } = await readGlobal();
|
|
58
|
+
const env = envForApiUrl(apiUrl);
|
|
59
|
+
const hosts = ENVS[env ?? DEFAULT_ENV];
|
|
60
|
+
// Each single-purpose env var still wins outright, for a self-hosted MCP / a tunnel / a skills
|
|
61
|
+
// fork. A custom apiUrl with none of them set falls back to the default environment, since
|
|
62
|
+
// there is nothing better to guess and it preserves today's behaviour.
|
|
63
|
+
const mcpUrl = process.env.INSTA_MCP_URL || hosts.mcp;
|
|
64
|
+
const skills = process.env.INSTA_SKILLS_REPO || hosts.skills;
|
|
65
|
+
return { apiUrl, env, mcpUrl, skills };
|
|
66
|
+
}
|
|
67
|
+
/** The config exactly as stored: no env-var overrides, no session scrubbing.
|
|
68
|
+
*
|
|
69
|
+
* `env use` must read this rather than `readGlobal()`. With INSTA_ENV set, `readGlobal()` already
|
|
70
|
+
* reports the override's host, so `env use <that same env>` would look like a no-op, print
|
|
71
|
+
* "already on X" and never write the file — leaving the next process (without the override in its
|
|
72
|
+
* environment) still pointed at the old one. Deciding "is this a real switch?" has to be done
|
|
73
|
+
* against what is persisted. */
|
|
74
|
+
export async function readPersistedGlobal() {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8'));
|
|
77
|
+
return { ...parsed, apiUrl: parsed.apiUrl ?? DEFAULT_API };
|
|
20
78
|
}
|
|
21
79
|
catch {
|
|
22
|
-
return { apiUrl:
|
|
80
|
+
return { apiUrl: DEFAULT_API };
|
|
23
81
|
}
|
|
24
82
|
}
|
|
25
83
|
export async function writeGlobal(c) {
|
package/dist/ensure-skills.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
+
import { resolveEnv } from './config.js';
|
|
11
|
+
import { DEFAULT_ENV, ENVS } from './env.js';
|
|
10
12
|
// Where `npx skills add` drops skills for the agents we pin below: Claude Code → .claude/skills/,
|
|
11
13
|
// Codex → .agents/skills/ (.github/skills/ is the third well-known dir). These are regenerable
|
|
12
14
|
// agent context, not the developer's source — keep them out of git.
|
|
@@ -28,8 +30,11 @@ const defaultRunner = (cmd, args, inherit = false) => new Promise((resolve) => {
|
|
|
28
30
|
// name the exact skills (-s …) so there's no skill picker; -y to skip the scope/confirm prompt;
|
|
29
31
|
// --copy to write real files (not symlinks into a transient npx cache).
|
|
30
32
|
const AGENT_FLAGS = ['-a', 'claude-code', '-a', 'codex', '-y', '--copy'];
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
// `instaSpec` is the insta skill source for the resolved environment (`owner/repo[@ref]`), so a
|
|
34
|
+
// project created against staging gets the staging skill text. The third-party stack skills are
|
|
35
|
+
// environment-independent — they document Neon/Tigris/Better Auth, not our control plane.
|
|
36
|
+
const skillTargets = (instaSpec) => [
|
|
37
|
+
{ label: 'insta', args: ['skills', 'add', instaSpec, '-s', 'insta', ...AGENT_FLAGS] },
|
|
33
38
|
{ label: 'neon-postgres', args: ['skills', 'add', 'neondatabase/agent-skills', '-s', 'neon-postgres', ...AGENT_FLAGS] },
|
|
34
39
|
{ label: 'tigris', args: ['skills', 'add', 'tigrisdata/skills',
|
|
35
40
|
'-s', 'tigris-object-operations', '-s', 'file-storage', '-s', 'tigris-sdk-guide',
|
|
@@ -40,6 +45,8 @@ const SKILLS = [
|
|
|
40
45
|
'-s', 'better-auth-best-practices', '-s', 'email-and-password-best-practices',
|
|
41
46
|
'-s', 'better-auth-security-best-practices', ...AGENT_FLAGS] },
|
|
42
47
|
];
|
|
48
|
+
/** Production's targets — kept for tests and as the fallback when no env resolves. */
|
|
49
|
+
const SKILLS = skillTargets(ENVS[DEFAULT_ENV].skills);
|
|
43
50
|
// Install all related skills. Production omits `run`/`print` → the real spawn + stdout; tests inject
|
|
44
51
|
// a fake runner and capture output. Continues past a per-skill failure so one bad repo doesn't skip
|
|
45
52
|
// the rest, and never throws.
|
|
@@ -47,8 +54,17 @@ export async function installSkills(deps) {
|
|
|
47
54
|
const run = deps.run ?? defaultRunner;
|
|
48
55
|
const print = deps.print ?? ((s) => process.stdout.write(s + '\n'));
|
|
49
56
|
try {
|
|
57
|
+
// Resolve once per call so the insta skill follows this machine's environment. Falls back to
|
|
58
|
+
// production's targets if anything about the resolve fails — a bad read must not skip the
|
|
59
|
+
// whole best-effort install.
|
|
60
|
+
let targets = SKILLS;
|
|
61
|
+
try {
|
|
62
|
+
const { skills } = await resolveEnv();
|
|
63
|
+
targets = skillTargets(skills);
|
|
64
|
+
}
|
|
65
|
+
catch { /* keep production defaults */ }
|
|
50
66
|
print(' installing related agent skills (insta, neon-postgres, tigris, better-auth) …');
|
|
51
|
-
for (const s of
|
|
67
|
+
for (const s of targets) {
|
|
52
68
|
// Don't stream: the `skills` tool's clack UI (clone spinner, banners) is noise. Run it
|
|
53
69
|
// silent (stdio 'ignore') and let the per-skill ✓/failed line below be the clean output —
|
|
54
70
|
// it appears as each skill finishes, so there's still live progress. (Also avoids the
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const ENVS = {
|
|
2
|
+
prod: {
|
|
3
|
+
api: 'https://api.instacloud.com',
|
|
4
|
+
mcp: 'https://mcp.instacloud.com/mcp',
|
|
5
|
+
skills: 'InsForge/insta-skills',
|
|
6
|
+
},
|
|
7
|
+
staging: {
|
|
8
|
+
api: 'https://api.staging.instacloud.com',
|
|
9
|
+
mcp: 'https://mcp.staging.instacloud.com/mcp',
|
|
10
|
+
skills: 'InsForge/insta-skills#devel',
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
export const DEFAULT_ENV = 'prod';
|
|
14
|
+
export const ENV_NAMES = Object.keys(ENVS);
|
|
15
|
+
export function isEnvName(v) {
|
|
16
|
+
return ENV_NAMES.includes(v);
|
|
17
|
+
}
|
|
18
|
+
/** Strip trailing slashes so 'https://x/' and 'https://x' compare equal. */
|
|
19
|
+
export const normalizeUrl = (url) => url.replace(/\/+$/, '');
|
|
20
|
+
/** The environment a persisted apiUrl belongs to, or null for a custom/self-hosted host
|
|
21
|
+
* (insta-oss on localhost, a preview deployment). null is not an error — it means "the user
|
|
22
|
+
* chose this URL deliberately", and callers must leave such a choice alone. */
|
|
23
|
+
export function envForApiUrl(apiUrl) {
|
|
24
|
+
const want = normalizeUrl(apiUrl);
|
|
25
|
+
return ENV_NAMES.find((n) => normalizeUrl(ENVS[n].api) === want) ?? null;
|
|
26
|
+
}
|
|
27
|
+
/** $INSTA_ENV, validated. An unrecognised value throws rather than falling back to prod: a
|
|
28
|
+
* typo'd `INSTA_ENV=stagng` that silently provisions real infrastructure in production is the
|
|
29
|
+
* worst possible outcome, and it would be invisible until the bill arrived. */
|
|
30
|
+
export function envFromEnvVar(raw = process.env.INSTA_ENV) {
|
|
31
|
+
if (raw === undefined)
|
|
32
|
+
return null;
|
|
33
|
+
const v = raw.trim().toLowerCase();
|
|
34
|
+
if (v === '')
|
|
35
|
+
return null;
|
|
36
|
+
if (!isEnvName(v)) {
|
|
37
|
+
throw new Error(`unknown INSTA_ENV "${raw}" — expected one of: ${ENV_NAMES.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
return v;
|
|
40
|
+
}
|
|
41
|
+
/** MCP registration name for an environment.
|
|
42
|
+
*
|
|
43
|
+
* Prod keeps the bare `insta-cloud` name — it is the installed base, and renaming it would
|
|
44
|
+
* orphan every existing registration. Other environments get a suffix so they can coexist on
|
|
45
|
+
* one machine. This matters more than it looks: `registerMcp` treats an already-registered
|
|
46
|
+
* name as "nothing to do", and the config-file agents key their entry by name, so sharing one
|
|
47
|
+
* name across environments leaves a staging install silently wired to the prod MCP server
|
|
48
|
+
* while reporting success. */
|
|
49
|
+
export function mcpServerName(env) {
|
|
50
|
+
return env === DEFAULT_ENV ? 'insta-cloud' : `insta-cloud-${env}`;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=env.js.map
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { Command } from 'commander';
|
|
|
4
4
|
import { ApiError } from './api.js';
|
|
5
5
|
import { die } from './util.js';
|
|
6
6
|
import * as auth from './commands/auth.js';
|
|
7
|
+
import * as envCmd_ from './commands/env.js';
|
|
8
|
+
import { ENV_NAMES } from './env.js';
|
|
7
9
|
import * as setup from './commands/setup.js';
|
|
8
10
|
import * as mcp from './commands/mcp.js';
|
|
9
11
|
import * as runCmd from './commands/run.js';
|
|
@@ -15,6 +17,7 @@ import * as regions from './commands/regions.js';
|
|
|
15
17
|
import * as secretsCmd from './commands/secrets.js';
|
|
16
18
|
import { deploy } from './commands/deploy.js';
|
|
17
19
|
import * as computeCmd from './commands/compute.js';
|
|
20
|
+
import * as dbCmd from './commands/db.js';
|
|
18
21
|
import { manifest } from './commands/manifest.js';
|
|
19
22
|
import * as govern from './commands/govern.js';
|
|
20
23
|
import * as observe from './commands/observe.js';
|
|
@@ -56,9 +59,16 @@ program.command('login').description('Log in with email + password, or --oauth <
|
|
|
56
59
|
.option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt)')
|
|
57
60
|
.option('--oauth <provider>', 'browser OAuth login: github | google')
|
|
58
61
|
.option('--api-url <url>', 'control-plane API base URL')
|
|
62
|
+
.option('--env <name>', `deployment environment: ${ENV_NAMES.join(' | ')}`)
|
|
59
63
|
.action(guard((o) => auth.login(o)));
|
|
60
64
|
program.command('logout').description('Log out and clear local tokens').action(guard(() => auth.logout()));
|
|
61
65
|
program.command('status').description('Show login + linked project').option('--json').action(guard((o) => auth.status(o)));
|
|
66
|
+
// ---- environment (prod | staging) ----
|
|
67
|
+
const envCmd = program.command('env').description('Show or switch the deployment environment (prod | staging)');
|
|
68
|
+
envCmd.command('show', { isDefault: true }).description('Show the current environment and its hosts')
|
|
69
|
+
.option('--json').action(guard((o) => envCmd_.envShow(o)));
|
|
70
|
+
envCmd.command('use <name>').description(`Switch environment (${ENV_NAMES.join(' | ')}) — drops the stored session, which is deployment-specific`)
|
|
71
|
+
.action(guard((name) => envCmd_.envUse(name)));
|
|
62
72
|
// ---- run (per-request secret injection — nothing written to disk) ----
|
|
63
73
|
program.command('run <cmd> [args...]').description('Run a command with the branch credential bundle injected into its environment (no .env written)')
|
|
64
74
|
.option('--branch <b>', 'branch bundle to inject (default: linked branch)')
|
|
@@ -102,6 +112,7 @@ svc.command('add <type> <name>').description('Provision a service on demand (ass
|
|
|
102
112
|
.option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
|
|
103
113
|
.option('--image <url>', 'compute only: run this container image at creation')
|
|
104
114
|
.option('--port <n>', 'compute only: port the image listens on (default 8080)')
|
|
115
|
+
.option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)')
|
|
105
116
|
.action(guard((type, name, o) => services.servicesAdd(type, name, o)));
|
|
106
117
|
svc.command('list').option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
107
118
|
.action(guard((o) => services.servicesList(o)));
|
|
@@ -152,6 +163,13 @@ compute.command('suspend [service]').description('Suspend a compute service (RAM
|
|
|
152
163
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeSuspend(service, o)));
|
|
153
164
|
compute.command('status [service]').description("Show a compute service's desired vs. live state")
|
|
154
165
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStatus(service, o)));
|
|
166
|
+
compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = default scale-to-zero. All plans; billing is actual usage either way')
|
|
167
|
+
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
|
|
168
|
+
// ---- db (postgres service controls) ----
|
|
169
|
+
const db = program.command('db').description('Postgres service controls (always-on / scale-to-zero)');
|
|
170
|
+
db.command('always-on <mode>').description('Set a postgres service always-on (mode: on|off). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only')
|
|
171
|
+
.option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
|
|
172
|
+
.action(guard((mode, o) => dbCmd.dbAlwaysOn(mode, o)));
|
|
155
173
|
// ---- manifest ----
|
|
156
174
|
program.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o)));
|
|
157
175
|
// ---- regions ----
|
|
@@ -160,8 +178,8 @@ program.command('regions').description('List regions available for postgres/comp
|
|
|
160
178
|
program.command('metrics <target> [group]').description('Service metrics (target: db|compute)')
|
|
161
179
|
.option('--branch <b>').option('--from <unix>').option('--to <unix>').option('--step <s>').option('--json')
|
|
162
180
|
.action(guard((target, group, o) => obs.metrics(target, group, o)));
|
|
163
|
-
program.command('logs <target> [group]').description('Service runtime
|
|
164
|
-
.option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--json')
|
|
181
|
+
program.command('logs <target> [group]').description('Service logs (runtime by default; --deploy = compute deploy events; target: db|compute)')
|
|
182
|
+
.option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--deploy', 'show compute deploy events (machine lifecycle) instead of runtime logs').option('--json')
|
|
165
183
|
.action(guard((target, group, o) => obs.logs(target, group, o)));
|
|
166
184
|
program.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)')
|
|
167
185
|
.option('--from <unix>').option('--to <unix>').option('--proj [id]', 'show one project (the linked one, or a given id) instead of the whole org').option('--json')
|
package/dist/observe/install.js
CHANGED
|
@@ -58,8 +58,11 @@ function claudeEntry() {
|
|
|
58
58
|
// Claude Code executes `command` as ONE shell string with $CLAUDE_PROJECT_DIR in the env —
|
|
59
59
|
// there is no `args` field in its hooks schema, so a ${…} template in args reaches node
|
|
60
60
|
// verbatim and throws MODULE_NOT_FOUND after every tool call.
|
|
61
|
+
// .claude/settings.json is often committed while ./.insta stays local-only, so a fresh
|
|
62
|
+
// clone (cloud session, teammate) gets the hook without the script — no-op there.
|
|
63
|
+
const hook = '"$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"';
|
|
61
64
|
return { matcher: '*', hooks: [{ type: 'command',
|
|
62
|
-
command:
|
|
65
|
+
command: `[ ! -f ${hook} ] || node ${hook}`, timeout: 15, _insta: MARKER }] };
|
|
63
66
|
}
|
|
64
67
|
function codexEntry(cwd) {
|
|
65
68
|
const abs = join(cwd, '.insta', 'observe', 'hook.js'); // Codex doesn't expand ${CLAUDE_PROJECT_DIR}; use an absolute path
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "insta",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "InstaCloud CLI
|
|
5
|
+
"description": "InstaCloud CLI — a thin client of the platform control-plane API.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"insta",
|
|
8
8
|
"insforge",
|