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 CHANGED
@@ -1,118 +1,220 @@
1
1
  # insta-cli
2
2
 
3
- InstaCloud CLI (`insta`) — a thin client of the [platform](../platform) control-plane API.
4
- Manages project / branch / secrets / deploy / governance — built for developers and agents.
3
+ [![npm](https://img.shields.io/npm/v/insta?color=blue)](https://www.npmjs.com/package/insta)
4
+ [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg)](LICENSE)
5
5
 
6
- Tech stack: Node 20 + TypeScript (ESM) + commander. Every command is a wrapper around the platform API.
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
- ## Installation
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
- **One-line install (native binary, no node required)** — macOS / Linux / WSL:
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
- **Build from source (requires node):**
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
- ### Build your own binaries (Bun cross-compilation)
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
- npm run compile # compile for the current platform only -> dist/bin/insta
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
- Artifacts look like `insta-darwin-arm64` / `insta-linux-x64` / `insta-windows-x64.exe` (`file` reports native Mach-O/ELF/PE executables).
40
- `dist/` is gitignored; binaries are not committed — CI publishes them to releases.
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
- # Point at the control plane (defaults to http://localhost:8080; override with $INSTA_API_URL or --api-url)
46
- insta login --email you@example.com --password ****** --api-url http://localhost:8080
47
- insta project create my-app # create an empty project and link the current directory (no services by default)
48
- insta services add postgres db # add services on demand (postgres/storage/compute)
49
- insta services add compute api # compute is used to deploy images
50
- insta secrets # write the current branch's credentials to ./.env (secret seam)
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
- ## Commands
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
- | Command | Description |
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
- # 1) Start Postgres + the platform dev server (see ../platform)
100
- docker run -d --name pg -e POSTGRES_PASSWORD=insta -e POSTGRES_DB=insta_dev -p 55432:5432 postgres:16-alpine
101
- cd ../platform && DATABASE_URL='postgres://postgres:insta@localhost:55432/insta_dev' PORT=8899 npm run dev:fake
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
- # 2) Run the full flow with the CLI (signup goes through /auth/signup + /auth/verify-email; in dev mode the verification code is printed in the server logs)
104
- INSTA_API_URL=http://localhost:8899 insta login --email you@x.com --password ...
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
- ## OAuth browser login
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
- insta login --oauth github # or google
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
- > The platform must have an OAuth app configured for that provider (`GITHUB_OAUTH_CLIENT_ID/SECRET` or `GOOGLE_*`),
116
- > and the app's callback URL must be **`{INSTA_API_BASE_URL}/api/auth/callback/<provider>`** (not the loopback address).
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
- > `metrics` / `logs` / `usage` are supported (usage is aggregated at the collection layer). Multiple compute services (`services add compute`) and `services scale/upgrade` are implemented; image building will come later. Multiple postgres/storage services (>1 per project) are currently constrained by the credential seam and remain future work.
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).
@@ -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
- if (opts.apiUrl)
10
- api.setApiUrl(opts.apiUrl);
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
- if (opts.apiUrl)
26
- api.setApiUrl(opts.apiUrl);
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)'}`);
@@ -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
@@ -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 { DEFAULT_MCP_URL, MCP_SERVER_NAME, registerMcp } from './setup.js';
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 ?? {}), [MCP_SERVER_NAME]: { type: 'remote', url, enabled: true } };
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 ?? {}), [MCP_SERVER_NAME]: entry };
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.${MCP_SERVER_NAME}]`))
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.${MCP_SERVER_NAME}]\nurl = "${url}"\n`;
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?.[MCP_SERVER_NAME] : root?.mcpServers?.[MCP_SERVER_NAME];
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 = process.env.INSTA_MCP_URL || DEFAULT_MCP_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 ${MCP_SERVER_NAME} manually`);
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
  }
@@ -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;
@@ -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
- export const SETUP_ARGS = ['skills', 'add', 'InsForge/insta-skills', '-s', 'insta', '-a', '*', '-g', '-y', '--copy'];
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
- export const MCP_SERVER_NAME = 'insta-cloud';
97
- export const DEFAULT_MCP_URL = 'https://mcp.instacloud.com/mcp';
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 = process.env.INSTA_MCP_URL || DEFAULT_MCP_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', MCP_SERVER_NAME])).ok) {
122
- info(`✓ MCP — ${MCP_SERVER_NAME} already registered with Claude Code`);
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', MCP_SERVER_NAME, url];
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 — ${MCP_SERVER_NAME} registered with Claude Code (\`claude mcp list\` to verify)`);
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 ${MCP_SERVER_NAME} ${url}`);
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
- info('setting up coding-agent skills …');
149
- const res = await run('npx', SETUP_ARGS);
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(' npx skills add InsForge/insta-skills -s insta -a "*" -g -y --copy');
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 = 'https://api.instacloud.com';
13
+ const DEFAULT_API = ENVS[DEFAULT_ENV].api;
13
14
  export async function readGlobal() {
14
- // INSTA_API_URL overrides the persisted apiUrl, not just the default — otherwise the
15
- // env var is silently ignored as soon as any login has written a config file.
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
- return { ...parsed, apiUrl: envApi ?? parsed.apiUrl ?? DEFAULT_API };
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: envApi ?? DEFAULT_API };
80
+ return { apiUrl: DEFAULT_API };
23
81
  }
24
82
  }
25
83
  export async function writeGlobal(c) {
@@ -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
- const SKILLS = [
32
- { label: 'insta', args: ['skills', 'add', 'InsForge/insta-skills', '-s', 'insta', ...AGENT_FLAGS] },
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 SKILLS) {
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 logs (target: db|compute)')
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')
@@ -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: 'node "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"', timeout: 15, _insta: MARKER }] };
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.22",
3
+ "version": "0.0.24",
4
4
  "type": "module",
5
- "description": "InstaCloud CLI \u2014 a thin client of the platform control-plane API.",
5
+ "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [
7
7
  "insta",
8
8
  "insforge",