From 4c73aff1b28aea93ee8eb44f677314e419688733 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:59:51 +0800 Subject: [PATCH 1/5] feat(env): first-class staging environment + agents.staging one-liner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a staging sibling to the `curl -fsSL agents.instacloud.com | sh` onboarding flow, and makes the environment a real concept rather than two independent env vars. curl -fsSL agents.staging.instacloud.com | sh Why this is more than an alias: - `api` and `mcp` hosts are now resolved TOGETHER from one switch (src/env.ts). Previously INSTA_API_URL and INSTA_MCP_URL were independent, so pointing the CLI at staging and forgetting the second var left the machine's agents registered against the PRODUCTION MCP server — silent, and reported as success. - Staging registers as `insta-cloud-staging`, not `insta-cloud`. registerMcp treats an already-registered name as "nothing to do", and the config-file agents key their entry by name, so a shared name meant a staging install silently kept prod's registration. Distinct names also let both environments coexist on one machine. - The installer persists the choice via `insta env use` instead of exporting INSTA_ENV. A piped `curl … | sh` cannot export into the parent shell, so an env var would evaporate before the user's next `insta project create`. - Switching environments drops the stored session. prod and staging are separate deployments (us-east-2 vs us-west-1, different databases), so the old token cannot authenticate — and keeping it is unsafe, because api.ts's 401 path would POST one deployment's refresh token to the other. Same reasoning as the retired-host path in config.ts. - An unrecognised INSTA_ENV is a hard error, not a fallback to prod: a typo'd `INSTA_ENV=stagng` that silently provisions production infrastructure would stay invisible until the bill arrived. Resolution order is unchanged at the top — INSTA_API_URL still wins outright, so insta-oss and preview deployments behave exactly as before. New surface: `insta env [--json]`, `insta env use `, `insta login --env `, `install.sh --staging|--env `, and `insta status` now names the environment alongside the URL. 26 new tests; full suite 140 passing, typecheck clean. --- README.md | 42 ++++++- agents-staging.sh | 23 ++++ install.sh | 38 +++++- src/commands/auth.ts | 28 ++++- src/commands/env.ts | 48 ++++++++ src/commands/mcp.ts | 28 ++--- src/commands/setup.ts | 29 +++-- src/config.ts | 32 ++++- src/env.ts | 70 +++++++++++ src/index.ts | 10 ++ test/env-switch.test.ts | 251 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 562 insertions(+), 37 deletions(-) create mode 100644 agents-staging.sh create mode 100644 src/commands/env.ts create mode 100644 src/env.ts create mode 100644 test/env-switch.test.ts diff --git a/README.md b/README.md index 06ffcd6..091bf53 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh # Windows: download insta-windows-x64.exe from the releases page. ``` +**Agent one-liner** (CLI + agent skills + MCP registration, non-interactive): + +```bash +curl -fsSL agents.instacloud.com | sh # production +curl -fsSL agents.staging.instacloud.com | sh # staging +``` + +Both install the same released binary — the difference is which control plane it targets. See +[Environments](#environments). + **Build from source (requires node):** ```bash @@ -52,11 +62,41 @@ insta deploy --image # deploy a container image to the current b insta status # login state + linked project/branch ``` +## Environments + +`prod` and `staging` are **separate deployments** — different regions, different databases, +different auth. A session from one cannot authenticate against the other, so switching drops the +stored session and you log in again. + +| Environment | Control plane | MCP server | Registers as | +|---|---|---|---| +| `prod` (default) | `api.instacloud.com` (us-east-2) | `mcp.instacloud.com/mcp` | `insta-cloud` | +| `staging` | `api.staging.instacloud.com` (us-west-1) | `mcp.staging.instacloud.com/mcp` | `insta-cloud-staging` | + +```bash +insta env # show the current environment and its hosts +insta env use staging # switch (persisted to ~/.insta/config.json) +insta login --env staging --oauth github +``` + +The API and MCP hosts are always resolved together from one switch, so the CLI and your agents can +never end up pointed at different environments. Distinct MCP registration names mean both can be +installed on the same machine at once. + +Resolution order, most specific first: + +1. `INSTA_API_URL` — a literal URL. The only way to reach a host no environment name covers + (`insta-oss` on localhost, a preview deployment). `INSTA_MCP_URL` does the same for MCP. +2. `INSTA_ENV` — `prod` | `staging`. An unrecognised value is an error, never a silent fallback. +3. the persisted `apiUrl` in `~/.insta/config.json`. +4. `prod`. + ## Commands | Command | Description | |------|------| -| `insta login [--email --password --api-url]` | Log in (email/password; tokens auto-refresh) | +| `insta login [--email --password --api-url --env]` | Log in (email/password; tokens auto-refresh) | +| `insta env [--json]` / `insta env use ` | Show or switch deployment environment | | `insta login --oauth ` | Browser OAuth login (starts a local loopback port; the token is carried back automatically after browser authorization) | | `insta logout` / `insta status [--json]` | Log out / show status | | `insta org list [--json]` / `org create ` | Organizations (each user may own only one free org) | diff --git a/agents-staging.sh b/agents-staging.sh new file mode 100644 index 0000000..839b27e --- /dev/null +++ b/agents-staging.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +# +# InstaCloud agent setup installer, STAGING — the one-liner for coding agents: +# +# curl -fsSL agents.staging.instacloud.com | sh +# +# (agents.staging.instacloud.com is a CloudFront edge cache of this file, the staging sibling of +# agents.instacloud.com → agents.sh. The raw fallback also works: +# curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/agents-staging.sh | sh) +# +# Identical to agents.sh except that it targets the staging deployment +# (api/mcp.staging.instacloud.com, us-west-1) instead of production (us-east-2). +# +# Note this ships from the SAME main branch as agents.sh — the staging/prod split here is about +# which control plane the CLI talks to, NOT which build of the CLI you get. You always get the +# current released binary; use INSTA_VERSION to pin a different one. +# +# --staging is passed to install.sh (rather than exporting INSTA_ENV around it) so the choice is +# PERSISTED into ~/.insta/config.json. A piped `curl … | sh` cannot export into the parent shell, +# so an env var would evaporate before the `insta project create` the user runs next. +set -eu + +curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh -s -- --agents --staging -y "$@" diff --git a/install.sh b/install.sh index a8937e5..460c584 100644 --- a/install.sh +++ b/install.sh @@ -9,23 +9,39 @@ # (equivalent to piping this script with: sh -s -- --agents -y) # # Flags: -# --agents after installing, run `insta setup agent` (skills for Claude Code/Codex/Cursor/…) -# -y non-interactive +# --agents after installing, run `insta setup agent` (skills for Claude Code/Codex/Cursor/…) +# -y non-interactive +# --staging target the staging deployment (shorthand for --env staging) +# --env target a named deployment: prod (default) | staging # # Options (env): # INSTA_VERSION release tag to install (e.g. v0.1.0); default: latest # INSTA_INSTALL_DIR install directory; default: $HOME/.insta/bin +# INSTA_ENV same as --env; the flag wins if both are given set -eu AGENTS=0 YES=0 -for arg in "$@"; do - case "$arg" in +# Environment is PERSISTED via `insta env use` below rather than exported, because the canonical +# install is a pipe and a piped script cannot set variables in the parent shell — an exported +# INSTA_ENV would vanish before the user's next `insta` command. +ENV_NAME="${INSTA_ENV:-}" +while [ $# -gt 0 ]; do + case "$1" in --agents) AGENTS=1 ;; -y|--yes) YES=1 ;; + --staging) ENV_NAME=staging ;; + --env) shift; [ $# -gt 0 ] || { echo "error: --env needs a value (prod|staging)" >&2; exit 1; }; ENV_NAME="$1" ;; + --env=*) ENV_NAME="${1#--env=}" ;; esac + shift done +case "$ENV_NAME" in + ''|prod|staging) ;; + *) echo "error: unknown environment '$ENV_NAME' (expected prod or staging)" >&2; exit 1 ;; +esac + REPO="InsForge/insta-cli" BIN="insta" INSTALL_DIR="${INSTA_INSTALL_DIR:-$HOME/.insta/bin}" @@ -143,6 +159,16 @@ if [ "$ON_PATH" != "1" ]; then done fi +# ---- environment (--staging / --env) ---- +# MUST run before `setup agent`: that step registers the MCP server, and it derives the MCP host and +# registration name from the persisted environment. Switching afterwards would leave the machine's +# agents pointed at production's MCP server while the CLI talked to staging. +if [ -n "$ENV_NAME" ]; then + echo + "$INSTALL_DIR/$BIN" env use "$ENV_NAME" \ + || echo "warn: could not select environment '$ENV_NAME' — run: insta env use $ENV_NAME" +fi + # ---- agent setup (--agents) ---- if [ "$AGENTS" = "1" ]; then echo @@ -175,6 +201,10 @@ fi # ---- next steps (the 3-command wow: real infra, then a full isolated clone of it) ---- echo +if [ "$ENV_NAME" = "staging" ]; then + echo "Environment: staging (api.staging.instacloud.com) — persisted; \`insta env use prod\` to switch back." + echo +fi echo "Next steps:" echo " insta login --oauth github # connect to the cloud (or run insta-oss locally to skip)" echo " insta project create demo # postgres + storage + compute, provisioned in one shot" diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 95d2efd..a7fd189 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,12 +1,25 @@ import { createServer } from 'node:http' import { randomBytes } from 'node:crypto' import { ApiClient, linkedProject } from '../api.js' +import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js' import { info, die, printJson, promptPassword, openUrl } from '../util.js' -export async function login(opts: { email?: string; password?: string; apiUrl?: string; oauth?: string }): Promise { +/** --api-url and --env both set the target host; --api-url wins (more specific), matching the + * INSTA_API_URL > INSTA_ENV precedence in config.ts. Returns the URL to point at, or undefined + * to leave whatever is already resolved alone. */ +function targetApiUrl(opts: { apiUrl?: string; env?: string }): string | undefined { + if (opts.apiUrl) return opts.apiUrl + if (!opts.env) return undefined + const want = opts.env.trim().toLowerCase() + if (!isEnvName(want)) die(`unknown --env "${opts.env}" — expected one of: ${ENV_NAMES.join(', ')}`) + return ENVS[want].api +} + +export async function login(opts: { email?: string; password?: string; apiUrl?: string; env?: string; oauth?: string }): Promise { if (opts.oauth) return loginOauth(opts.oauth, opts) const api = await ApiClient.load() - if (opts.apiUrl) api.setApiUrl(opts.apiUrl) + const target = targetApiUrl(opts) + if (target) api.setApiUrl(target) if (!opts.email) die('--email is required (or use --oauth )') const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword()) const res = await api.request('POST', '/auth/login', { email: opts.email, password }, { auth: false }) @@ -17,10 +30,11 @@ export async function login(opts: { email?: string; password?: string; apiUrl?: // Browser OAuth (GitHub/Google) via a loopback listener. We open the platform's CLI-OAuth bridge, // which runs Better Auth's social flow and bounces the resulting session token back to us. -export async function loginOauth(provider: string, opts: { apiUrl?: string }): Promise { +export async function loginOauth(provider: string, opts: { apiUrl?: string; env?: string }): Promise { if (provider !== 'github' && provider !== 'google') die('provider must be github or google') const api = await ApiClient.load() - if (opts.apiUrl) api.setApiUrl(opts.apiUrl) + const target = targetApiUrl(opts) + if (target) api.setApiUrl(target) const token = await browserOauth(api.apiUrl, provider) api.setSession({ accessToken: token, refreshToken: token }) const me = await api.request<{ user: { id: string; email: string | null; name: string | null } }>('GET', '/me') @@ -77,7 +91,11 @@ export async function status(opts: { json?: boolean }): Promise { let user: any = null try { user = (await api.request('GET', '/me')).user } catch { /* not logged in */ } const project = await linkedProject() - if (opts.json) return printJson({ apiUrl: api.apiUrl, user, project }) + // Surface the environment name alongside the URL: "api: https://api.staging.instacloud.com" is + // easy to skim past, and mistaking staging for prod is the mistake worth making loud. + const env = envForApiUrl(api.apiUrl) + if (opts.json) return printJson({ env, apiUrl: api.apiUrl, user, project }) + info(`env: ${env ?? '(custom)'}`) info(`api: ${api.apiUrl}`) info(`user: ${user ? (user.email ?? user.id) : '(not logged in)'}`) info(`project: ${project ? `${project.projectId} (branch ${project.branch})` : '(none linked)'}`) diff --git a/src/commands/env.ts b/src/commands/env.ts new file mode 100644 index 0000000..6ac796a --- /dev/null +++ b/src/commands/env.ts @@ -0,0 +1,48 @@ +// `insta env` — show or switch the deployment environment (prod | staging). +// +// This exists because the canonical install is a pipe: `curl -fsSL agents.staging.instacloud.com | sh`. +// A piped script cannot export anything into the parent shell, so the staging one-liner has no way +// to make `INSTA_ENV=staging` stick for the `insta project create` the user runs next. Persisting +// the choice into ~/.insta/config.json is the only mechanism that survives the pipe — and it is the +// same file `login --api-url` already writes, so this adds a surface, not a concept. +import { ApiClient } from '../api.js' +import { resolveEnv } from '../config.js' +import { ENVS, ENV_NAMES, envForApiUrl, isEnvName, mcpServerName, type EnvName } from '../env.js' +import { die, info, printJson } from '../util.js' + +export async function envShow(opts: { json?: boolean }): Promise { + const { apiUrl, env, mcpUrl } = await resolveEnv() + if (opts.json) return printJson({ env, apiUrl, mcpUrl, mcpServer: mcpServerName(env ?? 'prod') }) + info(`env: ${env ?? '(custom)'}`) + info(`api: ${apiUrl}`) + info(`mcp: ${mcpUrl}`) + if (!env) info(' (custom apiUrl — `insta env use ` to switch to a named environment)') +} + +export async function envUse(name: string): Promise { + const want = name.trim().toLowerCase() + if (!isEnvName(want)) die(`unknown environment "${name}" — expected one of: ${ENV_NAMES.join(', ')}`) + const target: EnvName = want + + const api = await ApiClient.load() + const from = envForApiUrl(api.apiUrl) + const nextApi = ENVS[target].api + if (api.apiUrl === nextApi) { + info(`already on ${target} (${nextApi})`) + return + } + + // Drop the stored session along with the host. prod and staging are separate deployments, so the + // old token cannot authenticate here — and keeping it is actively unsafe, because api.ts's 401 + // path POSTs the refresh token to whatever apiUrl now resolves to, handing one deployment's + // credential to another. Same reasoning as the retired-host path in config.ts. + const hadSession = !!api.config.accessToken + api.setApiUrl(nextApi) + if (hadSession) api.clearSession() + await api.persist() + + info(`switched ${from ?? '(custom)'} → ${target}`) + info(` api: ${nextApi}`) + info(` mcp: ${ENVS[target].mcp} (registers as \`${mcpServerName(target)}\`)`) + if (hadSession) info(' previous session dropped (separate deployment) — run `insta login --oauth github`') +} diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index d8ed4a7..86b1578 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -8,7 +8,7 @@ import { existsSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { info } from '../util.js' -import { DEFAULT_MCP_URL, MCP_SERVER_NAME, registerMcp } from './setup.js' +import { MCP_SERVER_NAME, registerMcp, resolveMcpTarget } from './setup.js' export const MCP_AGENT_TARGETS = ['cursor', 'codex', 'opencode', 'copilot', 'factory-droid'] as const export type McpAgent = (typeof MCP_AGENT_TARGETS)[number] @@ -31,7 +31,7 @@ export function detectAgents(home: string): McpAgent[] { // Merge our entry into existing JSON config. Returns null (skip, leave file alone) when the // existing content isn't valid JSON — never clobber a config we can't parse. -export function renderJsonConfig(slug: McpAgent, existing: string | null, url: string): string | null { +export function renderJsonConfig(slug: McpAgent, existing: string | null, url: string, name: string = MCP_SERVER_NAME): string | null { let root: any = {} if (existing && existing.trim()) { try { root = JSON.parse(existing) } catch { return null } @@ -39,34 +39,34 @@ export function renderJsonConfig(slug: McpAgent, existing: string | null, url: s } if (slug === 'opencode') { // OpenCode: `mcp` key, `type: "remote"` schema (docs.opencode.ai). - root.mcp = { ...(root.mcp ?? {}), [MCP_SERVER_NAME]: { type: 'remote', url, enabled: true } } + root.mcp = { ...(root.mcp ?? {}), [name]: { type: 'remote', url, enabled: true } } root.$schema ??= 'https://opencode.ai/config.json' } else { const entry = slug === 'cursor' ? { url } // Cursor auto-detects HTTP from `url` : slug === 'copilot' ? { type: 'http', url, tools: ['*'] } : { type: 'http', url, disabled: false } // factory-droid - root.mcpServers = { ...(root.mcpServers ?? {}), [MCP_SERVER_NAME]: entry } + root.mcpServers = { ...(root.mcpServers ?? {}), [name]: entry } } return JSON.stringify(root, null, 2) + '\n' } // Codex config is TOML. Appending a complete `[mcp_servers.]` table is always valid at // EOF, so we avoid a TOML parser: string-detect for idempotency, append for install. -export function renderCodexConfig(existing: string | null, url: string): string | null { +export function renderCodexConfig(existing: string | null, url: string, name: string = MCP_SERVER_NAME): string | null { const base = existing ?? '' - if (base.includes(`[mcp_servers.${MCP_SERVER_NAME}]`)) return null // already configured + if (base.includes(`[mcp_servers.${name}]`)) return null // already configured const sep = base.length && !base.endsWith('\n') ? '\n' : '' - return `${base}${sep}\n[mcp_servers.${MCP_SERVER_NAME}]\nurl = "${url}"\n` + return `${base}${sep}\n[mcp_servers.${name}]\nurl = "${url}"\n` } // Install for one agent. Returns 'installed' | 'already' | 'skipped' (unparseable config). -export async function installFor(slug: McpAgent, home: string, url: string): Promise<'installed' | 'already' | 'skipped'> { +export async function installFor(slug: McpAgent, home: string, url: string, name: string = MCP_SERVER_NAME): Promise<'installed' | 'already' | 'skipped'> { const file = configPath(slug, home) let existing: string | null = null try { existing = await fs.readFile(file, 'utf8') } catch { existing = null } if (slug === 'codex') { - const next = renderCodexConfig(existing, url) + const next = renderCodexConfig(existing, url, name) if (next === null) return 'already' await fs.mkdir(path.dirname(file), { recursive: true }) await fs.writeFile(file, next) @@ -75,11 +75,11 @@ export async function installFor(slug: McpAgent, home: string, url: string): Pro if (existing) { try { const root = JSON.parse(existing) - const entry = slug === 'opencode' ? root?.mcp?.[MCP_SERVER_NAME] : root?.mcpServers?.[MCP_SERVER_NAME] + const entry = slug === 'opencode' ? root?.mcp?.[name] : root?.mcpServers?.[name] if (entry) return 'already' } catch { /* fall through to renderJsonConfig, which refuses to clobber */ } } - const next = renderJsonConfig(slug, existing, url) + const next = renderJsonConfig(slug, existing, url, name) if (next === null) return 'skipped' await fs.mkdir(path.dirname(file), { recursive: true }) await fs.writeFile(file, next) @@ -93,7 +93,7 @@ const AGENT_LABELS: Record = { // Configure every detected config-file agent (or one forced via `agent`). Returns the labels of // agents now configured (installed or already present) for the caller's summary line. export async function installAgentConfigs(agent?: string, home: string = os.homedir()): Promise { - const url = process.env.INSTA_MCP_URL || DEFAULT_MCP_URL + const { name, url } = await resolveMcpTarget() const targets = agent ? (MCP_AGENT_TARGETS as readonly string[]).includes(agent) ? [agent as McpAgent] : [] : detectAgents(home) @@ -103,8 +103,8 @@ export async function installAgentConfigs(agent?: string, home: string = os.home } const done: string[] = [] for (const slug of targets) { - const result = await installFor(slug, home, url) - if (result === 'skipped') info(` ${AGENT_LABELS[slug]}: existing config at ${configPath(slug, home)} isn't valid JSON — add ${MCP_SERVER_NAME} manually`) + const result = await installFor(slug, home, url, name) + if (result === 'skipped') info(` ${AGENT_LABELS[slug]}: existing config at ${configPath(slug, home)} isn't valid JSON — add ${name} manually`) else done.push(AGENT_LABELS[slug]) } return done diff --git a/src/commands/setup.ts b/src/commands/setup.ts index f37402f..8b96164 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -8,6 +8,8 @@ import { spawn } from 'node:child_process' import os from 'node:os' import { ApiClient } from '../api.js' +import { resolveEnv } from '../config.js' +import { DEFAULT_ENV, ENVS, mcpServerName } from '../env.js' import { info } from '../util.js' import { installAgentConfigs } from './mcp.js' @@ -99,8 +101,19 @@ export const SETUP_ARGS = ['skills', 'add', 'InsForge/insta-skills', '-s', 'inst // ---- remote MCP registration ---- -export const MCP_SERVER_NAME = 'insta-cloud' -export const DEFAULT_MCP_URL = 'https://mcp.instacloud.com/mcp' +// Prod's name/URL, kept as named exports because they are the installed-base defaults and are +// asserted directly by tests. Everything at runtime goes through `resolveMcpTarget()` instead, so +// a staging install registers staging's MCP server under its own name rather than reusing prod's. +export const MCP_SERVER_NAME = mcpServerName(DEFAULT_ENV) +export const DEFAULT_MCP_URL = ENVS[DEFAULT_ENV].mcp + +/** The MCP server this machine should register, derived from the SAME resolved environment as the + * control-plane API. Returning name and url together is deliberate: they must never be chosen + * independently, or a staging machine ends up registering prod's URL under prod's name. */ +export async function resolveMcpTarget(): Promise<{ name: string; url: string }> { + const { env, mcpUrl } = await resolveEnv() + return { name: mcpServerName(env ?? DEFAULT_ENV), url: mcpUrl } +} // Headless fallback only (`--mcp-token`): the MCP config outlives the CLI's refreshable // session, so a static-header registration needs a durable `insta_` API token — minted once, @@ -124,13 +137,13 @@ const defaultMinter: TokenMinter = async () => { // Idempotent — an existing registration is left alone. Best-effort: the skill install is the // primary outcome; agents without an MCP registry are covered by the skill alone. export async function registerMcp(run: Runner = defaultRunner, mint: TokenMinter = defaultMinter, useToken = false): Promise { - const url = process.env.INSTA_MCP_URL || DEFAULT_MCP_URL + const { name, url } = await resolveMcpTarget() if (!(await run('claude', ['--version'])).ok) return // no Claude Code on this machine - if ((await run('claude', ['mcp', 'get', MCP_SERVER_NAME])).ok) { - info(`✓ MCP — ${MCP_SERVER_NAME} already registered with Claude Code`) + if ((await run('claude', ['mcp', 'get', name])).ok) { + info(`✓ MCP — ${name} already registered with Claude Code`) return } - const args = ['mcp', 'add', '--transport', 'http', '--scope', 'user', MCP_SERVER_NAME, url] + const args = ['mcp', 'add', '--transport', 'http', '--scope', 'user', name, url] if (useToken) { const token = await mint() if (!token) { @@ -141,10 +154,10 @@ export async function registerMcp(run: Runner = defaultRunner, mint: TokenMinter } const res = await run('claude', args) if (res.ok) { - info(`✓ MCP — ${MCP_SERVER_NAME} registered with Claude Code (\`claude mcp list\` to verify)`) + info(`✓ MCP — ${name} registered with Claude Code (\`claude mcp list\` to verify)`) if (!useToken) info(' first use: run `/mcp` in Claude Code and authorize in the browser (headless machines: `insta setup agent --mcp-token`)') } else { - info(` MCP registration failed — add manually:\n claude mcp add --transport http ${MCP_SERVER_NAME} ${url}`) + info(` MCP registration failed — add manually:\n claude mcp add --transport http ${name} ${url}`) } } diff --git a/src/config.ts b/src/config.ts index a7edf1d..3e870bf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import { homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { DEFAULT_ENV, ENVS, envForApiUrl, envFromEnvVar, type EnvName } from './env.js' const GLOBAL_DIR = join(homedir(), '.insta') const GLOBAL_FILE = join(GLOBAL_DIR, 'config.json') @@ -21,20 +22,41 @@ export type ProjectConfig = { projectId: string; orgId: string; branch: string } // The cloud API default. Uses the instacloud.com brand domain (matches the agents.instacloud.com // onboarding), NOT the legacy beta-api.insta.insforge.dev host — same backend, branded domain. // Only affects fresh installs: a persisted apiUrl (from a prior login) or INSTA_API_URL wins below. -const DEFAULT_API = 'https://api.instacloud.com' +const DEFAULT_API = ENVS[DEFAULT_ENV].api export async function readGlobal(): Promise { - // INSTA_API_URL overrides the persisted apiUrl, not just the default — otherwise the - // env var is silently ignored as soon as any login has written a config file. + // Precedence, most explicit first: + // 1. INSTA_API_URL — a literal URL. Overrides the persisted apiUrl, not just the default, + // otherwise the env var is silently ignored as soon as any login has written a config file. + // It also outranks INSTA_ENV: a hand-written URL is the more specific instruction, and it + // is the only way to reach a host no environment name covers (insta-oss, a preview). + // 2. INSTA_ENV — a named environment (see env.ts), resolved to its api host. + // 3. the persisted apiUrl, written by `insta login --env|--api-url` or `insta env use`. + // 4. DEFAULT_API. const envApi = process.env.INSTA_API_URL + const named = envFromEnvVar() + const override = envApi ?? (named ? ENVS[named].api : undefined) try { const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8')) as GlobalConfig - return { ...parsed, apiUrl: envApi ?? parsed.apiUrl ?? DEFAULT_API } + return { ...parsed, apiUrl: override ?? parsed.apiUrl ?? DEFAULT_API } } catch { - return { apiUrl: envApi ?? DEFAULT_API } + return { apiUrl: override ?? DEFAULT_API } } } +/** The environment the CLI is currently pointed at, plus its matched hosts. `env` is null when + * apiUrl is a custom host (insta-oss, a preview deployment) — deliberate, and left alone. */ +export async function resolveEnv(): Promise<{ apiUrl: string; env: EnvName | null; mcpUrl: string }> { + const { apiUrl } = await readGlobal() + const env = envForApiUrl(apiUrl) + // INSTA_MCP_URL still wins outright (self-hosted MCP, a tunnel). Otherwise the MCP host is + // derived from the SAME resolved environment as the API — one switch, matched hosts, so the + // two can never drift apart. A custom apiUrl with no INSTA_MCP_URL falls back to the default + // environment's MCP: there is nothing better to guess, and it preserves today's behaviour. + const mcpUrl = process.env.INSTA_MCP_URL || ENVS[env ?? DEFAULT_ENV].mcp + return { apiUrl, env, mcpUrl } +} + export async function writeGlobal(c: GlobalConfig): Promise { await mkdir(GLOBAL_DIR, { recursive: true }) await writeFile(GLOBAL_FILE, JSON.stringify(c, null, 2)) diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..c30dd0c --- /dev/null +++ b/src/env.ts @@ -0,0 +1,70 @@ +// InstaCloud deployment environments. +// +// `prod` and `staging` are SEPARATE deployments — different regions, different databases, different +// Better Auth instances — not one backend behind two names. api.instacloud.com is +// insta-platform-prod-alb (us-east-2); api.staging.instacloud.com is insta-beta-api-lb (us-west-1). +// The practical consequence, learned the hard way when beta-api was retired: a session minted by +// one environment can NEVER authenticate against the other, and its refresh token must never be +// POSTed there. So every environment switch drops the stored session (see config.ts). +// +// Each environment owns a matched set of hosts. They are resolved together, from a single switch, +// because the failure mode of resolving them independently is silent and bad: point the API at +// staging, forget the MCP URL, and the machine talks to staging while its agents talk to prod. +export type EnvName = 'prod' | 'staging' + +export type EnvHosts = { api: string; mcp: string } + +export const ENVS: Record = { + prod: { + api: 'https://api.instacloud.com', + mcp: 'https://mcp.instacloud.com/mcp', + }, + staging: { + api: 'https://api.staging.instacloud.com', + mcp: 'https://mcp.staging.instacloud.com/mcp', + }, +} + +export const DEFAULT_ENV: EnvName = 'prod' + +export const ENV_NAMES = Object.keys(ENVS) as EnvName[] + +export function isEnvName(v: string): v is EnvName { + return (ENV_NAMES as string[]).includes(v) +} + +/** Strip trailing slashes so 'https://x/' and 'https://x' compare equal. */ +export const normalizeUrl = (url: string): string => url.replace(/\/+$/, '') + +/** The environment a persisted apiUrl belongs to, or null for a custom/self-hosted host + * (insta-oss on localhost, a preview deployment). null is not an error — it means "the user + * chose this URL deliberately", and callers must leave such a choice alone. */ +export function envForApiUrl(apiUrl: string): EnvName | null { + const want = normalizeUrl(apiUrl) + return ENV_NAMES.find((n) => normalizeUrl(ENVS[n].api) === want) ?? null +} + +/** $INSTA_ENV, validated. An unrecognised value throws rather than falling back to prod: a + * typo'd `INSTA_ENV=stagng` that silently provisions real infrastructure in production is the + * worst possible outcome, and it would be invisible until the bill arrived. */ +export function envFromEnvVar(raw = process.env.INSTA_ENV): EnvName | null { + if (raw === undefined) return null + const v = raw.trim().toLowerCase() + if (v === '') return null + if (!isEnvName(v)) { + throw new Error(`unknown INSTA_ENV "${raw}" — expected one of: ${ENV_NAMES.join(', ')}`) + } + return v +} + +/** MCP registration name for an environment. + * + * Prod keeps the bare `insta-cloud` name — it is the installed base, and renaming it would + * orphan every existing registration. Other environments get a suffix so they can coexist on + * one machine. This matters more than it looks: `registerMcp` treats an already-registered + * name as "nothing to do", and the config-file agents key their entry by name, so sharing one + * name across environments leaves a staging install silently wired to the prod MCP server + * while reporting success. */ +export function mcpServerName(env: EnvName): string { + return env === DEFAULT_ENV ? 'insta-cloud' : `insta-cloud-${env}` +} diff --git a/src/index.ts b/src/index.ts index 8ccdc6b..aa059c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ import { Command } from 'commander' import { ApiError } from './api.js' import { die } from './util.js' import * as auth from './commands/auth.js' +import * as envCmd_ from './commands/env.js' +import { ENV_NAMES } from './env.js' import * as setup from './commands/setup.js' import * as mcp from './commands/mcp.js' import * as runCmd from './commands/run.js' @@ -56,10 +58,18 @@ program.command('login').description('Log in with email + password, or --oauth < .option('--password ', 'account password (else $INSTA_PASSWORD or prompt)') .option('--oauth ', 'browser OAuth login: github | google') .option('--api-url ', 'control-plane API base URL') + .option('--env ', `deployment environment: ${ENV_NAMES.join(' | ')}`) .action(guard((o) => auth.login(o))) program.command('logout').description('Log out and clear local tokens').action(guard(() => auth.logout())) program.command('status').description('Show login + linked project').option('--json').action(guard((o) => auth.status(o))) +// ---- environment (prod | staging) ---- +const envCmd = program.command('env').description('Show or switch the deployment environment (prod | staging)') +envCmd.command('show', { isDefault: true }).description('Show the current environment and its hosts') + .option('--json').action(guard((o) => envCmd_.envShow(o))) +envCmd.command('use ').description(`Switch environment (${ENV_NAMES.join(' | ')}) — drops the stored session, which is deployment-specific`) + .action(guard((name) => envCmd_.envUse(name))) + // ---- run (per-request secret injection — nothing written to disk) ---- program.command('run [args...]').description('Run a command with the branch credential bundle injected into its environment (no .env written)') .option('--branch ', 'branch bundle to inject (default: linked branch)') diff --git a/test/env-switch.test.ts b/test/env-switch.test.ts new file mode 100644 index 0000000..65609f2 --- /dev/null +++ b/test/env-switch.test.ts @@ -0,0 +1,251 @@ +// Environment resolution (prod | staging) and the guarantees that keep the two from bleeding into +// each other: matched api+mcp hosts, distinct MCP registration names, and a dropped session on +// every switch (prod and staging are separate deployments — a token from one is useless and +// dangerous at the other). +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, readFile, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, envFromEnvVar, isEnvName, mcpServerName, +} from '../src/env.js' + +const PROD_API = 'https://api.instacloud.com' +const STAGING_API = 'https://api.staging.instacloud.com' + +describe('env table', () => { + it('has a matched api + mcp host for every environment', () => { + for (const name of ENV_NAMES) { + expect(ENVS[name].api).toMatch(/^https:\/\//) + expect(ENVS[name].mcp).toMatch(/^https:\/\/.*\/mcp$/) + } + }) + + it('points prod and staging at genuinely different hosts', () => { + expect(ENVS.prod.api).toBe(PROD_API) + expect(ENVS.staging.api).toBe(STAGING_API) + expect(ENVS.prod.mcp).not.toBe(ENVS.staging.mcp) + }) + + // The whole point of resolving api+mcp from one switch: staging's mcp host must sit under the + // staging domain, so a staging install can't end up talking to prod's MCP server. + it('keeps each environment\'s mcp host under that environment\'s domain', () => { + expect(ENVS.staging.mcp).toContain('staging.instacloud.com') + expect(ENVS.prod.mcp).not.toContain('staging') + }) +}) + +describe('mcpServerName', () => { + it('keeps the bare name for prod so existing registrations are not orphaned', () => { + expect(mcpServerName('prod')).toBe('insta-cloud') + }) + + // registerMcp treats an existing name as "already done", so a shared name would leave a staging + // install silently wired to prod. Distinct names let both coexist on one machine. + it('gives staging its own name so it can coexist with prod', () => { + expect(mcpServerName('staging')).toBe('insta-cloud-staging') + expect(mcpServerName('staging')).not.toBe(mcpServerName('prod')) + }) +}) + +describe('envForApiUrl', () => { + it('maps known hosts back to their environment', () => { + expect(envForApiUrl(PROD_API)).toBe('prod') + expect(envForApiUrl(STAGING_API)).toBe('staging') + }) + + it('ignores a trailing slash', () => { + expect(envForApiUrl(STAGING_API + '/')).toBe('staging') + }) + + // A localhost/self-hosted URL is a deliberate choice, not an error — callers must leave it alone. + it('returns null for a custom host', () => { + expect(envForApiUrl('http://localhost:8080')).toBeNull() + expect(envForApiUrl('https://beta-api.insta.insforge.dev')).toBeNull() + }) + + // staging.instacloud.com is a deeper label than instacloud.com; a sloppy suffix match would + // classify the staging host as prod and provision against the wrong control plane. + it('does not let the prod host swallow the staging host', () => { + expect(envForApiUrl(STAGING_API)).not.toBe('prod') + }) +}) + +describe('envFromEnvVar', () => { + it('accepts the known names, case- and whitespace-insensitively', () => { + expect(envFromEnvVar('staging')).toBe('staging') + expect(envFromEnvVar(' STAGING ')).toBe('staging') + expect(envFromEnvVar('prod')).toBe('prod') + }) + + it('treats unset and empty as no opinion', () => { + expect(envFromEnvVar(undefined)).toBeNull() + expect(envFromEnvVar('')).toBeNull() + expect(envFromEnvVar(' ')).toBeNull() + }) + + // Silently falling back to prod on a typo would provision real production infrastructure and + // stay invisible until the bill arrived. Fail loudly instead. + it('throws on an unknown value rather than falling back to prod', () => { + expect(() => envFromEnvVar('stagng')).toThrow(/unknown INSTA_ENV/) + expect(() => envFromEnvVar('production')).toThrow(/unknown INSTA_ENV/) + }) +}) + +describe('isEnvName', () => { + it('accepts only the declared environments', () => { + expect(isEnvName('prod')).toBe(true) + expect(isEnvName('staging')).toBe(true) + expect(isEnvName('dev')).toBe(false) + }) + + it('defaults to prod', () => { + expect(DEFAULT_ENV).toBe('prod') + }) +}) + +// ---- config resolution + `env use`, against a real temp $HOME ---- + +describe('config + env use', () => { + let home: string + const origHome = process.env.HOME + const origEnv = process.env.INSTA_ENV + const origApi = process.env.INSTA_API_URL + const origMcp = process.env.INSTA_MCP_URL + + const configFile = () => join(home, '.insta', 'config.json') + const writeConfig = async (c: unknown) => { + await mkdir(join(home, '.insta'), { recursive: true }) + await writeFile(configFile(), JSON.stringify(c, null, 2)) + } + const readConfig = async () => JSON.parse(await readFile(configFile(), 'utf8')) + + // config.ts resolves $HOME at import time, so each test needs a fresh module registry. + const freshConfig = async () => { + vi.resetModules() + return await import('../src/config.js') + } + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'insta-env-')) + process.env.HOME = home + delete process.env.INSTA_ENV + delete process.env.INSTA_API_URL + delete process.env.INSTA_MCP_URL + }) + + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome + if (origEnv === undefined) delete process.env.INSTA_ENV; else process.env.INSTA_ENV = origEnv + if (origApi === undefined) delete process.env.INSTA_API_URL; else process.env.INSTA_API_URL = origApi + if (origMcp === undefined) delete process.env.INSTA_MCP_URL; else process.env.INSTA_MCP_URL = origMcp + vi.resetModules() + }) + + it('defaults a fresh install to prod', async () => { + const { readGlobal, resolveEnv } = await freshConfig() + expect((await readGlobal()).apiUrl).toBe(PROD_API) + expect(await resolveEnv()).toMatchObject({ env: 'prod', apiUrl: PROD_API, mcpUrl: ENVS.prod.mcp }) + }) + + it('resolves INSTA_ENV=staging to staging api AND mcp together', async () => { + process.env.INSTA_ENV = 'staging' + const { resolveEnv } = await freshConfig() + expect(await resolveEnv()).toMatchObject({ + env: 'staging', apiUrl: STAGING_API, mcpUrl: ENVS.staging.mcp, + }) + }) + + it('lets INSTA_ENV override a persisted prod apiUrl', async () => { + await writeConfig({ apiUrl: PROD_API, accessToken: 't' }) + process.env.INSTA_ENV = 'staging' + const { readGlobal } = await freshConfig() + expect((await readGlobal()).apiUrl).toBe(STAGING_API) + }) + + // A hand-written URL is the more specific instruction, and the only way to reach a host no + // environment name covers (insta-oss, a preview deployment). + it('lets INSTA_API_URL outrank INSTA_ENV', async () => { + process.env.INSTA_ENV = 'staging' + process.env.INSTA_API_URL = 'http://localhost:9999' + const { resolveEnv } = await freshConfig() + const r = await resolveEnv() + expect(r.apiUrl).toBe('http://localhost:9999') + expect(r.env).toBeNull() + }) + + it('lets INSTA_MCP_URL override the environment mcp host', async () => { + process.env.INSTA_ENV = 'staging' + process.env.INSTA_MCP_URL = 'http://localhost:1234/mcp' + const { resolveEnv } = await freshConfig() + expect(await resolveEnv()).toMatchObject({ env: 'staging', mcpUrl: 'http://localhost:1234/mcp' }) + }) + + it('honours a persisted staging apiUrl with no env vars set', async () => { + await writeConfig({ apiUrl: STAGING_API }) + const { resolveEnv } = await freshConfig() + expect(await resolveEnv()).toMatchObject({ env: 'staging', mcpUrl: ENVS.staging.mcp }) + }) + + it('surfaces a bad INSTA_ENV as an error instead of using prod', async () => { + process.env.INSTA_ENV = 'nope' + const { readGlobal } = await freshConfig() + await expect(readGlobal()).rejects.toThrow(/unknown INSTA_ENV/) + }) + + it('env use persists the switch so it survives the install pipe', async () => { + await writeConfig({ apiUrl: PROD_API }) + vi.resetModules() + const { envUse } = await import('../src/commands/env.js') + await envUse('staging') + expect((await readConfig()).apiUrl).toBe(STAGING_API) + }) + + // api.ts's 401 path POSTs the refresh token to whatever apiUrl now resolves to, so carrying a + // session across a switch would hand one deployment's credential to another. + it('env use drops the stored session when changing deployment', async () => { + await writeConfig({ apiUrl: PROD_API, accessToken: 'a', refreshToken: 'r', user: { id: 'u', email: null, name: null } }) + vi.resetModules() + const { envUse } = await import('../src/commands/env.js') + await envUse('staging') + const c = await readConfig() + expect(c.apiUrl).toBe(STAGING_API) + expect(c.accessToken).toBeUndefined() + expect(c.refreshToken).toBeUndefined() + expect(c.user).toBeUndefined() + }) + + it('env use is a no-op that keeps the session when already on that environment', async () => { + await writeConfig({ apiUrl: STAGING_API, accessToken: 'a', refreshToken: 'r' }) + vi.resetModules() + const { envUse } = await import('../src/commands/env.js') + await envUse('staging') + const c = await readConfig() + expect(c.apiUrl).toBe(STAGING_API) + expect(c.accessToken).toBe('a') + }) + + // `die` exits the process, so trap it rather than letting it take the test worker down with it. + it('env use rejects an unknown name', async () => { + vi.resetModules() + const { envUse } = await import('../src/commands/env.js') + const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`) + }) as never) + const err = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + await expect(envUse('stagng')).rejects.toThrow(/exit 1/) + } finally { + exit.mockRestore() + err.mockRestore() + } + }) + + it('preserves unrelated config keys across a switch', async () => { + await writeConfig({ apiUrl: PROD_API, autoUpdate: false }) + vi.resetModules() + const { envUse } = await import('../src/commands/env.js') + await envUse('staging') + expect((await readConfig()).autoUpdate).toBe(false) + }) +}) From 9729b6b81caffc02090b07c142bf27d053c23855 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:19:25 +0800 Subject: [PATCH 2/5] feat(env): make the staging one-liner install staging CLI + skills too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes `curl -fsSL agents.staging.instacloud.com | sh` so every piece it installs is staging, not just the API endpoint: CLI newest PRERELEASE build (v*-rc.N), falling back to latest stable API api.staging.instacloud.com MCP mcp.staging.instacloud.com, registered as `insta-cloud-staging` skills InsForge/insta-skills@devel Skill source joins the environment table, so `resolveEnv()` now returns api, mcp AND skills from one switch. Resolving them independently is silently wrong: a machine whose CLI talks to staging while its agents read prod's skill text describing a control plane it isn't using. `npx skills add` has no --ref/--branch flag but does accept `owner/repo@ref` (verified: it resolves to `…insta-skills.git @devel`), so the ref rides along in the spec string. `INSTA_SKILLS_REPO` overrides it, matching how INSTA_MCP_URL already works. Both skill-install paths follow the environment — `setup agent` (machine-global, the installer's path) and `ensure-skills` (per-project, on project create/link). ensure-skills falls back to prod's targets if the resolve throws, since it is best-effort and must never block the host command. The third-party stack skills (neon/tigris/better-auth) are untouched — they document those services, not our control plane. Release-channel plumbing, and two latent hazards fixed while adding it: - release.yml created every tag as a normal release, so a `v0.0.23-rc.1` tag would have been published as **"Latest"** and handed to every production installer by install.sh's /releases/latest lookup. Hyphenated (semver prerelease) tags now get --prerelease. - publish-npm ran a bare `npm publish`, which would have given a prerelease the `latest` dist-tag — so `npm i -g insta` would install a staging build. Those now publish under `next`. install.sh --staging resolves the newest non-draft prerelease via the releases list (/releases/latest deliberately excludes prereleases) and falls back to stable with a printed note when none exists — which is the case today, since no prerelease has ever been published. INSTA_VERSION still wins outright. Verified: typecheck clean, 146 tests passing (20 files, 6 new); prod's `setup agent` command sequence is byte-identical to before; staging's resolves to `npx skills add InsForge/insta-skills@devel …` + `claude mcp get insta-cloud-staging`; prerelease parser checked against fixtures (picks newest non-draft prerelease, skips drafts, empty when none) and the live API; installer arg matrix and channel fallback exercised directly; sh -n clean on all three scripts; release.yml still parses. --- .github/workflows/release.yml | 19 ++++++++++++-- README.md | 25 +++++++++++------- agents-staging.sh | 16 ++++++++---- install.sh | 37 ++++++++++++++++++++++++-- src/commands/env.ts | 10 ++++--- src/commands/setup.ts | 21 ++++++++++++--- src/config.ts | 28 +++++++++++++------- src/ensure-skills.ts | 23 +++++++++++++--- src/env.ts | 12 ++++++++- test/env-switch.test.ts | 49 +++++++++++++++++++++++++++++++++++ 10 files changed, 201 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d86573a..2d56fe6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,13 +85,21 @@ jobs: GH_TOKEN: ${{ github.token }} run: | TAG="${{ steps.t.outputs.tag }}" + # A semver prerelease tag (v0.0.23-rc.1, v1.0.0-beta.2 — anything with a hyphen) MUST be + # published with --prerelease. Without it GitHub marks the release "Latest", and + # install.sh resolves /releases/latest for the default channel — so an unmarked + # prerelease would be handed to every PRODUCTION installer. The staging channel + # (install.sh --staging) deliberately looks for the newest prerelease instead. + PRERELEASE="" + case "$TAG" in *-*) PRERELEASE="--prerelease" ;; esac # Create the release if it doesn't exist (dispatch also creates the tag), else just upload. if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + # shellcheck disable=SC2086 # $PRERELEASE is a single intentional flag or empty gh release create "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --target "$GITHUB_SHA" \ --title "insta $TAG" \ - --generate-notes + --generate-notes $PRERELEASE fi gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \ dist-bin/insta-* dist-bin/SHA256SUMS install.sh @@ -118,4 +126,11 @@ jobs: - run: npm install -g npm@latest - run: npm ci - name: Publish to npm (OIDC) - run: npm publish --provenance --access public + run: | + # Mirror the GitHub-release rule: a prerelease version must NOT take the `latest` + # dist-tag, or `npm i -g insta` (and `npx insta@latest`) would hand a staging build to + # production users. Publish those under `next` instead — `npm i -g insta@next` opts in. + TAG_ARG="" + case "${GITHUB_REF_NAME}" in *-*) TAG_ARG="--tag next" ;; esac + # shellcheck disable=SC2086 # $TAG_ARG is a single intentional flag pair or empty + npm publish --provenance --access public $TAG_ARG diff --git a/README.md b/README.md index 091bf53..6937747 100644 --- a/README.md +++ b/README.md @@ -68,29 +68,36 @@ insta status # login state + linked project/branch different auth. A session from one cannot authenticate against the other, so switching drops the stored session and you log in again. -| Environment | Control plane | MCP server | Registers as | -|---|---|---|---| -| `prod` (default) | `api.instacloud.com` (us-east-2) | `mcp.instacloud.com/mcp` | `insta-cloud` | -| `staging` | `api.staging.instacloud.com` (us-west-1) | `mcp.staging.instacloud.com/mcp` | `insta-cloud-staging` | +| | `prod` (default) | `staging` | +|---|---|---| +| control plane | `api.instacloud.com` (us-east-2) | `api.staging.instacloud.com` (us-west-1) | +| MCP server | `mcp.instacloud.com/mcp` | `mcp.staging.instacloud.com/mcp` | +| registers as | `insta-cloud` | `insta-cloud-staging` | +| agent skills | `InsForge/insta-skills` | `InsForge/insta-skills@devel` | +| CLI channel | latest stable release | newest prerelease (`v*-rc.N`), else stable | ```bash -insta env # show the current environment and its hosts +insta env # show the current environment and everything derived from it insta env use staging # switch (persisted to ~/.insta/config.json) insta login --env staging --oauth github ``` -The API and MCP hosts are always resolved together from one switch, so the CLI and your agents can -never end up pointed at different environments. Distinct MCP registration names mean both can be -installed on the same machine at once. +Control plane, MCP host **and** skill source are all resolved from one switch, so a machine can +never end up with its CLI on staging while its agents talk to prod and read prod's skill text. +Distinct MCP registration names mean both environments can be installed side by side. Resolution order, most specific first: 1. `INSTA_API_URL` — a literal URL. The only way to reach a host no environment name covers - (`insta-oss` on localhost, a preview deployment). `INSTA_MCP_URL` does the same for MCP. + (`insta-oss` on localhost, a preview deployment). `INSTA_MCP_URL` and `INSTA_SKILLS_REPO` do the + same for the MCP host and the skill source. 2. `INSTA_ENV` — `prod` | `staging`. An unrecognised value is an error, never a silent fallback. 3. the persisted `apiUrl` in `~/.insta/config.json`. 4. `prod`. +Prereleases never take the `latest` GitHub release or the `latest` npm dist-tag — they publish with +`--prerelease` and under npm's `next` tag — so a staging build can't reach production installers. + ## Commands | Command | Description | diff --git a/agents-staging.sh b/agents-staging.sh index 839b27e..980b217 100644 --- a/agents-staging.sh +++ b/agents-staging.sh @@ -8,12 +8,18 @@ # agents.instacloud.com → agents.sh. The raw fallback also works: # curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/agents-staging.sh | sh) # -# Identical to agents.sh except that it targets the staging deployment -# (api/mcp.staging.instacloud.com, us-west-1) instead of production (us-east-2). +# Identical to agents.sh except that everything it installs points at staging: # -# Note this ships from the SAME main branch as agents.sh — the staging/prod split here is about -# which control plane the CLI talks to, NOT which build of the CLI you get. You always get the -# current released binary; use INSTA_VERSION to pin a different one. +# CLI the newest PRERELEASE build (v*-rc.N), falling back to the latest stable if none +# exists yet. INSTA_VERSION pins an exact tag and overrides this. +# API api.staging.instacloud.com (us-west-1, a separate deployment from prod) +# MCP mcp.staging.instacloud.com, registered as `insta-cloud-staging` so it can coexist +# with a production registration on the same machine +# skills the staging ref of InsForge/insta-skills, so the agent reads skill text that +# describes the staging control plane +# +# One command, and every piece is staging. This script itself ships from `main` (same as +# agents.sh) — it is the installer, not the thing being staged. # # --staging is passed to install.sh (rather than exporting INSTA_ENV around it) so the choice is # PERSISTED into ~/.insta/config.json. A piped `curl … | sh` cannot export into the parent shell, diff --git a/install.sh b/install.sh index 460c584..38ca4fa 100644 --- a/install.sh +++ b/install.sh @@ -14,10 +14,16 @@ # --staging target the staging deployment (shorthand for --env staging) # --env target a named deployment: prod (default) | staging # +# Release channels: +# prod (default) the latest stable release +# staging the newest PRERELEASE (v*-rc.N etc), falling back to stable if none exists +# Either way, INSTA_VERSION pins an exact tag and always wins. +# # Options (env): -# INSTA_VERSION release tag to install (e.g. v0.1.0); default: latest +# INSTA_VERSION release tag to install (e.g. v0.1.0); default: the channel's newest # INSTA_INSTALL_DIR install directory; default: $HOME/.insta/bin # INSTA_ENV same as --env; the flag wins if both are given +# INSTA_SKILLS_REPO override the agent-skill source (default: per-environment, see src/env.ts) set -eu AGENTS=0 @@ -48,11 +54,38 @@ INSTALL_DIR="${INSTA_INSTALL_DIR:-$HOME/.insta/bin}" command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; } -# ---- already current? (skip the download; Railway-style existing-install awareness) ---- +# ---- release channel resolution ---- resolve_latest() { curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \ | sed -n 's/.*"tag_name": *"\(v[^"]*\)".*/\1/p' | head -1 } + +# Newest PRERELEASE tag = the staging channel. /releases/latest deliberately excludes prereleases, +# so the list endpoint is the only way to find them. Within a release object GitHub emits tag_name +# before draft/prerelease, so we remember the tag and act when the flags arrive; a draft clears it +# (drafts have no downloadable assets). +resolve_prerelease() { + curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=30" 2>/dev/null | awk ' + /"tag_name":/ { if (match($0, /v[^"]+/)) tag = substr($0, RSTART, RLENGTH) } + /"draft": *true/ { tag = "" } + /"prerelease": *true/ { if (tag != "") { print tag; exit } }' +} + +# Staging tracks the prerelease channel so it can run a build that has not shipped to production. +# An explicit INSTA_VERSION always wins. If no prerelease exists yet, say so and fall back to +# stable rather than failing — staging is still perfectly usable on the released binary, since the +# environment split is about which control plane the CLI talks to. +if [ "$ENV_NAME" = "staging" ] && [ -z "${INSTA_VERSION:-}" ]; then + pre_tag="$(resolve_prerelease || true)" + if [ -n "$pre_tag" ]; then + INSTA_VERSION="$pre_tag" + echo "staging channel: prerelease $pre_tag" + else + echo "note: no prerelease published yet — installing the latest stable build (staging control plane either way)" + fi +fi + +# ---- already current? (skip the download; Railway-style existing-install awareness) ---- if [ -x "$INSTALL_DIR/$BIN" ] && [ -z "${INSTA_VERSION:-}" ]; then current="v$("$INSTALL_DIR/$BIN" --version 2>/dev/null | tail -1)" latest="$(resolve_latest || true)" diff --git a/src/commands/env.ts b/src/commands/env.ts index 6ac796a..5b561cc 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -7,15 +7,17 @@ // same file `login --api-url` already writes, so this adds a surface, not a concept. import { ApiClient } from '../api.js' import { resolveEnv } from '../config.js' -import { ENVS, ENV_NAMES, envForApiUrl, isEnvName, mcpServerName, type EnvName } from '../env.js' +import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, isEnvName, mcpServerName, type EnvName } from '../env.js' import { die, info, printJson } from '../util.js' export async function envShow(opts: { json?: boolean }): Promise { - const { apiUrl, env, mcpUrl } = await resolveEnv() - if (opts.json) return printJson({ env, apiUrl, mcpUrl, mcpServer: mcpServerName(env ?? 'prod') }) + const { apiUrl, env, mcpUrl, skills } = await resolveEnv() + const mcpServer = mcpServerName(env ?? DEFAULT_ENV) + if (opts.json) return printJson({ env, apiUrl, mcpUrl, mcpServer, skills }) info(`env: ${env ?? '(custom)'}`) info(`api: ${apiUrl}`) - info(`mcp: ${mcpUrl}`) + info(`mcp: ${mcpUrl} (${mcpServer})`) + info(`skills: ${skills}`) if (!env) info(' (custom apiUrl — `insta env use ` to switch to a named environment)') } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8b96164..f0d1f0e 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -97,7 +97,14 @@ const defaultRunner: Runner = (cmd, args) => // -g = user-level (machine-global); -a '*' = every agent dir the skills tool supports // (Claude Code, Codex, Cursor, OpenCode, Copilot, …); --copy = real files, not cache symlinks. -export const SETUP_ARGS = ['skills', 'add', 'InsForge/insta-skills', '-s', 'insta', '-a', '*', '-g', '-y', '--copy'] +// `spec` is the skill source for the resolved environment (`owner/repo` or `owner/repo@ref`), so a +// staging install reads the staging skill text rather than what's published on main. +export const setupArgs = (spec: string): string[] => + ['skills', 'add', spec, '-s', 'insta', '-a', '*', '-g', '-y', '--copy'] + +/** Production's args. Kept as a named export because it is the installed-base default and is + * asserted directly by tests; runtime goes through `setupArgs(resolveEnv().skills)`. */ +export const SETUP_ARGS = setupArgs(ENVS[DEFAULT_ENV].skills) // ---- remote MCP registration ---- @@ -170,11 +177,17 @@ export async function setupAgent( if (!opts.yes && !process.stdout.isTTY) { info('non-interactive shell — assuming -y') } - info('setting up coding-agent skills …') - const res = await run('npx', SETUP_ARGS) + // One resolve for the whole step, so the skills and the MCP registration below cannot disagree + // about which environment this machine belongs to. + const { env, skills } = await resolveEnv() + const args = setupArgs(skills) + info(env && env !== DEFAULT_ENV + ? `setting up coding-agent skills (${env}) …` + : 'setting up coding-agent skills …') + const res = await run('npx', args) if (!res.ok) { info(' skill install failed — install manually with:') - info(' npx skills add InsForge/insta-skills -s insta -a "*" -g -y --copy') + info(` npx ${args.map((a) => (a === '*' ? '"*"' : a)).join(' ')}`) // Surface the REAL error: the captured tail, minus the expected no-global-support noise. const tail = (res.output ?? '') .split('\n') diff --git a/src/config.ts b/src/config.ts index 3e870bf..5e991cb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,17 +44,27 @@ export async function readGlobal(): Promise { } } -/** The environment the CLI is currently pointed at, plus its matched hosts. `env` is null when - * apiUrl is a custom host (insta-oss, a preview deployment) — deliberate, and left alone. */ -export async function resolveEnv(): Promise<{ apiUrl: string; env: EnvName | null; mcpUrl: string }> { +/** The environment the CLI is currently pointed at, plus everything derived from it. `env` is null + * when apiUrl is a custom host (insta-oss, a preview deployment) — deliberate, and left alone. + * + * API host, MCP host, and skill source are resolved from ONE environment on purpose: the failure + * mode of picking them independently is silent (a machine whose CLI talks to staging while its + * agents are wired to prod and reading prod's skill text). */ +export async function resolveEnv(): Promise<{ + apiUrl: string + env: EnvName | null + mcpUrl: string + skills: string +}> { const { apiUrl } = await readGlobal() const env = envForApiUrl(apiUrl) - // INSTA_MCP_URL still wins outright (self-hosted MCP, a tunnel). Otherwise the MCP host is - // derived from the SAME resolved environment as the API — one switch, matched hosts, so the - // two can never drift apart. A custom apiUrl with no INSTA_MCP_URL falls back to the default - // environment's MCP: there is nothing better to guess, and it preserves today's behaviour. - const mcpUrl = process.env.INSTA_MCP_URL || ENVS[env ?? DEFAULT_ENV].mcp - return { apiUrl, env, mcpUrl } + const hosts = ENVS[env ?? DEFAULT_ENV] + // Each single-purpose env var still wins outright, for a self-hosted MCP / a tunnel / a skills + // fork. A custom apiUrl with none of them set falls back to the default environment, since + // there is nothing better to guess and it preserves today's behaviour. + const mcpUrl = process.env.INSTA_MCP_URL || hosts.mcp + const skills = process.env.INSTA_SKILLS_REPO || hosts.skills + return { apiUrl, env, mcpUrl, skills } } export async function writeGlobal(c: GlobalConfig): Promise { diff --git a/src/ensure-skills.ts b/src/ensure-skills.ts index 8e97bb2..198c56c 100644 --- a/src/ensure-skills.ts +++ b/src/ensure-skills.ts @@ -7,6 +7,8 @@ import { spawn } from 'node:child_process' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { resolveEnv } from './config.js' +import { DEFAULT_ENV, ENVS } from './env.js' // Where `npx skills add` drops skills for the agents we pin below: Claude Code → .claude/skills/, // Codex → .agents/skills/ (.github/skills/ is the third well-known dir). These are regenerable @@ -34,8 +36,12 @@ const defaultRunner: Runner = (cmd, args, inherit = false) => // name the exact skills (-s …) so there's no skill picker; -y to skip the scope/confirm prompt; // --copy to write real files (not symlinks into a transient npx cache). const AGENT_FLAGS = ['-a', 'claude-code', '-a', 'codex', '-y', '--copy'] -const SKILLS: Array<{ label: string; args: string[] }> = [ - { label: 'insta', args: ['skills', 'add', 'InsForge/insta-skills', '-s', 'insta', ...AGENT_FLAGS] }, + +// `instaSpec` is the insta skill source for the resolved environment (`owner/repo[@ref]`), so a +// project created against staging gets the staging skill text. The third-party stack skills are +// environment-independent — they document Neon/Tigris/Better Auth, not our control plane. +const skillTargets = (instaSpec: string): Array<{ label: string; args: string[] }> => [ + { label: 'insta', args: ['skills', 'add', instaSpec, '-s', 'insta', ...AGENT_FLAGS] }, { label: 'neon-postgres', args: ['skills', 'add', 'neondatabase/agent-skills', '-s', 'neon-postgres', ...AGENT_FLAGS] }, { label: 'tigris', args: ['skills', 'add', 'tigrisdata/skills', '-s', 'tigris-object-operations', '-s', 'file-storage', '-s', 'tigris-sdk-guide', @@ -47,6 +53,9 @@ const SKILLS: Array<{ label: string; args: string[] }> = [ '-s', 'better-auth-security-best-practices', ...AGENT_FLAGS] }, ] +/** Production's targets — kept for tests and as the fallback when no env resolves. */ +const SKILLS = skillTargets(ENVS[DEFAULT_ENV].skills) + type Deps = { cwd: string; run?: Runner; print?: (s: string) => void } // Install all related skills. Production omits `run`/`print` → the real spawn + stdout; tests inject @@ -56,8 +65,16 @@ export async function installSkills(deps: Deps): Promise { const run = deps.run ?? defaultRunner const print = deps.print ?? ((s: string) => process.stdout.write(s + '\n')) try { + // Resolve once per call so the insta skill follows this machine's environment. Falls back to + // production's targets if anything about the resolve fails — a bad read must not skip the + // whole best-effort install. + let targets = SKILLS + try { + const { skills } = await resolveEnv() + targets = skillTargets(skills) + } catch { /* keep production defaults */ } print(' installing related agent skills (insta, neon-postgres, tigris, better-auth) …') - for (const s of SKILLS) { + for (const s of targets) { // Don't stream: the `skills` tool's clack UI (clone spinner, banners) is noise. Run it // silent (stdio 'ignore') and let the per-skill ✓/failed line below be the clean output — // it appears as each skill finishes, so there's still live progress. (Also avoids the diff --git a/src/env.ts b/src/env.ts index c30dd0c..1d6e38e 100644 --- a/src/env.ts +++ b/src/env.ts @@ -12,16 +12,26 @@ // staging, forget the MCP URL, and the machine talks to staging while its agents talk to prod. export type EnvName = 'prod' | 'staging' -export type EnvHosts = { api: string; mcp: string } +export type EnvHosts = { + api: string + mcp: string + // The agent-skill source passed to `npx skills add`, as `owner/repo` or `owner/repo@ref`. + // Staging pins the integration branch so a staging install gets the skill text that documents + // the staging control plane, rather than whatever is published on main. The `@ref` form is + // supported by the skills tool (it resolves to `…insta-skills.git @`). + skills: string +} export const ENVS: Record = { prod: { api: 'https://api.instacloud.com', mcp: 'https://mcp.instacloud.com/mcp', + skills: 'InsForge/insta-skills', }, staging: { api: 'https://api.staging.instacloud.com', mcp: 'https://mcp.staging.instacloud.com/mcp', + skills: 'InsForge/insta-skills@devel', }, } diff --git a/test/env-switch.test.ts b/test/env-switch.test.ts index 65609f2..3975d3d 100644 --- a/test/env-switch.test.ts +++ b/test/env-switch.test.ts @@ -33,6 +33,21 @@ describe('env table', () => { expect(ENVS.staging.mcp).toContain('staging.instacloud.com') expect(ENVS.prod.mcp).not.toContain('staging') }) + + it('gives every environment a skill source', () => { + for (const name of ENV_NAMES) { + expect(ENVS[name].skills).toMatch(/^[\w.-]+\/[\w.-]+(@[\w./-]+)?$/) + } + }) + + // Prod must stay unpinned (whatever is published on the default branch); staging pins a ref so + // an installer run reads the staging skill text. `owner/repo@ref` is what the skills tool accepts. + it('pins staging skills to a ref and leaves prod unpinned', () => { + expect(ENVS.prod.skills).toBe('InsForge/insta-skills') + expect(ENVS.prod.skills).not.toContain('@') + expect(ENVS.staging.skills).toContain('@') + expect(ENVS.staging.skills.split('@')[0]).toBe('InsForge/insta-skills') + }) }) describe('mcpServerName', () => { @@ -112,6 +127,7 @@ describe('config + env use', () => { const origEnv = process.env.INSTA_ENV const origApi = process.env.INSTA_API_URL const origMcp = process.env.INSTA_MCP_URL + const origSkills = process.env.INSTA_SKILLS_REPO const configFile = () => join(home, '.insta', 'config.json') const writeConfig = async (c: unknown) => { @@ -132,6 +148,7 @@ describe('config + env use', () => { delete process.env.INSTA_ENV delete process.env.INSTA_API_URL delete process.env.INSTA_MCP_URL + delete process.env.INSTA_SKILLS_REPO }) afterEach(() => { @@ -139,6 +156,7 @@ describe('config + env use', () => { if (origEnv === undefined) delete process.env.INSTA_ENV; else process.env.INSTA_ENV = origEnv if (origApi === undefined) delete process.env.INSTA_API_URL; else process.env.INSTA_API_URL = origApi if (origMcp === undefined) delete process.env.INSTA_MCP_URL; else process.env.INSTA_MCP_URL = origMcp + if (origSkills === undefined) delete process.env.INSTA_SKILLS_REPO; else process.env.INSTA_SKILLS_REPO = origSkills vi.resetModules() }) @@ -174,6 +192,37 @@ describe('config + env use', () => { expect(r.env).toBeNull() }) + // The point of one switch: api, mcp AND skills all move together, so a staging machine can never + // end up with prod's skill text describing a control plane it isn't talking to. + it('resolves api, mcp and skills together for staging', async () => { + process.env.INSTA_ENV = 'staging' + const { resolveEnv } = await freshConfig() + expect(await resolveEnv()).toMatchObject({ + env: 'staging', + apiUrl: STAGING_API, + mcpUrl: ENVS.staging.mcp, + skills: ENVS.staging.skills, + }) + }) + + it('uses prod skills by default', async () => { + const { resolveEnv } = await freshConfig() + expect((await resolveEnv()).skills).toBe(ENVS.prod.skills) + }) + + it('resolves staging skills from a persisted staging apiUrl (the installer path)', async () => { + await writeConfig({ apiUrl: STAGING_API }) + const { resolveEnv } = await freshConfig() + expect((await resolveEnv()).skills).toBe(ENVS.staging.skills) + }) + + it('lets INSTA_SKILLS_REPO override the environment skill source', async () => { + process.env.INSTA_ENV = 'staging' + process.env.INSTA_SKILLS_REPO = 'me/my-skills@wip' + const { resolveEnv } = await freshConfig() + expect(await resolveEnv()).toMatchObject({ env: 'staging', skills: 'me/my-skills@wip' }) + }) + it('lets INSTA_MCP_URL override the environment mcp host', async () => { process.env.INSTA_ENV = 'staging' process.env.INSTA_MCP_URL = 'http://localhost:1234/mcp' From b30429dbf54aa98fb91d568292e5fc45c1aad0d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:44:37 +0800 Subject: [PATCH 3/5] fix(install): fail loudly when the requested environment can't be applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by actually running `sh install.sh --staging` against the released binary. v0.0.22 has no `insta env` command, so `insta env use staging` failed — and the installer then printed Environment: staging (api.staging.instacloud.com) — persisted anyway, because the banner was gated on $ENV_NAME rather than on whether the step succeeded. The install was still pointed at PRODUCTION while telling the user it was on staging. That is the exact failure this whole PR exists to prevent, and it is worst in the canonical usage — `curl … | sh && insta project create`, frequently run unattended by an agent — which would have provisioned real production infrastructure believing it was staging. Now: a requested-but-unappliable environment is a hard error (exit 1) naming the version requirement, and it exits BEFORE `setup agent`, so the machine's agents are never wired to the wrong environment either. The banner additionally checks ENV_APPLIED, so it can't outlive the step it describes. This is expected to fire for `--staging` until a release containing `insta env` ships — failing is the correct behaviour there, and it self-resolves on release. Verified with the real installer and a bun-compiled branch binary: - released 0.0.22 + --staging -> error, exit 1, no false banner - branch build + --staging -> "switched prod → staging", banner printed, ~/.insta/config.json apiUrl = staging - end to end: env use staging -> `insta env --json` reports staging api/mcp/ skills -> `insta mcp install --agent cursor` writes insta-cloud-staging -> https://mcp.staging.instacloud.com/mcp --- install.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 38ca4fa..ac0d188 100644 --- a/install.sh +++ b/install.sh @@ -198,8 +198,18 @@ fi # agents pointed at production's MCP server while the CLI talked to staging. if [ -n "$ENV_NAME" ]; then echo - "$INSTALL_DIR/$BIN" env use "$ENV_NAME" \ - || echo "warn: could not select environment '$ENV_NAME' — run: insta env use $ENV_NAME" + if ! "$INSTALL_DIR/$BIN" env use "$ENV_NAME"; then + # HARD FAIL, deliberately. The environment was requested and could not be applied, so this + # install is still pointed at PRODUCTION. Carrying on would be the worst outcome: the canonical + # usage is `curl … | sh && insta project create`, often run unattended by an agent, which would + # then provision real production infrastructure believing it was staging. Exiting here also + # stops `setup agent` from wiring this machine's agents to the wrong environment. + echo "error: could not select environment '$ENV_NAME' — this install is still pointed at PRODUCTION." >&2 + echo " The installed CLI ($("$INSTALL_DIR/$BIN" --version 2>/dev/null | tail -1)) may predate \`insta env\` (needs >= 0.0.23)." >&2 + echo " Upgrade, then retry: insta upgrade && insta env use $ENV_NAME" >&2 + exit 1 + fi + ENV_APPLIED=1 fi # ---- agent setup (--agents) ---- @@ -234,7 +244,9 @@ fi # ---- next steps (the 3-command wow: real infra, then a full isolated clone of it) ---- echo -if [ "$ENV_NAME" = "staging" ]; then +# Only claim persistence when `env use` actually succeeded (it exits above if not, so this is +# belt-and-braces against the banner ever outliving the step it describes). +if [ "$ENV_NAME" = "staging" ] && [ "${ENV_APPLIED:-0}" = "1" ]; then echo "Environment: staging (api.staging.instacloud.com) — persisted; \`insta env use prod\` to switch back." echo fi From 97f2e53efb8be563c1bfd62d8f8a6ebcb650287d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:58:35 +0800 Subject: [PATCH 4/5] fix(env): use the #ref skills form and drop foreign sessions on override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Criticals from John-bot's review on #66. 1. The staging skills pin silently did nothing. `ENVS.staging.skills` was `InsForge/insta-skills@devel`, but the skills tool parses `@` as a SKILL-NAME FILTER, not a git ref — the ref form is the `#fragment`. So staging installs read the DEFAULT branch: exactly the cross-environment drift this PR exists to prevent (machine on staging, agent docs describing prod). My original "verification" was worthless: the tool prints `Source: …insta-skills.git @devel` for the @ form too, so it reads as if the ref applied. Settled it properly by installing both forms and diffing the result — `#docs/staging-env` produced that branch's cli-reference.md, `@docs/staging-env` produced main's. Now `InsForge/insta-skills#devel`, and the tests assert the `#` form and that `@` never appears (the previous tests locked in the broken syntax and gave false confidence). 2. The INSTA_ENV / INSTA_API_URL override path leaked credentials across deployments. `env use` drops the stored session on a switch precisely because api.ts's 401 path POSTs the refresh token to whatever apiUrl now resolves to — but the env-var path only overrode apiUrl and left the persisted session attached. `INSTA_ENV=staging insta …` on a prod-logged-in machine sent prod's bearer to staging and then POSTed prod's REFRESH token to staging's /auth/refresh. readGlobal now scrubs the session when the override names a different deployment. In-memory only, so unsetting the override restores the real login (a stray INSTA_ENV must not log you out permanently); an override naming the SAME deployment keeps the session, trailing slash included. Also from the review, both non-blocking: - `env use` now points at `insta setup agent`, since switching the CLI does not re-point already-installed agents (their registration is keyed by the other environment's server name). - Added the missing MCP coexistence tests: a staging install adds a SECOND entry beside prod for cursor/codex/opencode without clobbering it, is idempotent per name, and — since `insta-cloud` is a strict prefix of `insta-cloud-staging` — that prefix does not confuse TOML detection. Verified: typecheck clean, 157 tests passing (20 files, +11). With a bun-compiled binary: `insta env --json` reports skills `InsForge/insta-skills#devel`; a planted prod session under INSTA_ENV=staging yields user=null, i.e. the token is not carried across. --- src/commands/env.ts | 5 +++ src/config.ts | 20 +++++++++-- src/env.ts | 13 ++++--- test/env-switch.test.ts | 76 ++++++++++++++++++++++++++++++++++++---- test/mcp-install.test.ts | 55 +++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 13 deletions(-) diff --git a/src/commands/env.ts b/src/commands/env.ts index 5b561cc..12ef1ec 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -47,4 +47,9 @@ export async function envUse(name: string): Promise { info(` api: ${nextApi}`) info(` mcp: ${ENVS[target].mcp} (registers as \`${mcpServerName(target)}\`)`) if (hadSession) info(' previous session dropped (separate deployment) — run `insta login --oauth github`') + // Switching the CLI does NOT re-point already-installed agents: their MCP registration and skill + // files were written for the previous environment and are keyed by a different server name, so + // they keep talking to it until setup is re-run. (The installer path is fine — install.sh runs + // `env use` before `setup agent`.) + info(' re-point this machine\'s agents at it with: insta setup agent') } diff --git a/src/config.ts b/src/config.ts index 5e991cb..0c2b6e8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import { homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { DEFAULT_ENV, ENVS, envForApiUrl, envFromEnvVar, type EnvName } from './env.js' +import { DEFAULT_ENV, ENVS, envForApiUrl, envFromEnvVar, normalizeUrl, type EnvName } from './env.js' const GLOBAL_DIR = join(homedir(), '.insta') const GLOBAL_FILE = join(GLOBAL_DIR, 'config.json') @@ -38,7 +38,23 @@ export async function readGlobal(): Promise { const override = envApi ?? (named ? ENVS[named].api : undefined) try { const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8')) as GlobalConfig - return { ...parsed, apiUrl: override ?? parsed.apiUrl ?? DEFAULT_API } + const persisted = parsed.apiUrl ?? DEFAULT_API + // An override that points at a DIFFERENT deployment than the stored session was minted for + // must not carry that session along. `env use` already drops it on an explicit switch; without + // this, `INSTA_ENV=staging insta …` on a prod-logged-in machine sends prod's bearer to staging + // and then — on the 401 — POSTs prod's REFRESH token to staging's /auth/refresh (api.ts), which + // is the cross-deployment credential leak env.ts's header calls out as never allowed. + // + // In-memory only: the file keeps the real login, so unsetting the override restores it. A + // custom host (insta-oss, a preview) is treated the same way — its session is equally foreign. + if (override && normalizeUrl(override) !== normalizeUrl(persisted)) { + const scrubbed: GlobalConfig = { ...parsed, apiUrl: override } + delete scrubbed.accessToken + delete scrubbed.refreshToken + delete scrubbed.user + return scrubbed + } + return { ...parsed, apiUrl: override ?? persisted } } catch { return { apiUrl: override ?? DEFAULT_API } } diff --git a/src/env.ts b/src/env.ts index 1d6e38e..21cc9f1 100644 --- a/src/env.ts +++ b/src/env.ts @@ -15,10 +15,15 @@ export type EnvName = 'prod' | 'staging' export type EnvHosts = { api: string mcp: string - // The agent-skill source passed to `npx skills add`, as `owner/repo` or `owner/repo@ref`. + // The agent-skill source passed to `npx skills add`, as `owner/repo` or `owner/repo#ref`. // Staging pins the integration branch so a staging install gets the skill text that documents - // the staging control plane, rather than whatever is published on main. The `@ref` form is - // supported by the skills tool (it resolves to `…insta-skills.git @`). + // the staging control plane, rather than whatever is published on the default branch. + // + // The ref MUST use the `#ref` fragment form. `owner/repo@thing` looks like a ref but the skills + // tool parses `@` as a SKILL-NAME FILTER, silently leaving the source on the default branch — + // and its progress line still echoes "Source: …insta-skills.git @thing", so it reads as if the + // ref took effect. Verified by installing both forms and diffing the result: `#docs/staging-env` + // produced that branch's cli-reference.md, `@docs/staging-env` produced main's. skills: string } @@ -31,7 +36,7 @@ export const ENVS: Record = { staging: { api: 'https://api.staging.instacloud.com', mcp: 'https://mcp.staging.instacloud.com/mcp', - skills: 'InsForge/insta-skills@devel', + skills: 'InsForge/insta-skills#devel', }, } diff --git a/test/env-switch.test.ts b/test/env-switch.test.ts index 3975d3d..d50e092 100644 --- a/test/env-switch.test.ts +++ b/test/env-switch.test.ts @@ -36,17 +36,27 @@ describe('env table', () => { it('gives every environment a skill source', () => { for (const name of ENV_NAMES) { - expect(ENVS[name].skills).toMatch(/^[\w.-]+\/[\w.-]+(@[\w./-]+)?$/) + expect(ENVS[name].skills).toMatch(/^[\w.-]+\/[\w.-]+(#[\w./-]+)?$/) } }) - // Prod must stay unpinned (whatever is published on the default branch); staging pins a ref so - // an installer run reads the staging skill text. `owner/repo@ref` is what the skills tool accepts. - it('pins staging skills to a ref and leaves prod unpinned', () => { + // Prod stays unpinned (whatever is on the default branch); staging pins a ref so an installer + // run reads the staging skill text. + // + // The ref MUST be the `#ref` fragment form. `owner/repo@thing` is parsed by the skills tool as a + // SKILL-NAME FILTER, so the source silently stays on the default branch — while its output still + // prints "Source: …git @thing", which reads like the ref applied. Proven by installing both + // forms: `#docs/staging-env` yielded that branch's cli-reference.md, `@docs/staging-env` yielded + // main's. These assertions exist to stop that footgun coming back. + it('pins staging skills with the #ref form, never @', () => { expect(ENVS.prod.skills).toBe('InsForge/insta-skills') - expect(ENVS.prod.skills).not.toContain('@') - expect(ENVS.staging.skills).toContain('@') - expect(ENVS.staging.skills.split('@')[0]).toBe('InsForge/insta-skills') + expect(ENVS.staging.skills).toBe('InsForge/insta-skills#devel') + }) + + it('never uses @ in a skill source (it is a skill filter, not a ref)', () => { + for (const name of ENV_NAMES) { + expect(ENVS[name].skills).not.toContain('@') + } }) }) @@ -216,6 +226,58 @@ describe('config + env use', () => { expect((await resolveEnv()).skills).toBe(ENVS.staging.skills) }) + // The security invariant env.ts's header states: a session minted by one deployment must never be + // sent to another. `env use` enforced it, but the INSTA_ENV / INSTA_API_URL override path did not + // — it returned the staging host with the prod session still attached, so api.ts's 401 path would + // POST prod's REFRESH token to staging's /auth/refresh. + it('drops the stored session when INSTA_ENV points at a different deployment', async () => { + await writeConfig({ apiUrl: PROD_API, accessToken: 'prod-a', refreshToken: 'prod-r', user: { id: 'u', email: null, name: null } }) + process.env.INSTA_ENV = 'staging' + const { readGlobal } = await freshConfig() + const c = await readGlobal() + expect(c.apiUrl).toBe(STAGING_API) + expect(c.accessToken).toBeUndefined() + expect(c.refreshToken).toBeUndefined() + expect(c.user).toBeUndefined() + }) + + it('drops the stored session when INSTA_API_URL points at a custom host', async () => { + await writeConfig({ apiUrl: PROD_API, accessToken: 'prod-a', refreshToken: 'prod-r' }) + process.env.INSTA_API_URL = 'http://localhost:8080' + const { readGlobal } = await freshConfig() + const c = await readGlobal() + expect(c.apiUrl).toBe('http://localhost:8080') + expect(c.accessToken).toBeUndefined() + }) + + // Only a MISMATCH scrubs. An override naming the same deployment must keep the session, or + // exporting INSTA_ENV=prod on a prod machine would silently log you out. + it('keeps the session when the override names the same deployment', async () => { + await writeConfig({ apiUrl: PROD_API, accessToken: 'prod-a', refreshToken: 'prod-r' }) + process.env.INSTA_ENV = 'prod' + const { readGlobal } = await freshConfig() + expect((await readGlobal()).accessToken).toBe('prod-a') + }) + + it('keeps the session when the override differs only by a trailing slash', async () => { + await writeConfig({ apiUrl: STAGING_API, accessToken: 'stg-a' }) + process.env.INSTA_API_URL = STAGING_API + '/' + const { readGlobal } = await freshConfig() + expect((await readGlobal()).accessToken).toBe('stg-a') + }) + + // The scrub is in-memory: the file still holds the real login, so unsetting the override restores + // it. Otherwise one stray `INSTA_ENV=staging` would permanently log the user out of prod. + it('does not persist the scrub — the stored login survives', async () => { + await writeConfig({ apiUrl: PROD_API, accessToken: 'prod-a', refreshToken: 'prod-r' }) + process.env.INSTA_ENV = 'staging' + let mod = await freshConfig() + expect((await mod.readGlobal()).accessToken).toBeUndefined() + delete process.env.INSTA_ENV + mod = await freshConfig() + expect((await mod.readGlobal()).accessToken).toBe('prod-a') + }) + it('lets INSTA_SKILLS_REPO override the environment skill source', async () => { process.env.INSTA_ENV = 'staging' process.env.INSTA_SKILLS_REPO = 'me/my-skills@wip' diff --git a/test/mcp-install.test.ts b/test/mcp-install.test.ts index 111bce6..b57a2db 100644 --- a/test/mcp-install.test.ts +++ b/test/mcp-install.test.ts @@ -78,3 +78,58 @@ test('detectAgents only reports agents whose config dir exists', async () => { await fs.mkdir(path.join(home, '.codex'), { recursive: true }) expect(detectAgents(home)).toEqual(['cursor', 'codex']) }) + +// ---- environment coexistence ---- +// Both environments must be installable on one machine: registration is idempotent BY NAME, so if +// prod and staging shared a name a staging install would be a no-op that silently left the agent +// pointed at prod. These cover the `name` parameter that makes the two distinct. + +const STAGING_NAME = 'insta-cloud-staging' +const STAGING_URL = 'https://mcp.staging.instacloud.com/mcp' + +test('cursor: a staging install adds a SECOND entry beside prod, clobbering neither', async () => { + const home = await tmpHome() + expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('installed') + expect(await installFor('cursor', home, STAGING_URL, STAGING_NAME)).toBe('installed') + const cfg = JSON.parse(await fs.readFile(configPath('cursor', home), 'utf8')) + expect(cfg.mcpServers[MCP_SERVER_NAME]).toEqual({ url: DEFAULT_MCP_URL }) + expect(cfg.mcpServers[STAGING_NAME]).toEqual({ url: STAGING_URL }) + expect(Object.keys(cfg.mcpServers).sort()).toEqual([MCP_SERVER_NAME, STAGING_NAME].sort()) +}) + +test('cursor: staging install is idempotent per name', async () => { + const home = await tmpHome() + expect(await installFor('cursor', home, STAGING_URL, STAGING_NAME)).toBe('installed') + expect(await installFor('cursor', home, STAGING_URL, STAGING_NAME)).toBe('already') + // ...and prod is still installable afterwards, not reported as "already" + expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('installed') +}) + +test('codex TOML: staging appends its own table without touching prod\'s', async () => { + const home = await tmpHome() + expect(await installFor('codex', home, DEFAULT_MCP_URL)).toBe('installed') + expect(await installFor('codex', home, STAGING_URL, STAGING_NAME)).toBe('installed') + const toml = await fs.readFile(configPath('codex', home), 'utf8') + expect(toml).toContain(`[mcp_servers.${MCP_SERVER_NAME}]`) + expect(toml).toContain(`[mcp_servers.${STAGING_NAME}]`) + expect(toml).toContain(STAGING_URL) + expect(toml).toContain(DEFAULT_MCP_URL) +}) + +// The prod name is a strict prefix of the staging name, so a substring-based idempotency check +// would wrongly report staging as "already configured" once prod existed (or vice versa). +test('codex TOML: prod name being a prefix of staging name does not confuse detection', async () => { + const home = await tmpHome() + expect(await installFor('codex', home, STAGING_URL, STAGING_NAME)).toBe('installed') + expect(await installFor('codex', home, DEFAULT_MCP_URL)).toBe('installed') + expect(renderCodexConfig(await fs.readFile(configPath('codex', home), 'utf8'), STAGING_URL, STAGING_NAME)).toBeNull() +}) + +test('opencode: staging entry coexists under the mcp key', async () => { + const home = await tmpHome() + expect(await installFor('opencode', home, DEFAULT_MCP_URL)).toBe('installed') + expect(await installFor('opencode', home, STAGING_URL, STAGING_NAME)).toBe('installed') + const cfg = JSON.parse(await fs.readFile(configPath('opencode', home), 'utf8')) + expect(cfg.mcp[MCP_SERVER_NAME]).toMatchObject({ url: DEFAULT_MCP_URL, type: 'remote' }) + expect(cfg.mcp[STAGING_NAME]).toMatchObject({ url: STAGING_URL, type: 'remote' }) +}) From f4aff4a985d7a18f2bc239ce98b95351ef990b03 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 22:26:54 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(env):=20address=20cubic=20review=20?= =?UTF-8?q?=E2=80=94=20env=20use=20correctness,=20prerelease=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from the automated review on #66, three of them P1. env use (src/commands/env.ts) had three real defects, all now covered by tests: - It decided against the OVERRIDE-resolved host, so `INSTA_ENV=staging insta env use staging` reported "already on staging", wrote nothing, and left the next process — without INSTA_ENV set — still on prod. It now reads the persisted config directly (new `readPersistedGlobal`), which is the only correct basis for "is this a real switch?". - It compared URLs raw, so a stored trailing slash looked like a switch and needlessly discarded a valid session. Now normalised, matching the trailing-slash equivalence envForApiUrl already documents. - `hadSession` checked accessToken alone, so a config holding only a refreshToken (interrupted login, hand-edited file) kept that token across a switch — and api.ts's 401 path would POST it to the new deployment. The session is now cleared unconditionally on a real change. release.yml: `--prerelease` was applied only on the `gh release create` path, so a release that already existed (created by hand, or a re-run of this job) stayed marked "Latest" — exactly the case install.sh's production /releases/latest channel would then serve. Now enforced with `gh release edit --prerelease`. install.sh: `--env=` (empty value) passed the later validation and silently fell through to the default instead of erroring like a bare `--env`; now rejected (exit 1, verified). Prerelease lookup raised to per_page=100 — with the default 30, thirty newer stable releases would push the newest prerelease onto page 2 and staging would quietly install stable. agents-staging.sh: piping `curl … | sh` means a failed download hands sh an EMPTY stream and sh exits 0 — the one-liner reported success having installed nothing, which matters most to automation reading only the exit code. Now it downloads to a file, checks curl's status and that the file is non-empty, then runs it (verified: exit 1 on a bad URL). README: the staging one-liner is documented with the 404-until-merged caveat and the raw-URL equivalent, matching what agents-staging.sh already says. Verified: typecheck clean, 161 tests passing (20 files, +4); sh -n clean on all three scripts; release.yml still parses; `--env=` exits 1 and `--env=staging` exits 0. --- .github/workflows/release.yml | 6 +++++ README.md | 14 +++++++++-- agents-staging.sh | 13 ++++++++++- install.sh | 9 ++++++- src/commands/env.ts | 37 ++++++++++++++++++----------- src/config.ts | 16 +++++++++++++ test/env-switch.test.ts | 44 +++++++++++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d56fe6..003a816 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,6 +100,12 @@ jobs: --target "$GITHUB_SHA" \ --title "insta $TAG" \ --generate-notes $PRERELEASE + elif [ -n "$PRERELEASE" ]; then + # The release already exists (created by hand, or this job is a re-run). Enforce the + # flag anyway: applying it only on the create path would leave a pre-existing + # prerelease marked "Latest", which is precisely what install.sh's production + # /releases/latest channel would then serve. + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --prerelease fi gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \ dist-bin/insta-* dist-bin/SHA256SUMS install.sh diff --git a/README.md b/README.md index 6937747..037d4f6 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,18 @@ curl -fsSL agents.instacloud.com | sh # production curl -fsSL agents.staging.instacloud.com | sh # staging ``` -Both install the same released binary — the difference is which control plane it targets. See -[Environments](#environments). +Each installs a complete stack for its environment — CLI build, control plane, MCP registration and +skill text all match. See [Environments](#environments). + +> The staging host serves this repo's `agents-staging.sh` from `main`, so it returns 404 until that +> file is on `main`. Equivalent, and works regardless: +> +> ```bash +> curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh -s -- --agents --staging -y +> ``` +> +> If the environment can't be applied (a CLI predating `insta env`), the installer exits non-zero +> rather than silently leaving you on production. **Build from source (requires node):** diff --git a/agents-staging.sh b/agents-staging.sh index 980b217..6b0f24c 100644 --- a/agents-staging.sh +++ b/agents-staging.sh @@ -26,4 +26,15 @@ # so an env var would evaporate before the `insta project create` the user runs next. set -eu -curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh | sh -s -- --agents --staging -y "$@" +# Download to a file and check curl's status BEFORE running it, rather than piping straight into sh. +# A piped `curl … | sh` that fails to fetch hands sh an EMPTY stream, and sh exits 0 — so the +# one-liner reports success while installing nothing. That matters most for automation, which has +# only the exit code to go on. +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +if ! curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh -o "$tmp"; then + echo "error: could not download install.sh (network, or GitHub raw unavailable)" >&2 + exit 1 +fi +[ -s "$tmp" ] || { echo "error: downloaded install.sh is empty" >&2; exit 1; } +sh "$tmp" --agents --staging -y "$@" diff --git a/install.sh b/install.sh index ac0d188..b1c65df 100644 --- a/install.sh +++ b/install.sh @@ -38,6 +38,9 @@ while [ $# -gt 0 ]; do -y|--yes) YES=1 ;; --staging) ENV_NAME=staging ;; --env) shift; [ $# -gt 0 ] || { echo "error: --env needs a value (prod|staging)" >&2; exit 1; }; ENV_NAME="$1" ;; + # `--env=` (empty) must be rejected like a bare `--env`, not silently treated as "no environment + # requested" — otherwise a malformed selection falls through to the default instead of erroring. + --env=) echo "error: --env needs a value (prod|staging)" >&2; exit 1 ;; --env=*) ENV_NAME="${1#--env=}" ;; esac shift @@ -64,8 +67,12 @@ resolve_latest() { # so the list endpoint is the only way to find them. Within a release object GitHub emits tag_name # before draft/prerelease, so we remember the tag and act when the flags arrive; a draft clears it # (drafts have no downloadable assets). +# per_page=100 (the API maximum) rather than the default 30: with 30, once thirty newer stable +# releases pile up the newest prerelease slides onto page 2 and staging would silently install +# stable. 100 is a single request and covers any realistic release history; INSTA_VERSION remains +# the escape hatch beyond that. resolve_prerelease() { - curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=30" 2>/dev/null | awk ' + curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=100" 2>/dev/null | awk ' /"tag_name":/ { if (match($0, /v[^"]+/)) tag = substr($0, RSTART, RLENGTH) } /"draft": *true/ { tag = "" } /"prerelease": *true/ { if (tag != "") { print tag; exit } }' diff --git a/src/commands/env.ts b/src/commands/env.ts index 12ef1ec..d4e3851 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -5,9 +5,8 @@ // to make `INSTA_ENV=staging` stick for the `insta project create` the user runs next. Persisting // the choice into ~/.insta/config.json is the only mechanism that survives the pipe — and it is the // same file `login --api-url` already writes, so this adds a surface, not a concept. -import { ApiClient } from '../api.js' -import { resolveEnv } from '../config.js' -import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, isEnvName, mcpServerName, type EnvName } from '../env.js' +import { readPersistedGlobal, resolveEnv, writeGlobal, type GlobalConfig } from '../config.js' +import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, isEnvName, mcpServerName, normalizeUrl, type EnvName } from '../env.js' import { die, info, printJson } from '../util.js' export async function envShow(opts: { json?: boolean }): Promise { @@ -26,22 +25,32 @@ export async function envUse(name: string): Promise { if (!isEnvName(want)) die(`unknown environment "${name}" — expected one of: ${ENV_NAMES.join(', ')}`) const target: EnvName = want - const api = await ApiClient.load() - const from = envForApiUrl(api.apiUrl) const nextApi = ENVS[target].api - if (api.apiUrl === nextApi) { + // The PERSISTED config, deliberately not the override-resolved view — see readPersistedGlobal. + const stored = await readPersistedGlobal() + const from = envForApiUrl(stored.apiUrl) + + // Compare normalised, so a stored trailing slash is recognised as the same environment (which is + // how envForApiUrl already treats it) instead of being rewritten as a "switch" that needlessly + // drops a perfectly good session. + if (normalizeUrl(stored.apiUrl) === normalizeUrl(nextApi)) { info(`already on ${target} (${nextApi})`) return } - // Drop the stored session along with the host. prod and staging are separate deployments, so the - // old token cannot authenticate here — and keeping it is actively unsafe, because api.ts's 401 - // path POSTs the refresh token to whatever apiUrl now resolves to, handing one deployment's - // credential to another. Same reasoning as the retired-host path in config.ts. - const hadSession = !!api.config.accessToken - api.setApiUrl(nextApi) - if (hadSession) api.clearSession() - await api.persist() + // A real switch, so drop the stored session unconditionally. prod and staging are separate + // deployments: the old token cannot authenticate here, and keeping it is actively unsafe because + // api.ts's 401 path POSTs the refresh token to whatever apiUrl now resolves to, handing one + // deployment's credential to another. Same reasoning as the retired-host path in config.ts. + // + // "Any field" rather than accessToken alone: a config holding only a refreshToken (an interrupted + // login, a hand-edited file) would otherwise keep that token and post it to the new host. + const hadSession = !!(stored.accessToken || stored.refreshToken || stored.user) + const next: GlobalConfig = { ...stored, apiUrl: nextApi } + delete next.accessToken + delete next.refreshToken + delete next.user + await writeGlobal(next) info(`switched ${from ?? '(custom)'} → ${target}`) info(` api: ${nextApi}`) diff --git a/src/config.ts b/src/config.ts index 0c2b6e8..bb2153c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -83,6 +83,22 @@ export async function resolveEnv(): Promise<{ return { apiUrl, env, mcpUrl, skills } } +/** The config exactly as stored: no env-var overrides, no session scrubbing. + * + * `env use` must read this rather than `readGlobal()`. With INSTA_ENV set, `readGlobal()` already + * reports the override's host, so `env use ` would look like a no-op, print + * "already on X" and never write the file — leaving the next process (without the override in its + * environment) still pointed at the old one. Deciding "is this a real switch?" has to be done + * against what is persisted. */ +export async function readPersistedGlobal(): Promise { + try { + const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8')) as GlobalConfig + return { ...parsed, apiUrl: parsed.apiUrl ?? DEFAULT_API } + } catch { + return { apiUrl: DEFAULT_API } + } +} + export async function writeGlobal(c: GlobalConfig): Promise { await mkdir(GLOBAL_DIR, { recursive: true }) await writeFile(GLOBAL_FILE, JSON.stringify(c, null, 2)) diff --git a/test/env-switch.test.ts b/test/env-switch.test.ts index d50e092..e0d8d4f 100644 --- a/test/env-switch.test.ts +++ b/test/env-switch.test.ts @@ -352,6 +352,50 @@ describe('config + env use', () => { } }) + // `env use` must decide against the PERSISTED host, not the override-resolved one. Otherwise + // `INSTA_ENV=staging insta env use staging` sees "already on staging", writes nothing, and the + // next process — without INSTA_ENV in its environment — is still on prod. + it('env use persists even when INSTA_ENV already names the target', async () => { + await writeConfig({ apiUrl: PROD_API, accessToken: 'a' }) + process.env.INSTA_ENV = 'staging' + vi.resetModules() + const { envUse } = await import('../src/commands/env.js') + await envUse('staging') + expect((await readConfig()).apiUrl).toBe(STAGING_API) + }) + + // A stored trailing slash is the same environment (envForApiUrl says so), so this is a no-op and + // must not rewrite the URL or discard a valid session. + it('env use treats a trailing-slash host as already-current and keeps the session', async () => { + await writeConfig({ apiUrl: STAGING_API + '/', accessToken: 'stg-a', refreshToken: 'stg-r' }) + vi.resetModules() + const { envUse } = await import('../src/commands/env.js') + await envUse('staging') + const c = await readConfig() + expect(c.apiUrl).toBe(STAGING_API + '/') + expect(c.accessToken).toBe('stg-a') + }) + + // hadSession used to check accessToken alone, so a config with only a refreshToken kept it — and + // api.ts's 401 path would POST that token to the new deployment. + it('env use drops a refresh-token-only session on a real switch', async () => { + await writeConfig({ apiUrl: PROD_API, refreshToken: 'prod-r' }) + vi.resetModules() + const { envUse } = await import('../src/commands/env.js') + await envUse('staging') + const c = await readConfig() + expect(c.apiUrl).toBe(STAGING_API) + expect(c.refreshToken).toBeUndefined() + }) + + it('env use drops a user-only remnant on a real switch', async () => { + await writeConfig({ apiUrl: PROD_API, user: { id: 'u', email: 'a@b.c', name: null } }) + vi.resetModules() + const { envUse } = await import('../src/commands/env.js') + await envUse('staging') + expect((await readConfig()).user).toBeUndefined() + }) + it('preserves unrelated config keys across a switch', async () => { await writeConfig({ apiUrl: PROD_API, autoUpdate: false }) vi.resetModules()