Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
# 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
Expand All @@ -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
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The one-liner curl -fsSL agents.staging.instacloud.com | sh is documented as a working staging command, but the PR description notes that DNS/CloudFront for this hostname is not yet provisioned. Users who try this command will get a DNS resolution error. Either add a note that this URL is coming soon (and link to the alternative install.sh --staging approach), or keep the documentation change scoped to the same PR that provisions the DNS.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 23:

<comment>The one-liner `curl -fsSL agents.staging.instacloud.com | sh` is documented as a working staging command, but the PR description notes that DNS/CloudFront for this hostname is not yet provisioned. Users who try this command will get a DNS resolution error. Either add a note that this URL is coming soon (and link to the alternative `install.sh --staging` approach), or keep the documentation change scoped to the same PR that provisions the DNS.</comment>

<file context>
@@ -16,6 +16,16 @@ curl -fsSL https://raw.githubusercontent.com/InsForge/insta-cli/main/install.sh
+
+```bash
+curl -fsSL agents.instacloud.com | sh            # production
+curl -fsSL agents.staging.instacloud.com | sh    # staging
+```
+
</file context>

```

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
Expand Down Expand Up @@ -52,11 +72,48 @@ insta deploy --image <registry/img> # 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 <prod\|staging>` | Show or switch deployment environment |
| `insta login --oauth <github\|google>` | 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 <name>` | Organizations (each user may own only one free org) |
Expand Down
40 changes: 40 additions & 0 deletions agents-staging.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
94 changes: 88 additions & 6 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> 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=}" ;;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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)"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
28 changes: 23 additions & 5 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
/** --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<void> {
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 <github|google>)')
const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword())
const res = await api.request('POST', '/auth/login', { email: opts.email, password }, { auth: false })
Expand All @@ -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<void> {
export async function loginOauth(provider: string, opts: { apiUrl?: string; env?: string }): Promise<void> {
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')
Expand Down Expand Up @@ -77,7 +91,11 @@ export async function status(opts: { json?: boolean }): Promise<void> {
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)'}`)
Expand Down
Loading
Loading