insta 0.0.21 → 0.0.23-rc.1

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
@@ -16,6 +16,26 @@ curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh
16
16
  # Windows: download insta-windows-x64.exe from the releases page.
17
17
  ```
18
18
 
19
+ **Agent one-liner** (CLI + agent skills + MCP registration, non-interactive):
20
+
21
+ ```bash
22
+ curl -fsSL agents.instacloud.com | sh # production
23
+ curl -fsSL agents.staging.instacloud.com | sh # staging
24
+ ```
25
+
26
+ Each installs a complete stack for its environment — CLI build, control plane, MCP registration and
27
+ skill text all match. See [Environments](#environments).
28
+
29
+ > The staging host serves this repo's `agents-staging.sh` from `main`, so it returns 404 until that
30
+ > file is on `main`. Equivalent, and works regardless:
31
+ >
32
+ > ```bash
33
+ > curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh -s -- --agents --staging -y
34
+ > ```
35
+ >
36
+ > If the environment can't be applied (a CLI predating `insta env`), the installer exits non-zero
37
+ > rather than silently leaving you on production.
38
+
19
39
  **Build from source (requires node):**
20
40
 
21
41
  ```bash
@@ -52,11 +72,48 @@ insta deploy --image <registry/img> # deploy a container image to the current b
52
72
  insta status # login state + linked project/branch
53
73
  ```
54
74
 
75
+ ## Environments
76
+
77
+ `prod` and `staging` are **separate deployments** — different regions, different databases,
78
+ different auth. A session from one cannot authenticate against the other, so switching drops the
79
+ stored session and you log in again.
80
+
81
+ | | `prod` (default) | `staging` |
82
+ |---|---|---|
83
+ | control plane | `api.instacloud.com` (us-east-2) | `api.staging.instacloud.com` (us-west-1) |
84
+ | MCP server | `mcp.instacloud.com/mcp` | `mcp.staging.instacloud.com/mcp` |
85
+ | registers as | `insta-cloud` | `insta-cloud-staging` |
86
+ | agent skills | `InsForge/insta-skills` | `InsForge/insta-skills@devel` |
87
+ | CLI channel | latest stable release | newest prerelease (`v*-rc.N`), else stable |
88
+
89
+ ```bash
90
+ insta env # show the current environment and everything derived from it
91
+ insta env use staging # switch (persisted to ~/.insta/config.json)
92
+ insta login --env staging --oauth github
93
+ ```
94
+
95
+ Control plane, MCP host **and** skill source are all resolved from one switch, so a machine can
96
+ never end up with its CLI on staging while its agents talk to prod and read prod's skill text.
97
+ Distinct MCP registration names mean both environments can be installed side by side.
98
+
99
+ Resolution order, most specific first:
100
+
101
+ 1. `INSTA_API_URL` — a literal URL. The only way to reach a host no environment name covers
102
+ (`insta-oss` on localhost, a preview deployment). `INSTA_MCP_URL` and `INSTA_SKILLS_REPO` do the
103
+ same for the MCP host and the skill source.
104
+ 2. `INSTA_ENV` — `prod` | `staging`. An unrecognised value is an error, never a silent fallback.
105
+ 3. the persisted `apiUrl` in `~/.insta/config.json`.
106
+ 4. `prod`.
107
+
108
+ Prereleases never take the `latest` GitHub release or the `latest` npm dist-tag — they publish with
109
+ `--prerelease` and under npm's `next` tag — so a staging build can't reach production installers.
110
+
55
111
  ## Commands
56
112
 
57
113
  | Command | Description |
58
114
  |------|------|
59
- | `insta login [--email --password --api-url]` | Log in (email/password; tokens auto-refresh) |
115
+ | `insta login [--email --password --api-url --env]` | Log in (email/password; tokens auto-refresh) |
116
+ | `insta env [--json]` / `insta env use <prod\|staging>` | Show or switch deployment environment |
60
117
  | `insta login --oauth <github\|google>` | Browser OAuth login (starts a local loopback port; the token is carried back automatically after browser authorization) |
61
118
  | `insta logout` / `insta status [--json]` | Log out / show status |
62
119
  | `insta org list [--json]` / `org create <name>` | Organizations (each user may own only one free org) |
@@ -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)'}`);
@@ -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);
@@ -0,0 +1,12 @@
1
+ import { ApiClient } from '../api.js';
2
+ import { info, printJson } from '../util.js';
3
+ // insta regions — list the regions a postgres/compute service can be created in.
4
+ export async function regionsList(opts = {}) {
5
+ const api = await ApiClient.load();
6
+ const { regions } = await api.request('GET', '/regions');
7
+ if (opts.json)
8
+ return printJson(regions);
9
+ for (const r of regions)
10
+ info(`${r.slug} ${r.label}`);
11
+ }
12
+ //# sourceMappingURL=regions.js.map
@@ -28,15 +28,61 @@ export async function secrets(opts) {
28
28
  info(` .gitignore += ${out} (credentials must never be committed)`);
29
29
  info(' tip: `insta run -- <cmd>` injects these per-run with nothing written to disk');
30
30
  }
