diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d86573a..003a816 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,13 +85,27 @@ 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 + 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 @@ -118,4 +132,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 06ffcd6..037d4f6 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,26 @@ 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 +``` + +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):** ```bash @@ -52,11 +72,48 @@ 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. + +| | `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 everything derived from it +insta env use staging # switch (persisted to ~/.insta/config.json) +insta login --env staging --oauth github +``` + +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` 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 | |------|------| -| `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..6b0f24c --- /dev/null +++ b/agents-staging.sh @@ -0,0 +1,40 @@ +#!/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 everything it installs points at staging: +# +# 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, +# so an env var would evaporate before the `insta project create` the user runs next. +set -eu + +# 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 a8937e5..b1c65df 100644 --- a/install.sh +++ b/install.sh @@ -9,34 +9,90 @@ # (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 +# +# 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 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=` (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 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}" 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). +# 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=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 } }' +} + +# 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)" @@ -143,6 +199,26 @@ 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 + 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) ---- if [ "$AGENTS" = "1" ]; then echo @@ -175,6 +251,12 @@ fi # ---- next steps (the 3-command wow: real infra, then a full isolated clone of it) ---- echo +# 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 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..d4e3851 --- /dev/null +++ b/src/commands/env.ts @@ -0,0 +1,64 @@ +// `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 { 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 { + 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} (${mcpServer})`) + info(`skills: ${skills}`) + 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 nextApi = ENVS[target].api + // 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 + } + + // 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}`) + 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/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..f0d1f0e 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' @@ -95,12 +97,30 @@ 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 ---- -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 +144,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 +161,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}`) } } @@ -157,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 a7edf1d..bb2153c 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, normalizeUrl, type EnvName } from './env.js' const GLOBAL_DIR = join(homedir(), '.insta') const GLOBAL_FILE = join(GLOBAL_DIR, 'config.json') @@ -21,17 +22,80 @@ 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 } + 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 } + } +} + +/** 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) + 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 } +} + +/** 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: envApi ?? DEFAULT_API } + return { apiUrl: DEFAULT_API } } } 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 new file mode 100644 index 0000000..21cc9f1 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,85 @@ +// 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 + // 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 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 +} + +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', + }, +} + +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..e0d8d4f --- /dev/null +++ b/test/env-switch.test.ts @@ -0,0 +1,406 @@ +// 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') + }) + + it('gives every environment a skill source', () => { + for (const name of ENV_NAMES) { + expect(ENVS[name].skills).toMatch(/^[\w.-]+\/[\w.-]+(#[\w./-]+)?$/) + } + }) + + // 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.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('@') + } + }) +}) + +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 origSkills = process.env.INSTA_SKILLS_REPO + + 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 + delete process.env.INSTA_SKILLS_REPO + }) + + 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 + if (origSkills === undefined) delete process.env.INSTA_SKILLS_REPO; else process.env.INSTA_SKILLS_REPO = origSkills + 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() + }) + + // 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) + }) + + // 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' + 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' + 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() + } + }) + + // `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() + const { envUse } = await import('../src/commands/env.js') + await envUse('staging') + expect((await readConfig()).autoUpdate).toBe(false) + }) +}) 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' }) +})