insta 0.0.22 → 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);
@@ -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';
@@ -56,9 +58,16 @@ program.command('login').description('Log in with email + password, or --oauth <
56
58
  .option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt)')
57
59
  .option('--oauth <provider>', 'browser OAuth login: github | google')
58
60
  .option('--api-url <url>', 'control-plane API base URL')
61
+ .option('--env <name>', `deployment environment: ${ENV_NAMES.join(' | ')}`)
59
62
  .action(guard((o) => auth.login(o)));
60
63
  program.command('logout').description('Log out and clear local tokens').action(guard(() => auth.logout()));
61
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)));
62
71
  // ---- run (per-request secret injection — nothing written to disk) ----
63
72
  program.command('run <cmd> [args...]').description('Run a command with the branch credential bundle injected into its environment (no .env written)')
64
73
  .option('--branch <b>', 'branch bundle to inject (default: linked branch)')
@@ -160,8 +169,8 @@ program.command('regions').description('List regions available for postgres/comp
160
169
  program.command('metrics <target> [group]').description('Service metrics (target: db|compute)')
161
170
  .option('--branch <b>').option('--from <unix>').option('--to <unix>').option('--step <s>').option('--json')
162
171
  .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')
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')
165
174
  .action(guard((target, group, o) => obs.logs(target, group, o)));
166
175
  program.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)')
167
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.22",
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",