31
+ // Render one branch's service-grouped secrets, then its unbound (branch-level) secrets.
32
+ function renderBranch(b) {
33
+ for (const s of b.services)
34
+ if (s.secrets.length) {
35
+ info(` ${s.type}/${s.name}`);
36
+ for (const n of s.secrets)
37
+ info(` ${n}`);
38
+ }
39
+ if (b.unbound.length) {
40
+ info(' (branch-level)');
41
+ for (const n of b.unbound)
42
+ info(` ${n}`);
43
+ }
44
+ }
45
+ // Show the full binding tree: project-wide, then every branch grouped by service.
46
+ export async function secretsTree(opts) {
47
+ const api = await ApiClient.load();
48
+ const p = await requireProject();
49
+ const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets/tree`);
50
+ if (handleApproval(res))
51
+ return;
52
+ const tree = res.body;
53
+ if (opts.json)
54
+ return printJson(tree);
55
+ if (tree.projectWide.length) {
56
+ info('(project-wide)');
57
+ for (const n of tree.projectWide)
58
+ info(` ${n}`);
59
+ }
60
+ for (const b of tree.branches) {
61
+ info(`${b.name}${b.isDefault ? ' *' : ''}`);
62
+ renderBranch(b);
63
+ }
64
+ }
65
+ // List secret names for the current (or given) branch, grouped by service.
31
66
  export async function secretsList(opts) {
32
67
  const api = await ApiClient.load();
33
68
  const p = await requireProject();
34
69
  const branch = opts.branch ?? p.branch;
35
- const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets${q(branch)}`);
70
+ const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets/tree`);
36
71
  if (handleApproval(res))
37
72
  return;
38
- for (const name of Object.keys(res.body.secrets))
39
- info(name);
73
+ const tree = res.body;
74
+ const b = tree.branches.find((x) => x.name === branch);
75
+ if (opts.json)
76
+ return printJson({ projectWide: tree.projectWide, branch: b });
77
+ if (tree.projectWide.length) {
78
+ info('(project-wide)');
79
+ for (const n of tree.projectWide)
80
+ info(` ${n}`);
81
+ }
82
+ if (b) {
83
+ info(`${b.name}`);
84
+ renderBranch(b);
85
+ }
40
86
  }
41
87
  async function readStdin() {
42
88
  let data = '';
@@ -44,19 +90,21 @@ async function readStdin() {
44
90
  data += chunk;
45
91
  return data.trim();
46
92
  }
47
- // Set a user secret. Project-wide by default; --branch scopes it to one branch. Value comes from
48
- // the argument, or stdin when omitted (keeps secret values out of shell history).
93
+ // Set a user secret. Project-wide by default; --branch scopes it to one branch. --service binds
94
+ // it to a branch service instead, which implies the current branch (binding requires one). Value
95
+ // comes from the argument, or stdin when omitted (keeps secret values out of shell history).
49
96
  export async function secretsSet(name, value, opts) {
50
97
  const api = await ApiClient.load();
51
98
  const p = await requireProject();
52
99
  const v = value ?? (await readStdin());
53
100
  if (!v)
54
101
  die('value is required (pass as an argument or on stdin)');
55
- const payload = opts.branch ? { value: v, branch: opts.branch } : { value: v };
102
+ const branch = opts.service ? (opts.branch ?? p.branch) : opts.branch;
103
+ const payload = { value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
56
104
  const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}`, payload);
57
105
  if (handleApproval(res))
58
106
  return;
59
- info(`set ${name} (${opts.branch ? `branch ${opts.branch}` : 'project-wide'})`);
107
+ info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
60
108
  }
61
109
  export async function secretsUnset(name, opts) {
62
110
  const api = await ApiClient.load();
@@ -2,6 +2,7 @@
2
2
  import { ApiClient, requireProject } from '../api.js';
3
3
  import { info, printJson, handleApproval, renderNextActions } from '../util.js';
4
4
  export const SERVICE_TYPES = ['postgres', 'storage', 'compute'];
5
+ const SERVICE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,38}$/;
5
6
  export function q(branch) {
6
7
  return branch ? `?branch=${encodeURIComponent(branch)}` : '';
7
8
  }
@@ -11,6 +12,10 @@ export function assertType(type, allowed = SERVICE_TYPES) {
11
12
  if (!allowed.includes(type))
12
13
  throw new Error(`type must be ${allowed.join('|')}`);
13
14
  }
15
+ export function assertServiceName(name) {
16
+ if (!SERVICE_NAME_RE.test(name))
17
+ throw new Error('service name must be lower-kebab (a-z, 0-9, -)');
18
+ }
14
19
  // Parse a positive-integer machine count.
15
20
  export function parseCount(raw) {
16
21
  const n = Number(raw);
@@ -40,22 +45,46 @@ export function resolveComputeServiceId(services, name) {
40
45
  throw new Error(`multiple compute services — specify one: ${compute.map((s) => s.name).join(', ')}`);
41
46
  return compute[0].id;
42
47
  }
43
- // ---- commands ----
48
+ // Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
49
+ // mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
50
+ // type) stays in servicesAdd, ahead of any network/config access.
51
+ export function servicesAddRequestBody(type, name, branch, opts) {
52
+ return {
53
+ type, name, ...(branch ? { branch } : {}), public: !!opts.public,
54
+ ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: Number(opts.port) } : {}),
55
+ ...(opts.region ? { region: opts.region } : {}),
56
+ };
57
+ }
44
58
  export async function servicesAdd(type, name, opts = {}) {
45
59
  assertType(type);
46
60
  if (opts.public && type !== 'storage')
47
61
  throw new Error('--public is only valid for storage services');
62
+ if (opts.region && type === 'storage')
63
+ throw new Error('--region is not valid for storage services');
64
+ if (opts.image && type !== 'compute')
65
+ throw new Error('--image is only valid for compute services');
66
+ if (opts.port && type !== 'compute')
67
+ throw new Error('--port is only valid for compute services');
48
68
  const api = await ApiClient.load();
49
69
  const p = await requireProject();
50
70
  const branch = opts.branch ?? p.branch;
51
- const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, { type, name, ...(branch ? { branch } : {}), public: !!opts.public });
71
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, servicesAddRequestBody(type, name, branch, opts));
52
72
  if (handleApproval(res))
53
73
  return;
54
74
  const svc = res.body.service;
55
75
  const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '';
56
- info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.domain ? ` — ${svc.domain}` : ''}`);
76
+ const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '';
77
+ info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${svc.domain ? ` — ${svc.domain}` : ''}`);
57
78
  renderNextActions(res.body.nextActions);
58
79
  }
80
+ // Render one `services list` row. Pure, so it's unit-tested without a network mock (mirrors
81
+ // billingLines in billing.ts). Compute rows show the running image when the platform reports one.
82
+ export function serviceListLine(s) {
83
+ const extra = s.type === 'compute'
84
+ ? ` x${s.machine_count}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}`
85
+ : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '';
86
+ return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`;
87
+ }
59
88
  export async function servicesList(opts) {
60
89
  const api = await ApiClient.load();
61
90
  const p = await requireProject();
@@ -65,10 +94,8 @@ export async function servicesList(opts) {
65
94
  return printJson(services);
66
95
  if (!services.length)
67
96
  return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add <postgres|storage|compute> <name>\`)`);
68
- for (const s of services) {
69
- const extra = s.type === 'compute' ? ` x${s.machine_count}` : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '';
70
- info(`${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`);
71
- }
97
+ for (const s of services)
98
+ info(serviceListLine(s));
72
99
  }
73
100
  export async function servicesRemove(type, name, opts = {}) {
74
101
  assertType(type);
@@ -82,6 +109,21 @@ export async function servicesRemove(type, name, opts = {}) {
82
109
  return;
83
110
  info(`removed ${type} service ${name} from ${branch ?? 'default'}`);
84
111
  }
112
+ export async function servicesRename(type, name, newName, opts = {}) {
113
+ assertType(type);
114
+ assertServiceName(newName);
115
+ const api = await ApiClient.load();
116
+ const p = await requireProject();
117
+ const branch = opts.branch ?? p.branch;
118
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
119
+ const id = resolveServiceId(services, type, name);
120
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/rename`, { name: newName });
121
+ if (handleApproval(res))
122
+ return;
123
+ if (opts.json)
124
+ return printJson(res.body.service);
125
+ info(`renamed ${type} service ${name} to ${newName}`);
126
+ }
85
127
  // Validate a bucket access-mode argument.
86
128
  export function parseAccess(raw) {
87
129
  if (raw === 'public')
@@ -134,4 +176,22 @@ export async function servicesUpgrade(type, name, spec, _opts) {
134
176
  return printJson(res.body.service);
135
177
  info(`upgraded ${type} ${name} to ${spec}`);
136
178
  }
179
+ // insta services secrets <type> <name> — the secret names bound to a service.
180
+ export async function servicesSecrets(type, name, opts = {}) {
181
+ assertType(type);
182
+ const api = await ApiClient.load();
183
+ const p = await requireProject();
184
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(opts.branch ?? p.branch)}`);
185
+ const id = resolveServiceId(services, type, name);
186
+ const res = await api.rawRequest('GET', `/projects/${p.projectId}/services/${id}/secrets`);
187
+ if (handleApproval(res))
188
+ return;
189
+ const { secrets } = res.body;
190
+ if (opts.json)
191
+ return printJson(secrets);
192
+ if (!secrets.length)
193
+ return info(`(no secrets bound to ${type}/${name})`);
194
+ for (const n of secrets)
195
+ info(n);
196
+ }
137
197
  //# sourceMappingURL=services.js.map
@@ -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';
@@ -11,6 +13,7 @@ import * as org from './commands/org.js';
11
13
  import * as project from './commands/project.js';
12
14
  import * as branch from './commands/branch.js';
13
15
  import * as services from './commands/services.js';
16
+ import * as regions from './commands/regions.js';
14
17
  import * as secretsCmd from './commands/secrets.js';
15
18
  import { deploy } from './commands/deploy.js';
16
19
  import * as computeCmd from './commands/compute.js';
@@ -55,9 +58,16 @@ program.command('login').description('Log in with email + password, or --oauth <
55
58
  .option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt)')
56
59
  .option('--oauth <provider>', 'browser OAuth login: github | google')
57
60
  .option('--api-url <url>', 'control-plane API base URL')
61
+ .option('--env <name>', `deployment environment: ${ENV_NAMES.join(' | ')}`)
58
62
  .action(guard((o) => auth.login(o)));
59
63
  program.command('logout').description('Log out and clear local tokens').action(guard(() => auth.logout()));
60
64
  program.command('status').description('Show login + linked project').option('--json').action(guard((o) => auth.status(o)));
65
+ // ---- environment (prod | staging) ----
66
+ const envCmd = program.command('env').description('Show or switch the deployment environment (prod | staging)');
67
+ envCmd.command('show', { isDefault: true }).description('Show the current environment and its hosts')
68
+ .option('--json').action(guard((o) => envCmd_.envShow(o)));
69
+ envCmd.command('use <name>').description(`Switch environment (${ENV_NAMES.join(' | ')}) — drops the stored session, which is deployment-specific`)
70
+ .action(guard((name) => envCmd_.envUse(name)));
61
71
  // ---- run (per-request secret injection — nothing written to disk) ----
62
72
  program.command('run <cmd> [args...]').description('Run a command with the branch credential bundle injected into its environment (no .env written)')
63
73
  .option('--branch <b>', 'branch bundle to inject (default: linked branch)')
@@ -97,28 +107,39 @@ br.command('merge <source>').description('Merge a branch service set into anothe
97
107
  const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute)');
98
108
  svc.command('add <type> <name>').description('Provision a service on demand (assigns a default domain for postgres/compute)')
99
109
  .option('--branch <branch>', 'target branch (default: current)')
110
+ .option('--region <region>', 'region for postgres/compute, e.g. us-east (see `insta regions`)')
100
111
  .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
112
+ .option('--image <url>', 'compute only: run this container image at creation')
113
+ .option('--port <n>', 'compute only: port the image listens on (default 8080)')
101
114
  .action(guard((type, name, o) => services.servicesAdd(type, name, o)));
102
115
  svc.command('list').option('--json').option('--branch <branch>', 'branch (default: current)')
103
116
  .action(guard((o) => services.servicesList(o)));
104
117
  svc.command('remove <type> <name>').description('Remove a service and destroy its resources')
105
118
  .option('--branch <branch>', 'branch (default: current)')
106
119
  .action(guard((type, name, o) => services.servicesRemove(type, name, o)));
120
+ svc.command('rename <type> <name> <new-name>').description('Rename a service and re-key its managed secret names')
121
+ .option('--json').option('--branch <branch>', 'branch (default: current)')
122
+ .action(guard((type, name, newName, o) => services.servicesRename(type, name, newName, o)));
107
123
  svc.command('set-access <type> <name> <access>').description('Set a storage service bucket access mode (access: public|private)')
108
124
  .option('--json').action(guard((type, name, access, o) => services.servicesSetAccess(type, name, access, o)));
109
125
  svc.command('scale <type> <name> <number> [region]').description('Set a compute service machine count (paid plans only)')
110
126
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((type, name, number, region, o) => services.servicesScale(type, name, number, region, o)));
111
127
  svc.command('upgrade <type> <name> <spec>').description('Change a compute/postgres service spec (paid plans only)')
112
128
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((type, name, spec, o) => services.servicesUpgrade(type, name, spec, o)));
129
+ svc.command('secrets <type> <name>').description("List a service's secret names")
130
+ .option('--branch <b>').option('--json').action(guard((type, name, o) => services.servicesSecrets(type, name, o)));
113
131
  // ---- secrets (seam) ----
114
132
  const sec = program.command('secrets').description('Fetch the credential bundle (secret seam) into .env')
115
133
  .option('--branch <branch>').option('-o, --output <file>', 'output file (default .env)').option('--print', 'print instead of writing').option('--json')
116
134
  .action(guard((o) => secretsCmd.secrets(o)));
117
- sec.command('list').description('List secret names only').option('--branch <branch>').action(guard((o) => secretsCmd.secretsList(o)));
135
+ sec.command('list').description('List secret names, grouped by service').option('--branch <branch>').option('--json').action(guard((o) => secretsCmd.secretsList(o)));
118
136
  sec.command('set <name> [value]').description('Set a user secret (project-wide; value from stdin if omitted)')
119
- .option('--branch <branch>', 'scope to one branch').action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o)));
137
+ .option('--branch <branch>', 'scope to one branch').option('--service <type/name>', 'bind to a branch service (implies current branch)')
138
+ .action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o)));
120
139
  sec.command('unset <name>').description('Remove a user secret')
121
140
  .option('--branch <branch>', 'scope to one branch').action(guard((n, o) => secretsCmd.secretsUnset(n, o)));
141
+ sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json')
142
+ .action(guard((o) => secretsCmd.secretsTree(o)));
122
143
  // ---- deploy ----
123
144
  program.command('deploy [dir]').description('Deploy a source directory (built remotely on Fly) or a prebuilt --image to a branch compute group')
124
145
  .option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
@@ -142,12 +163,14 @@ compute.command('status [service]').description("Show a compute service's desire
142
163
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStatus(service, o)));
143
164
  // ---- manifest ----
144
165
  program.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o)));
166
+ // ---- regions ----
167
+ program.command('regions').description('List regions available for postgres/compute services').option('--json').action(guard((o) => regions.regionsList(o)));
145
168
  // ---- observability ----
146
169
  program.command('metrics <target> [group]').description('Service metrics (target: db|compute)')
147
170
  .option('--branch <b>').option('--from <unix>').option('--to <unix>').option('--step <s>').option('--json')
148
171
  .action(guard((target, group, o) => obs.metrics(target, group, o)));
149
- program.command('logs <target> [group]').description('Service runtime logs (target: db|compute)')
150
- .option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--json')
172
+ program.command('logs <target> [group]').description('Service logs (runtime by default; --deploy = compute deploy events; target: db|compute)')
173
+ .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')
151
174
  .action(guard((target, group, o) => obs.logs(target, group, o)));
152
175
  program.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)')
153
176
  .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.21",
3
+ "version": "0.0.23-rc.1",
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",