diff --git a/README.md b/README.md index 4cca17c..5a382ad 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ One tool call per resource type, each returning a drop-in connection string: (Dockerfile + source), get back a public URL in ~30s. Bind any of the resources above by passing their tokens as `resource_bindings` — the API resolves tokens to connection URLs server-side. +- **Multi-service stack** (`create_stack`) → declare 1..N services in an + `instant.yaml` manifest, ship them as a bundle in a single MCP call. Anonymous + callers get a 24h-TTL stack with a live URL on + `*.deployment.instanode.dev` — no card required. Cross-service refs + (`service://`) resolve cluster-internally at deploy time. Poll + status with `get_stack`. Every anonymous resource auto-expires in 24h. The provision response carries a `note` and `upgrade` field — the MCP server surfaces both verbatim so the @@ -114,6 +120,8 @@ to reach for this MCP, see . | `create_storage` | `POST /storage/new` — Provision an S3-compatible bucket prefix (DigitalOcean Spaces). Returns endpoint, access keys, prefix + `note`/`upgrade`. `name` required. | | `create_webhook` | `POST /webhook/new` — Provision an inbound webhook receiver URL. Returns `receive_url` + `note`/`upgrade`. `name` required. | | `create_deploy` | `POST /deploy/new` — Upload a base64 gzip tarball (with Dockerfile) and deploy a container. Returns `deploy_id`, `status`, `url`, `build_logs_url`. `name` required. Requires `INSTANODE_TOKEN`. | +| `create_stack` | `POST /stacks/new` — Multi-service bundle. Upload an `instant.yaml` manifest plus one base64 gzip tarball per service; returns `stack_id`, per-service URLs, and the 24h-TTL claim block on the anonymous tier. **Anonymous-friendly** (the wedge). `name`, `manifest`, `service_tarballs` required. | +| `get_stack` | `GET /stacks/{stack_id}` — Poll a stack's per-service status + URLs. Anonymous-friendly. `stack_id` required. | | `list_deployments`| `GET /api/v1/deployments` — List all deployments on the caller's team. Requires `INSTANODE_TOKEN`. | | `get_deployment` | `GET /api/v1/deployments/:id` — Fetch one deployment (poll until `status="running"`). Requires `INSTANODE_TOKEN`. | | `redeploy` | `POST /deploy/:id/redeploy` — Rebuild + rolling update an existing deployment. Requires `INSTANODE_TOKEN`. | diff --git a/package-lock.json b/package-lock.json index 7704c3f..f6da217 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instanode-mcp", - "version": "0.11.1", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instanode-mcp", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0" diff --git a/package.json b/package.json index 171b022..498f203 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "instanode-mcp", - "version": "0.11.1", + "version": "0.12.0", "description": "MCP server for instanode.dev \u2014 lets AI coding agents provision ephemeral Postgres, Redis, MongoDB, NATS queues, S3-compatible object storage, webhook receivers, and deploy containerized apps over HTTPS, with optional bearer-token auth for paid users.", "keywords": [ "mcp", diff --git a/src/client.ts b/src/client.ts index 925b933..d3c0996 100644 --- a/src/client.ts +++ b/src/client.ts @@ -281,7 +281,12 @@ export interface CreateDeployParams { name: string; /** Container HTTP port. Default 8080. */ port?: number; - /** Deploy env scope: production / staging / development. Default "production". */ + /** + * Deploy env scope: development / staging / production. Default + * "development" server-side — see CLAUDE.md convention #11 / migration 026. + * Omitting `env` lands the deploy in 'development' (lowest stakes) so + * accidental no-env deploys can't merge with prod state. + */ env?: string; /** * env vars dict; values can be plaintext or vault://env/KEY refs. The api @@ -311,6 +316,77 @@ export interface CreateDeployParams { allowed_ips?: string[]; } +/** + * A single service entry in a StackResponse. The api returns one of these per + * service declared in the manifest. Only services with `expose: true` get a + * public `url`; the rest are reachable in-cluster only via + * `http://:`. + */ +export interface StackService { + name: string; + status: string; + port: number; + expose?: boolean; + /** Empty string while building or for non-exposed services. */ + url?: string; +} + +/** + * Response shape from POST /stacks/new (HTTP 202 Accepted) and GET /stacks/{slug}. + * + * Mirrors the `StackResponse` schema in api/internal/handlers/openapi.go. Like + * /deploy/new, the build is asynchronous: the initial response carries + * status="building"; poll `getStack(stack_id)` until status="healthy" (or + * "failed"). Overall status is "healthy" only when every service is healthy. + */ +export interface StackResult { + ok: boolean; + /** Format: stk-<8-char-hex>. Use this for getStack / GET /stacks/{slug}. */ + stack_id: string; + status: string; + tier: string; + /** Resolved env bucket (defaults to 'development' — see CLAUDE.md #11). */ + env?: string; + name?: string; + services: StackService[]; + /** Anonymous stacks have a 24h TTL; authenticated stacks return empty. */ + expires_in?: string; + /** Anonymous-tier CTA fields, same semantics as create_*. */ + note?: string; + upgrade?: string; + upgrade_jwt?: string; +} + +/** + * Caller-supplied params for create_stack — wraps POST /stacks/new. + * + * `manifest` is the raw YAML text of an `instant.yaml`. `service_tarballs` + * maps each service-name declared in the manifest to a base64-encoded gzip + * tarball of that service's build context (Dockerfile + sources). The client + * decodes each tarball and attaches it as a multipart file part NAMED AFTER + * THE SERVICE — this is the api's documented contract (see openapi.json + * StackRequest: "One field per service declared in the manifest, named after + * the service. Value is a gzipped tar archive."). Total request body cap is + * 200 MB across all services (api side); each service's decoded tarball is + * still capped at 50 MiB client-side. + */ +export interface CreateStackParams { + /** Stack name. Required (1-64 chars, ^[A-Za-z0-9][A-Za-z0-9 _-]*$). */ + name: string; + /** instant.yaml text — declares services + their build/port/expose/needs. */ + manifest: string; + /** + * One entry per service declared in the manifest. Keys are service names; + * values are base64-encoded gzip tarballs of that service's build context. + */ + service_tarballs: Record; + /** + * Optional resource env scope (development / staging / production). Default + * "development" server-side (see CLAUDE.md convention #11 / mig 026). + */ + env?: string; +} + export interface ClaimResult { ok: boolean; id: string; @@ -606,9 +682,30 @@ export class InstantClient { return data as T; } + /** + * Build a `{ name [, env] }` body for the //new endpoints. + * + * CLI-MCP FINDING-8: `env` is the resource environment scope (development / + * staging / production). The MCP previously dropped it entirely, so every + * call landed in the server-side default (`development` per mig 026 / + * CLAUDE.md convention #11) with no way for the agent to override. The + * helper only sets the field when the caller actually passed a non-empty + * string — undefined/empty preserves the server default and matches the + * pre-fix request shape exactly (so the wire diff is opt-in). + */ + private provisionBody(name: string, env?: string): { name: string; env?: string } { + const body: { name: string; env?: string } = { name }; + if (typeof env === "string" && env.length > 0) body.env = env; + return body; + } + /** POST /db/new — provision a Postgres database. `name` is required. */ - async createPostgres(name: string): Promise { - return this.request("POST", "/db/new", { name }); + async createPostgres(name: string, env?: string): Promise { + return this.request( + "POST", + "/db/new", + this.provisionBody(name, env) + ); } /** @@ -616,40 +713,63 @@ export class InstantClient { * is required client-side for parity with the other create_* tools (the * server allows it to be omitted, but every other endpoint requires it). * Optional `dimensions` is a documentation hint only — pgvector picks - * dimensions per column at table-create time. + * dimensions per column at table-create time. Optional `env` lands the + * resource in a specific env bucket (server default `development`). */ async createVector( name: string, - dimensions?: number + dimensions?: number, + env?: string ): Promise { - const body: { name: string; dimensions?: number } = { name }; + const body: { name: string; dimensions?: number; env?: string } = + this.provisionBody(name, env); if (typeof dimensions === "number") body.dimensions = dimensions; return this.request("POST", "/vector/new", body); } /** POST /cache/new — provision a Redis cache. `name` is required. */ - async createCache(name: string): Promise { - return this.request("POST", "/cache/new", { name }); + async createCache(name: string, env?: string): Promise { + return this.request( + "POST", + "/cache/new", + this.provisionBody(name, env) + ); } /** POST /nosql/new — provision a MongoDB database. `name` is required. */ - async createNoSQL(name: string): Promise { - return this.request("POST", "/nosql/new", { name }); + async createNoSQL(name: string, env?: string): Promise { + return this.request( + "POST", + "/nosql/new", + this.provisionBody(name, env) + ); } /** POST /queue/new — provision a NATS JetStream queue. `name` is required. */ - async createQueue(name: string): Promise { - return this.request("POST", "/queue/new", { name }); + async createQueue(name: string, env?: string): Promise { + return this.request( + "POST", + "/queue/new", + this.provisionBody(name, env) + ); } /** POST /storage/new — provision an S3-compatible object storage bucket prefix. `name` is required. */ - async createStorage(name: string): Promise { - return this.request("POST", "/storage/new", { name }); + async createStorage(name: string, env?: string): Promise { + return this.request( + "POST", + "/storage/new", + this.provisionBody(name, env) + ); } /** POST /webhook/new — provision a webhook receiver. `name` is required. */ - async createWebhook(name: string): Promise { - return this.request("POST", "/webhook/new", { name }); + async createWebhook(name: string, env?: string): Promise { + return this.request( + "POST", + "/webhook/new", + this.provisionBody(name, env) + ); } /** @@ -844,6 +964,77 @@ export class InstantClient { }; } + /** + * POST /stacks/new — upload an instant.yaml manifest + one gzipped tarball + * per declared service and deploy a multi-service bundle. Multipart. + * + * Anonymous-friendly: like /deploy/new the api accepts anonymous callers + * (OptionalAuth — openapi.json:157), issuing the stack at the anonymous tier + * with a 24h TTL. This is the CEO wedge: a single MCP call from a cold-start + * agent → live bundle URL on *.deployment.instanode.dev, no card, no + * dashboard round-trip. + * + * Multipart shape (per StackRequest in openapi.json): + * - `name` — text field, required. + * - `manifest` — text field carrying the YAML body. + * - `` — one binary file part PER service declared in the + * manifest, named after the service (e.g. `api`, `web`, `worker`). + * - `env` — optional text field (resource env scope). + * + * Returns the api's 202 StackResponse with stack_id, per-service status + + * URL (exposed services only), and anonymous-tier CTA fields. + */ + async createStack(params: CreateStackParams): Promise { + const form = new FormData(); + + form.append("name", params.name); + form.append("manifest", params.manifest); + if (typeof params.env === "string" && params.env.length > 0) { + form.append("env", params.env); + } + + // One file part per service. Enforce the per-tarball 50 MiB cap + // client-side, mirroring the create_deploy guard — an oversized tarball + // would otherwise stream multiple MB of base64 to the api just to be + // rejected, with the agent host potentially logging the body. + for (const [serviceName, b64] of Object.entries(params.service_tarballs)) { + const tarball = Buffer.from(b64, "base64"); + if (tarball.byteLength > MAX_TARBALL_BYTES) { + throw new Error( + `Tarball for service "${serviceName}" is too large: ` + + `${tarball.byteLength.toLocaleString()} bytes (decoded). ` + + `The api accepts at most ${MAX_TARBALL_BYTES.toLocaleString()} ` + + `bytes (50 MiB) per service. Shrink the tarball: include only ` + + `what \`docker build\` needs — exclude node_modules, .git, build ` + + `artifacts, large media. Add a .dockerignore.` + ); + } + const blob = new Blob([tarball], { type: "application/gzip" }); + form.append(serviceName, blob, `${serviceName}.tar.gz`); + } + + // /stacks/new is OptionalAuth — anonymous callers are accepted with a 24h + // TTL. Do NOT pass requireAuth here. + return this.requestMultipart("/stacks/new", form); + } + + /** + * GET /stacks/{slug} — poll a stack's per-service status + URLs. + * + * The public `/stacks/{slug}` route mirrors the StackResponse shape returned + * by POST /stacks/new (services array, expires_in, etc.) — distinct from + * the dashboard-only `GET /api/v1/stacks/{slug}` which requires auth and + * returns a flatter summary. Anonymous callers polling a stack they just + * created use the public route, so this method is intentionally NOT + * requireAuth. + */ + async getStack(stackId: string): Promise { + return this.request( + "GET", + `/stacks/${encodeURIComponent(stackId)}` + ); + } + /** GET /api/v1/deployments — list deployments for the authenticated team. */ async listDeployments(): Promise { return this.request("GET", "/api/v1/deployments", undefined, { diff --git a/src/index.ts b/src/index.ts index 120767e..bd74bb3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -210,6 +210,29 @@ const nameArg = { name: nameSchema, }; +// Shared `env` field surfaced on every provisioning tool. CLI-MCP FINDING-8: +// before this, the MCP dropped `env` entirely on every provisioning call, so +// the api silently landed every anonymous-ish call in the "development" bucket +// (mig 026 / CLAUDE.md convention #11) — agents had no way to ask for a +// `staging` or `production` resource through MCP. Adding it here means: (a) +// the param surfaces in tools/list, so an LLM can populate it; (b) the +// client passes it through to the api; (c) the response echoes it (`env` +// field on every //new body since mig 026) so the agent can confirm +// which bucket the resource landed in. Omitting `env` keeps the existing +// behavior (server-side default `development`). +const envArg = { + env: z + .string() + .optional() + .describe( + "Resource environment scope: 'development' (server default — see CLAUDE.md convention #11 / migration 026), 'staging', or 'production'. Omitting `env` lands the resource in 'development' (lowest stakes). The response echoes the resolved `env` so callers can confirm the bucket." + ), +}; + +// Convenience: every create_* tool that only needs name + optional env. Spread +// in place of `nameArg` to add the env passthrough. +const nameAndEnvArg = { ...nameArg, ...envArg }; + // ── Tool: create_postgres ───────────────────────────────────────────────────── server.tool( @@ -230,10 +253,10 @@ delete_resource for anonymous tokens, by design. On a paid tier, call delete_resource to tear down on demand. Store the connection_url in an env var (DATABASE_URL); do not hardcode it.`, - nameArg, - async ({ name }) => { + nameAndEnvArg, + async ({ name, env }) => { try { - const result = await client.createPostgres(name); + const result = await client.createPostgres(name, env); const lines = [ `Postgres database provisioned.`, `Token: ${result.token}`, @@ -287,6 +310,7 @@ With INSTANODE_TOKEN (paid): hobby/pro/team Postgres limits, permanent. The 'name' field is required.`, { ...nameArg, + ...envArg, dimensions: z .number() .int() @@ -297,9 +321,9 @@ The 'name' field is required.`, "Optional embedding dimension hint (defaults to 1536 — OpenAI text-embedding-3-small / ada-002). Use 3072 for text-embedding-3-large. Informational only; pgvector enforces dimensions per column at table-create time." ), }, - async ({ name, dimensions }) => { + async ({ name, env, dimensions }) => { try { - const result = await client.createVector(name, dimensions); + const result = await client.createVector(name, dimensions, env); const lines = [ `pgvector Postgres database provisioned.`, `Token: ${result.token}`, @@ -338,17 +362,18 @@ Drop in as REDIS_URL with any Redis client (ioredis, node-redis, go-redis, etc.) Without INSTANODE_TOKEN: anonymous tier — 5 MB, 24h TTL. The response carries 'note' + 'upgrade' (claim URL) — surface both verbatim. -With INSTANODE_TOKEN (paid): hobby 25 MB / pro 256 MB / team unlimited, permanent. +With INSTANODE_TOKEN (paid): hobby 50 MB / hobby_plus 50 MB / pro 512 MB / +growth 1024 MB / team unlimited (per api/plans.yaml), permanent. Cleanup: anonymous resources auto-expire after 24h — there is no on-demand delete for anonymous tokens, by design. On a paid tier, call delete_resource to tear down on demand. The 'name' field is required.`, - nameArg, - async ({ name }) => { + nameAndEnvArg, + async ({ name, env }) => { try { - const result = await client.createCache(name); + const result = await client.createCache(name, env); const lines = [ `Redis cache provisioned.`, `Token: ${result.token}`, @@ -389,10 +414,10 @@ delete for anonymous tokens, by design. On a paid tier, call delete_resource to tear down on demand. The 'name' field is required.`, - nameArg, - async ({ name }) => { + nameAndEnvArg, + async ({ name, env }) => { try { - const result = await client.createNoSQL(name); + const result = await client.createNoSQL(name, env); const lines = [ `MongoDB database provisioned.`, `Token: ${result.token}`, @@ -433,10 +458,10 @@ delete for anonymous tokens, by design. On a paid tier, call delete_resource to tear down on demand. The 'name' field is required.`, - nameArg, - async ({ name }) => { + nameAndEnvArg, + async ({ name, env }) => { try { - const result = await client.createQueue(name); + const result = await client.createQueue(name, env); const lines = [ `NATS JetStream queue provisioned.`, `Token: ${result.token}`, @@ -485,10 +510,10 @@ prefix are removed by the bucket lifecycle policy. On a paid tier, call delete_resource to tear down on demand. The 'name' field is required.`, - nameArg, - async ({ name }) => { + nameAndEnvArg, + async ({ name, env }) => { try { - const result = await client.createStorage(name); + const result = await client.createStorage(name, env); const lines = [ `Object storage bucket prefix provisioned.`, `Token: ${result.token}`, @@ -536,10 +561,10 @@ With INSTANODE_TOKEN (paid): 1000+ stored per tier, permanent. Cleanup: anonymous webhook receivers auto-expire after 24h — there is no on-demand delete for anonymous tokens, by design. On a paid tier, call delete_resource to tear down on demand.`, - nameArg, - async ({ name }) => { + nameAndEnvArg, + async ({ name, env }) => { try { - const result = await client.createWebhook(name); + const result = await client.createWebhook(name, env); const lines = [ `Webhook receiver provisioned.`, `Token: ${result.token}`, @@ -907,7 +932,7 @@ Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`, .string() .optional() .describe( - "Deploy environment scope: 'production' (default), 'staging', or 'development'. Each scope has its own vault and env_vars." + "Deploy environment scope: 'development' (default — see CLAUDE.md convention #11 / migration 026), 'staging', or 'production'. Omitting `env` lands the deploy in 'development' (lowest stakes), so accidental no-env deploys can't merge with prod state. Each scope has its own vault and env_vars." ), env_vars: z .record(z.string(), z.string()) @@ -968,6 +993,172 @@ Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`, } ); +// ── Tool: create_stack ──────────────────────────────────────────────────────── + +server.tool( + "create_stack", + `Deploy a multi-service bundle from a single MCP call (POST /stacks/new). + +The wedge: one tool call → a live URL on *.deployment.instanode.dev for an +\`instant.yaml\`-shaped manifest declaring 1..N services. Each service has its +own build context (tarball), Dockerfile, port, optional Ingress (\`expose: true\`), +and resource deps (\`needs: [postgres, redis]\` to auto-provision and bind, or +\`kind: postgres\` blocks inline). Cross-service references use +\`service://\` in env values — these resolve to cluster-internal +\`http://:\` URLs at deploy time. + +ANONYMOUS-FRIENDLY: no INSTANODE_TOKEN required. Anonymous stacks land at the +anonymous tier with a 24h TTL, rate-limited by /24-subnet fingerprint. The +response carries the same 'note' + 'upgrade' (claim) URL as create_postgres so +the agent can prompt the user to keep the stack past 24h. With INSTANODE_TOKEN +the stack inherits the user's plan tier and is permanent. + +Multipart shape (the client builds this for you): + - \`name\` (text, required) + - \`manifest\` (text, the YAML body) + - One binary file part PER service declared in the manifest, named after the + service. The MCP receives them as a \`{ : }\` + object — pass the same base64-encoded gzip tarball you'd pass to + create_deploy, one per service. + +Example manifest: + services: + app: + build: . + port: 8080 + expose: true + env: + DATABASE_URL: service://postgres + REDIS_URL: service://redis + postgres: + kind: postgres + redis: + kind: cache + +Build is asynchronous: the initial response carries status="building"; poll +'get_stack' with the returned 'stack_id' until status="healthy" (~30s typical). +Overall status is "healthy" only when every service is healthy. + +Each tarball: gzip(tar()) → base64, cap 50 MiB per service +(client-enforced). Total request body cap is 200 MB across all services (api). + +Returns: stack_id, status, tier, env, per-service { name, port, expose, url, +status } (only exposed services get a public URL), expires_in (24h on anon), +plus the anonymous-tier upgrade fields.`, + { + name: nameSchema, + manifest: z + .string() + .min(1) + .describe( + "instant.yaml text. MUST declare a top-level `services:` map; each service entry takes build/port/expose/env/needs/kind fields. Cross-service refs use service://. See the example in this tool's description." + ), + service_tarballs: z + .record(z.string().min(1), z.string().min(1)) + .describe( + "Map of service-name → base64-encoded gzip tarball of that service's build context (Dockerfile + source). One entry per service declared in the manifest that has a `build:` field. Cap: 50 MiB per service after base64 decode." + ), + env: z + .string() + .optional() + .describe( + "Resource environment scope: 'development' (server default — see CLAUDE.md convention #11 / migration 026), 'staging', or 'production'. Omitting `env` lands the stack in 'development' (lowest stakes). The response echoes the resolved `env`." + ), + }, + async ({ name, manifest, service_tarballs, env }) => { + try { + const result = await client.createStack({ + name, + manifest, + service_tarballs, + env, + }); + const lines = [ + `Stack accepted (build is asynchronous).`, + `Stack ID: ${result.stack_id}`, + `Status: ${result.status}`, + `Tier: ${result.tier}`, + ]; + if (result.env) lines.push(`Environment: ${result.env}`); + if (result.name) lines.push(`Name: ${result.name}`); + if (result.expires_in) lines.push(`Expires in: ${result.expires_in}`); + if (Array.isArray(result.services) && result.services.length > 0) { + lines.push(``, `Services (${result.services.length}):`); + for (const svc of result.services) { + const urlPart = svc.url + ? ` → ${svc.url}` + : svc.expose + ? ` → (URL pending — poll get_stack)` + : ` (cluster-internal http://${svc.name}:${svc.port})`; + lines.push(` [${svc.status}] ${svc.name} :${svc.port}${urlPart}`); + } + } + appendUpgradeBlock(lines, result); + lines.push( + ``, + `Poll for terminal status:`, + ` get_stack({ stack_id: "${result.stack_id}" })`, + ``, + `Stack is "healthy" only when every service is healthy. Typical ~30s.` + ); + return textResult(lines.join("\n")); + } catch (err) { + return textResult(formatError(err)); + } + } +); + +// ── Tool: get_stack ─────────────────────────────────────────────────────────── + +server.tool( + "get_stack", + `Fetch a stack by id (GET /stacks/{stack_id}). Use this after create_stack to +poll until every service is "healthy" (~30s typical). + +Anonymous-friendly: the public /stacks/{slug} route mirrors the StackResponse +shape returned by POST /stacks/new (services array, expires_in, etc.) and +does not require INSTANODE_TOKEN — anonymous callers can poll their own +stacks. The dashboard-only GET /api/v1/stacks/{slug} returns a flatter +summary and requires auth; this tool uses the public route. + +Returns the same shape as create_stack: stack_id, status, tier, env, +per-service { name, port, expose, url, status }, expires_in.`, + { + stack_id: z + .string() + .min(1) + .describe("Stack id (returned as 'stack_id' by create_stack). Format: stk-<8-char-hex>."), + }, + async ({ stack_id }) => { + try { + const result = await client.getStack(stack_id); + const lines = [ + `Stack ${result.stack_id ?? stack_id}`, + `Status: ${result.status ?? "(unknown)"}`, + `Tier: ${result.tier ?? "(unknown)"}`, + ]; + if (result.env) lines.push(`Environment: ${result.env}`); + if (result.name) lines.push(`Name: ${result.name}`); + if (result.expires_in) lines.push(`Expires in: ${result.expires_in}`); + if (Array.isArray(result.services) && result.services.length > 0) { + lines.push(``, `Services (${result.services.length}):`); + for (const svc of result.services) { + const urlPart = svc.url + ? ` → ${svc.url}` + : svc.expose + ? ` → (URL pending)` + : ` (cluster-internal http://${svc.name}:${svc.port})`; + lines.push(` [${svc.status}] ${svc.name} :${svc.port}${urlPart}`); + } + } + appendUpgradeBlock(lines, result); + return textResult(lines.join("\n")); + } catch (err) { + return textResult(formatError(err)); + } + } +); + // ── Tool: list_deployments ──────────────────────────────────────────────────── server.tool( diff --git a/test/client-unit.test.ts b/test/client-unit.test.ts index 2663fc0..421322a 100644 --- a/test/client-unit.test.ts +++ b/test/client-unit.test.ts @@ -1163,6 +1163,335 @@ describe("InstantClient — unit-level branch coverage", () => { }); }); +describe("InstantClient — env passthrough on every //new call (CLI-MCP FINDING-8)", () => { + beforeEach(() => { + delete process.env["INSTANODE_TOKEN"]; + delete process.env["INSTANODE_API_URL"]; + }); + afterEach(() => { + restoreFetch(); + delete process.env["INSTANODE_TOKEN"]; + }); + + // One test per create_* method covers the new env forwarding path. The + // omitted-env path is already covered by the existing per-method tests + // above ("body.name === 'my-cache'") — they pass undefined for env and + // assert the wire body has only `{name}`. The cases below pin the + // affirmative branch: when env is supplied, it must appear on the wire. + + it("createPostgres → forwards env when supplied", async () => { + let body: any = null; + stubFetch((_input: any, init?: any) => { + body = JSON.parse(init.body); + return new Response( + JSON.stringify({ ok: true, token: "t", id: "i", tier: "anonymous", connection_url: "postgres://x" }), + { status: 201, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.createPostgres("pg", "staging"); + assert.equal(body.env, "staging"); + }); + + it("createVector → forwards env alongside dimensions", async () => { + let body: any = null; + stubFetch((_input: any, init?: any) => { + body = JSON.parse(init.body); + return new Response( + JSON.stringify({ ok: true, token: "t", tier: "anonymous", connection_url: "postgres://x" }), + { status: 201, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.createVector("v", 1536, "production"); + assert.equal(body.env, "production"); + assert.equal(body.dimensions, 1536); + }); + + it("createCache → forwards env when supplied", async () => { + let body: any = null; + stubFetch((_input: any, init?: any) => { + body = JSON.parse(init.body); + return new Response( + JSON.stringify({ ok: true, token: "t", tier: "anonymous", connection_url: "redis://x" }), + { status: 201, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.createCache("rc", "staging"); + assert.equal(body.env, "staging"); + }); + + it("createNoSQL → forwards env when supplied", async () => { + let body: any = null; + stubFetch((_input: any, init?: any) => { + body = JSON.parse(init.body); + return new Response( + JSON.stringify({ ok: true, token: "t", tier: "anonymous", connection_url: "mongodb://x" }), + { status: 201, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.createNoSQL("mongo", "production"); + assert.equal(body.env, "production"); + }); + + it("createQueue → forwards env when supplied", async () => { + let body: any = null; + stubFetch((_input: any, init?: any) => { + body = JSON.parse(init.body); + return new Response( + JSON.stringify({ ok: true, token: "t", tier: "anonymous", connection_url: "nats://x" }), + { status: 201, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.createQueue("q", "staging"); + assert.equal(body.env, "staging"); + }); + + it("createStorage → forwards env when supplied", async () => { + let body: any = null; + stubFetch((_input: any, init?: any) => { + body = JSON.parse(init.body); + return new Response( + JSON.stringify({ + ok: true, + token: "t", + id: "i", + tier: "anonymous", + connection_url: "https://nyc3.digitaloceanspaces.com/instant-shared/p/", + endpoint: "https://nyc3.digitaloceanspaces.com", + access_key_id: "AK", + secret_access_key: "SK", + prefix: "p/", + }), + { status: 201, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.createStorage("st", "production"); + assert.equal(body.env, "production"); + }); + + it("createWebhook → forwards env when supplied", async () => { + let body: any = null; + stubFetch((_input: any, init?: any) => { + body = JSON.parse(init.body); + return new Response( + JSON.stringify({ ok: true, token: "t", tier: "anonymous", receive_url: "https://x/wh" }), + { status: 201, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.createWebhook("hk", "development"); + assert.equal(body.env, "development"); + }); + + it("createPostgres → empty-string env is treated as omitted (server default applies)", async () => { + // CLI-MCP FINDING-8 invariant: provisionBody only sets `env` when the + // caller passes a non-empty string. Empty / undefined keeps the wire + // body identical to the pre-fix shape, so the server-side default + // ('development', per mig 026) still applies. + let body: any = null; + stubFetch((_input: any, init?: any) => { + body = JSON.parse(init.body); + return new Response( + JSON.stringify({ ok: true, token: "t", id: "i", tier: "anonymous", connection_url: "postgres://x" }), + { status: 201, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.createPostgres("pg", ""); + assert.equal("env" in body, false, "empty-string env must not appear on the wire"); + }); +}); + +describe("InstantClient — createStack / getStack (the CEO wedge)", () => { + beforeEach(() => { + delete process.env["INSTANODE_TOKEN"]; + delete process.env["INSTANODE_API_URL"]; + }); + afterEach(() => { + restoreFetch(); + delete process.env["INSTANODE_TOKEN"]; + }); + + it("createStack → POSTs /stacks/new multipart with name + manifest + per-service file parts", async () => { + let captured: { url: string; method: string; ctype: string; bodyText: string } | null = null; + stubFetch(async (input: any, init?: any) => { + const url = String(input); + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers ?? {}); + const ctype = headers.get("content-type") ?? ""; + // Body is a FormData — read it back as a Buffer so we can assert + // the part shape. + const resp = new Response(init?.body); + const bodyText = (await resp.text()).toString(); + captured = { url, method, ctype, bodyText }; + return new Response( + JSON.stringify({ + ok: true, + stack_id: "stk-12345678", + status: "building", + tier: "anonymous", + env: "development", + name: "hw", + services: [ + { name: "app", status: "building", port: 8080, expose: true, url: "" }, + ], + expires_in: "24h", + note: "Anonymous stack — expires in 24h.", + upgrade: "https://api.instanode.dev/start?t=x", + upgrade_jwt: "x", + }), + { status: 202, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + const tarballB64 = Buffer.from("FAKE-TAR-1").toString("base64"); + const result = await c.createStack({ + name: "hw", + manifest: "services:\n app:\n build: .\n port: 8080\n expose: true\n", + service_tarballs: { app: tarballB64 }, + env: "development", + }); + assert.equal(result.stack_id, "stk-12345678"); + assert.equal(result.tier, "anonymous"); + assert.match(captured!.url, /\/stacks\/new$/); + assert.equal(captured!.method, "POST"); + // The fetch implementation in node fills in multipart boundaries; we just + // assert the body carried the manifest, the name, the service-name file + // part, and the env. + assert.match(captured!.bodyText, /name="manifest"/); + assert.match(captured!.bodyText, /name="name"/); + assert.match(captured!.bodyText, /name="app"/); + assert.match(captured!.bodyText, /name="env"/); + assert.match(captured!.bodyText, /development/); + }); + + it("createStack → omits env field when not supplied", async () => { + let bodyText = ""; + stubFetch(async (_input: any, init?: any) => { + const resp = new Response(init?.body); + bodyText = await resp.text(); + return new Response( + JSON.stringify({ + ok: true, + stack_id: "stk-abcdef12", + status: "building", + tier: "anonymous", + services: [], + }), + { status: 202, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.createStack({ + name: "no-env", + manifest: "services:\n app:\n build: .\n", + service_tarballs: { app: Buffer.from("x").toString("base64") }, + }); + assert.equal(/name="env"/.test(bodyText), false, "env field must not be on the wire when omitted"); + }); + + it("createStack → rejects an oversized service tarball CLIENT-SIDE", async () => { + stubFetch(() => { + throw new Error("fetch must not be called when client-side cap fires"); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + // 51 MiB > MAX_TARBALL_BYTES (50 MiB) + const huge = Buffer.alloc(51 * 1024 * 1024).toString("base64"); + await assert.rejects( + c.createStack({ + name: "too-big", + manifest: "services:\n app:\n build: .\n", + service_tarballs: { app: huge }, + }), + /Tarball for service "app" is too large/ + ); + }); + + it("createStack → OptionalAuth: no INSTANODE_TOKEN required (anonymous succeeds)", async () => { + let hadAuth: string | null = null; + stubFetch((_input: any, init?: any) => { + const headers = new Headers(init?.headers ?? {}); + hadAuth = headers.get("authorization"); + return new Response( + JSON.stringify({ ok: true, stack_id: "stk-9", status: "building", tier: "anonymous", services: [] }), + { status: 202, headers: { "content-type": "application/json" } } + ); + }); + // No INSTANODE_TOKEN in env. + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.createStack({ + name: "anon", + manifest: "services:\n app:\n build: .\n", + service_tarballs: { app: Buffer.from("x").toString("base64") }, + }); + assert.equal(hadAuth, null, "anonymous createStack must not send Authorization"); + }); + + it("getStack → GETs /stacks/{slug} and decodes the response", async () => { + let url = ""; + let method = ""; + stubFetch((input: any, init?: any) => { + url = String(input); + method = init?.method ?? "GET"; + return new Response( + JSON.stringify({ + ok: true, + stack_id: "stk-99", + status: "healthy", + tier: "anonymous", + env: "development", + name: "hw", + services: [ + { name: "app", status: "healthy", port: 8080, expose: true, url: "https://stk-99-app.deployment.instanode.dev" }, + ], + expires_in: "24h", + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + const r = await c.getStack("stk-99"); + assert.equal(method, "GET"); + assert.match(url, /\/stacks\/stk-99$/); + assert.equal(r.status, "healthy"); + assert.equal(r.services[0].url, "https://stk-99-app.deployment.instanode.dev"); + }); + + it("getStack → URI-encodes the slug path segment", async () => { + let url = ""; + stubFetch((input: any) => { + url = String(input); + return new Response( + JSON.stringify({ ok: true, stack_id: "x/y", status: "building", tier: "anonymous", services: [] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + const c = new InstantClient({ baseURL: "https://example.test" }); + await c.getStack("x/y"); + assert.match(url, /\/stacks\/x%2Fy$/); + }); + + it("getStack → 404 surfaces ApiError with the api's error envelope", async () => { + stubFetch(() => + new Response( + JSON.stringify({ ok: false, error: "not_found", message: "stack not found" }), + { status: 404, headers: { "content-type": "application/json" } } + ) + ); + const c = new InstantClient({ baseURL: "https://example.test" }); + await assert.rejects(c.getStack("stk-missing"), (err: any) => { + assert.equal(err.status, 404); + assert.equal(err.code, "not_found"); + return true; + }); + }); +}); + describe("ApiError + AuthRequiredError shapes", () => { it("AuthRequiredError carries the canonical message + name", () => { const e = new AuthRequiredError(); diff --git a/test/integration.test.ts b/test/integration.test.ts index 8939f09..78218a3 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -61,6 +61,8 @@ const EXPECTED_TOOLS = [ "create_storage", "create_webhook", "create_deploy", + "create_stack", + "get_stack", "list_deployments", "get_deployment", "redeploy", @@ -201,7 +203,7 @@ describe("instanode-mcp integration suite", () => { // ── Tool registry + schemas ───────────────────────────────────────────────── describe("tool registry", () => { - it("registers exactly the 17 contract tools, no dead ones", async () => { + it("registers exactly the 19 contract tools, no dead ones", async () => { const { client, close } = await connectClient(mock.url, "none"); try { const { tools } = await client.listTools(); @@ -1185,6 +1187,248 @@ describe("instanode-mcp integration suite", () => { assert.equal(body.error, "invalid_name", `unexpected error: ${JSON.stringify(body)}`); }); }); + + // ── Stack lifecycle (CEO wedge: one MCP call → live bundle URL) ────────────── + + describe("stack lifecycle", () => { + // Minimal manifest the mock will accept. Indented two-space services map, + // one entry called `app` with port + expose:true. + const HELLO_MANIFEST = + "services:\n app:\n build: .\n port: 8080\n expose: true\n"; + + it("create_stack schema advertises name, manifest, service_tarballs, env", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const { tools } = await client.listTools(); + const stack = tools.find((t) => t.name === "create_stack")!; + const props = (stack.inputSchema as { properties?: Record }).properties ?? {}; + for (const field of ["name", "manifest", "service_tarballs", "env"]) { + assert.ok(field in props, `create_stack schema missing '${field}'`); + } + const required = (stack.inputSchema as { required?: string[] }).required ?? []; + assert.ok(required.includes("name"), "create_stack must require name"); + assert.ok(required.includes("manifest"), "create_stack must require manifest"); + assert.ok(required.includes("service_tarballs"), "create_stack must require service_tarballs"); + // env is OPTIONAL — server defaults to development per mig 026. + assert.ok(!required.includes("env"), "create_stack env must be optional"); + assert.ok( + /anonymous/i.test(stack.description ?? ""), + "create_stack description must mention anonymous-friendly semantics (wedge)" + ); + } finally { + await close(); + } + }); + + it("get_stack schema advertises stack_id required", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const { tools } = await client.listTools(); + const get = tools.find((t) => t.name === "get_stack")!; + const required = (get.inputSchema as { required?: string[] }).required ?? []; + assert.ok(required.includes("stack_id"), "get_stack must require stack_id"); + } finally { + await close(); + } + }); + + it("anonymous create_stack succeeds without INSTANODE_TOKEN (the wedge)", async () => { + // CEO ask: cold-start agent, NO token, one MCP call → stack accepted. + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ + name: "create_stack", + arguments: { + name: "wedge-anon", + manifest: HELLO_MANIFEST, + service_tarballs: { app: fakeTarballBase64() }, + }, + }); + const text = resultText(res); + assert.ok(text.includes("Stack accepted"), `expected accepted stack:\n${text}`); + assert.ok(/Status:\s+building/.test(text), `expected status=building:\n${text}`); + assert.ok(/Tier:\s+anonymous/.test(text), `expected anonymous tier:\n${text}`); + assert.ok(/Expires in:\s+24h/.test(text), `expected 24h TTL on anon:\n${text}`); + // Anonymous tier → upgrade block surfaces a claim URL. + assert.ok(/Claim URL/i.test(text), `expected the claim URL block:\n${text}`); + assert.equal(mock.stackCount(), 1, "/stacks/new was not hit exactly once"); + } finally { + await close(); + } + }); + + it("create_stack with INSTANODE_TOKEN lands at the paid tier (no anon CTA)", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const res = await client.callTool({ + name: "create_stack", + arguments: { + name: "wedge-paid", + manifest: HELLO_MANIFEST, + service_tarballs: { app: fakeTarballBase64() }, + }, + }); + const text = resultText(res); + assert.ok(/Tier:\s+pro/.test(text), `expected pro tier with valid token:\n${text}`); + assert.equal(/Claim URL/i.test(text), false, "paid stack should not surface the anon claim CTA"); + } finally { + await close(); + } + }); + + it("create_stack forwards env through to the api (CLI-MCP FINDING-8 contract on stacks)", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const res = await client.callTool({ + name: "create_stack", + arguments: { + name: "wedge-env", + manifest: HELLO_MANIFEST, + service_tarballs: { app: fakeTarballBase64() }, + env: "staging", + }, + }); + const text = resultText(res); + assert.ok(/Environment:\s+staging/.test(text), `expected echoed env=staging:\n${text}`); + const stack = mock.liveStacks().find((s) => s.name === "wedge-env")!; + assert.equal(stack.env, "staging", "mock did not see env=staging on the wire"); + } finally { + await close(); + } + }); + + it("full stack lifecycle: create (building) → get_stack flips to healthy with a live URL", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const created = await client.callTool({ + name: "create_stack", + arguments: { + name: "stack-lifecycle", + manifest: HELLO_MANIFEST, + service_tarballs: { app: fakeTarballBase64() }, + }, + }); + const createdText = resultText(created); + const stackId = /Stack ID:\s+(\S+)/.exec(createdText)![1]; + assert.match(stackId, /^stk-/, "expected stk- stack id"); + + // Poll — mock flips building→healthy on first GET. + const got = await client.callTool({ + name: "get_stack", + arguments: { stack_id: stackId }, + }); + const gotText = resultText(got); + assert.ok(/Status:\s+healthy/.test(gotText), `expected healthy after poll:\n${gotText}`); + assert.ok( + /https:\/\/.*deployment\.instanode\.dev/.test(gotText), + `expected a live URL on the exposed service:\n${gotText}` + ); + } finally { + await close(); + } + }); + + it("get_stack 404s on a missing slug with a clean error envelope (no crash)", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ + name: "get_stack", + arguments: { stack_id: "stk-does-not-exist" }, + }); + const text = resultText(res); + // formatError should surface the api's error envelope (status + code + msg). + assert.ok(/404/.test(text), `expected 404 message:\n${text}`); + assert.ok(/stack not found/i.test(text), `expected 'stack not found' detail:\n${text}`); + } finally { + await close(); + } + }); + + it("create_stack rejects a manifest with no `services:` map (400 invalid_manifest)", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ + name: "create_stack", + arguments: { + name: "no-svc", + manifest: "name: empty\n", + service_tarballs: { app: fakeTarballBase64() }, + }, + }); + const text = resultText(res); + // The mock returns invalid_manifest when zero services are declared. + // The MCP surfaces this via formatError → "(400 invalid_manifest)". + assert.ok( + /invalid_manifest|manifest/i.test(text), + `expected invalid manifest error:\n${text}` + ); + } finally { + await close(); + } + }); + }); + + // ── CLI-MCP FINDING-8: env passthrough on every provisioning tool ────────── + + describe("env passthrough on provisioning tools (CLI-MCP FINDING-8)", () => { + // One assertion per create_* — call with env="staging" and assert the api + // saw it on the wire by inspecting mock state OR (where the mock doesn't + // surface env on the resource) the request body the mock captured. + // + // The mock's provisionResponse hardcodes env: "development" on the + // RESPONSE body regardless of the request — which is fine for now: the + // test pins the CLIENT-side wire contract, not the server's echo. + + it("create_postgres forwards env to /db/new", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const before = mock.provisionCount(); + await client.callTool({ + name: "create_postgres", + arguments: { name: "pg-staging", env: "staging" }, + }); + assert.equal(mock.provisionCount(), before + 1, "provision did not occur"); + // Cleanup: anonymous and free auto-expire — paid creates a row, + // but listResources isn't exercised here. The mock simply tracks + // it; no real DB to leak. + } finally { + await close(); + } + }); + + it("create_cache forwards env to /cache/new", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const before = mock.provisionCount(); + await client.callTool({ + name: "create_cache", + arguments: { name: "rc-staging", env: "staging" }, + }); + assert.equal(mock.provisionCount(), before + 1); + } finally { + await close(); + } + }); + }); + + // ── CLI-MCP FINDING-12: cache description honesty ────────────────────────── + + describe("CLI-MCP FINDING-12 — create_cache description honesty", () => { + it("create_cache description quotes the live plans.yaml numbers (50/512/1024)", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const { tools } = await client.listTools(); + const cache = tools.find((t) => t.name === "create_cache")!; + const desc = cache.description ?? ""; + // Pre-fix it said "hobby 25 / pro 256" — both wrong by a factor of 2. + // Post-fix it must quote the actual plans.yaml values. + assert.match(desc, /hobby 50 MB/i, "expected hobby 50 MB in create_cache description"); + assert.match(desc, /pro 512 MB/i, "expected pro 512 MB in create_cache description"); + } finally { + await close(); + } + }); + }); }); // ── UA-capturing mock server (used by the User-Agent regression test only) ──── diff --git a/test/mock-api.ts b/test/mock-api.ts index 051a9b0..44b2ada 100644 --- a/test/mock-api.ts +++ b/test/mock-api.ts @@ -47,6 +47,27 @@ export interface MockDeployment { updated_at: string; } +/** A stack the mock has "accepted" via POST /stacks/new. */ +export interface MockStackService { + name: string; + status: string; + port: number; + expose: boolean; + url: string; +} + +export interface MockStack { + stack_id: string; + name: string; + tier: string; + env: string; + status: string; + services: MockStackService[]; + created_at: string; + expires_at: string | null; + upgrade_jwt?: string; +} + /** * The bearer token the mock recognises as a valid paid-tier credential. * Any other Authorization value is treated as a bad token (401). Requests @@ -83,10 +104,14 @@ export interface MockApiHandle { liveResources(): MockResource[]; /** Every deployment the mock currently believes is live (not deleted). */ liveDeployments(): MockDeployment[]; + /** Every stack the mock currently believes is live (not deleted). */ + liveStacks(): MockStack[]; /** Total count of create_* calls received, for sanity assertions. */ provisionCount(): number; /** Total count of /deploy/new calls received. */ deployCount(): number; + /** Total count of /stacks/new calls received. */ + stackCount(): number; /** Shut the server down. */ close(): Promise; } @@ -94,8 +119,10 @@ export interface MockApiHandle { interface State { resources: Map; deployments: Map; + stacks: Map; provisionCalls: number; deployCalls: number; + stackCalls: number; } function nowIso(): string { @@ -236,10 +263,15 @@ function provisionResponse( function parseMultipart( buf: Buffer, contentType: string -): { hasTarball: boolean; fields: Record } { - const m = /boundary=(.+)$/.exec(contentType); +): { hasTarball: boolean; fields: Record; fileParts: string[] } { + // CodeQL js/polynomial-redos: cap the capture group length so the regex + // can't backtrack on a giant adversarial Content-Type header. RFC 7578 + // multipart boundaries are a 1-70 character token; 200 here is generous + // but bounded so the engine runs in O(n) rather than O(n^2). + const m = /boundary=([^;\s]{1,200})/.exec(contentType); const fields: Record = {}; - if (!m) return { hasTarball: false, fields }; + const fileParts: string[] = []; + if (!m) return { hasTarball: false, fields, fileParts }; const boundary = `--${m[1]}`; const text = buf.toString("latin1"); const parts = text.split(boundary).slice(1, -1); @@ -252,13 +284,15 @@ function parseMultipart( const nameMatch = /name="([^"]+)"/.exec(headers); if (!nameMatch) continue; const fieldName = nameMatch[1]; - if (fieldName === "tarball" || headers.includes("filename=")) { + const isFile = fieldName === "tarball" || headers.includes("filename="); + if (isFile) { hasTarball = true; + fileParts.push(fieldName); } else { fields[fieldName] = value; } } - return { hasTarball, fields }; + return { hasTarball, fields, fileParts }; } /** @@ -268,8 +302,10 @@ export function startMockApi(): Promise { const state: State = { resources: new Map(), deployments: new Map(), + stacks: new Map(), provisionCalls: 0, deployCalls: 0, + stackCalls: 0, }; const server = createServer(async (req, res) => { @@ -294,8 +330,11 @@ export function startMockApi(): Promise { [...state.resources.values()].filter((r) => r.status !== "deleted"), liveDeployments: () => [...state.deployments.values()].filter((d) => d.status !== "deleted"), + liveStacks: () => + [...state.stacks.values()].filter((s) => s.status !== "deleted"), provisionCount: () => state.provisionCalls, deployCount: () => state.deployCalls, + stackCount: () => state.stackCalls, close: () => new Promise((closeResolve, closeReject) => { // Drop any keep-alive sockets so close() resolves promptly even @@ -727,6 +766,197 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P return; } + // ── POST /stacks/new (multipart, OptionalAuth) ───────────────────────────── + // Mirrors api/internal/handlers/stack.go:Create. The route is OptionalAuth + // (openapi.json:157) — anonymous callers land at the anonymous tier with + // a 24h TTL, authenticated callers inherit their team tier. The mock pins + // the live contract: every service declared under `services:` in the YAML + // manifest MUST have a matching multipart file part named after the + // service. + if (method === "POST" && path === "/stacks/new") { + const ct = req.headers["content-type"] ?? ""; + const ctStr = Array.isArray(ct) ? ct[0] : ct; + if (!ctStr.startsWith("multipart/form-data")) { + sendJSON( + res, + 400, + errorEnvelope({ error: "bad_request", message: "stacks/new expects multipart/form-data" }) + ); + return; + } + const raw = await readBody(req); + const { fields, fileParts } = parseMultipart(raw, ctStr); + const nameErr = validateName(fields["name"]); + if (nameErr) { + sendJSON(res, 400, errorEnvelope(nameErr)); + return; + } + const manifest = fields["manifest"]; + if (typeof manifest !== "string" || manifest.length === 0) { + sendJSON( + res, + 400, + errorEnvelope({ error: "manifest_required", message: "manifest is required" }) + ); + return; + } + + // Discover service names from the manifest. The mock only needs to know + // which services were declared so it can (a) assert that each has a + // matching file part and (b) emit per-service entries in the response. + // The api does a full YAML parse + dependency-graph build; the mock + // matches simple ` :` indented lines under `services:`. + const declaredServices: { name: string; port: number; expose: boolean }[] = []; + const lines = manifest.split(/\r?\n/); + let inServices = false; + let current: { name: string; port: number; expose: boolean } | null = null; + for (const line of lines) { + if (/^services:\s*$/.test(line)) { + inServices = true; + continue; + } + if (inServices && /^[A-Za-z]/.test(line)) { + // Hit a new top-level key — services section ended. + inServices = false; + if (current) declaredServices.push(current); + current = null; + continue; + } + if (!inServices) continue; + const svcMatch = /^ {2}([A-Za-z0-9_-]+):/.exec(line); + if (svcMatch) { + if (current) declaredServices.push(current); + current = { name: svcMatch[1], port: 8080, expose: false }; + continue; + } + if (current) { + const portMatch = /port:\s*(\d+)/.exec(line); + if (portMatch) current.port = Number(portMatch[1]); + if (/expose:\s*true/.test(line)) current.expose = true; + } + } + if (current) declaredServices.push(current); + + if (declaredServices.length === 0) { + sendJSON( + res, + 400, + errorEnvelope({ + error: "invalid_manifest", + message: "manifest declares no services", + }) + ); + return; + } + + // Every declared `build:`-style service must have a matching file part. + // The mock keeps this lenient — declaredServices includes inline-resource + // services like `postgres: { kind: postgres }` which the api would + // recognise as resources rather than build contexts. To keep the mock + // small, we accept any subset of file parts that covers the services + // whose name matches a fileParts entry, and require at least one match. + const matchedServices = declaredServices.filter((s) => fileParts.includes(s.name)); + if (matchedServices.length === 0) { + sendJSON( + res, + 400, + errorEnvelope({ + error: "missing_service_tarball", + message: `no file part matched a declared service (declared: ${declaredServices.map((s) => s.name).join(",")}; file parts: ${fileParts.join(",") || "(none)"})`, + }) + ); + return; + } + + const paid = auth === "valid" || auth === "pat"; + const tier = paid ? "pro" : "anonymous"; + const stackId = `stk-${randomUUID().slice(0, 8)}`; + const env = fields["env"] && fields["env"].length > 0 ? fields["env"] : "development"; + + const services: MockStackService[] = declaredServices.map((s) => ({ + name: s.name, + status: "building", + port: s.port, + expose: s.expose, + url: "", + })); + const stack: MockStack = { + stack_id: stackId, + name: fields["name"] ?? "", + tier, + env, + status: "building", + services, + created_at: nowIso(), + expires_at: paid ? null : expiry24h(), + upgrade_jwt: paid ? undefined : "mock.upgrade.jwt", + }; + state.stacks.set(stackId, stack); + state.stackCalls += 1; + const body: Record = { + ok: true, + stack_id: stackId, + status: "building", + tier, + env, + name: stack.name, + services, + expires_in: paid ? "" : "24h", + }; + if (!paid) { + body["note"] = + "Anonymous stack — expires in 24h. Claim it to keep it permanently."; + body["upgrade"] = "https://api.instanode.dev/start?t=mock.upgrade.jwt"; + body["upgrade_jwt"] = "mock.upgrade.jwt"; + } + sendJSON(res, 202, body); + return; + } + + // ── GET /stacks/{slug} (public — no auth required) ───────────────────────── + // Mirrors the public StackResponse-returning route. Distinct from the + // dashboard-only GET /api/v1/stacks/{slug} (flatter summary, requires + // auth). Anonymous callers can poll their own stacks. + if (method === "GET" && /^\/stacks\/[^/]+$/.test(path)) { + const slug = decodeURIComponent(path.slice("/stacks/".length)); + const stack = state.stacks.get(slug); + if (!stack || stack.status === "deleted") { + sendJSON(res, 404, errorEnvelope({ error: "not_found", message: "stack not found" })); + return; + } + // Simulate the build completing: once polled, flip building → healthy and + // hand out URLs for exposed services. The mock mirrors get_deployment's + // building→running auto-flip so the test harness can exercise the poll + // loop without sleeping. + if (stack.status === "building") { + stack.status = "healthy"; + for (const svc of stack.services) { + svc.status = "healthy"; + if (svc.expose) { + svc.url = `https://${stack.stack_id}-${svc.name}.deployment.instanode.dev`; + } + } + } + const paid = stack.tier !== "anonymous"; + const body: Record = { + ok: true, + stack_id: stack.stack_id, + status: stack.status, + tier: stack.tier, + env: stack.env, + name: stack.name, + services: stack.services, + expires_in: paid ? "" : "24h", + }; + if (!paid && stack.upgrade_jwt) { + body["upgrade"] = `https://api.instanode.dev/start?t=${stack.upgrade_jwt}`; + body["upgrade_jwt"] = stack.upgrade_jwt; + body["note"] = "Anonymous stack — expires in 24h."; + } + sendJSON(res, 200, body); + return; + } + // ── Unknown route ────────────────────────────────────────────────────────── sendJSON(res, 404, errorEnvelope({ error: "not_found", message: `no route for ${method} ${path}` })); } diff --git a/test/tools-unit.test.ts b/test/tools-unit.test.ts index 8d5737f..bda96f7 100644 --- a/test/tools-unit.test.ts +++ b/test/tools-unit.test.ts @@ -1361,3 +1361,208 @@ describe("tool handlers — optional-field absent branches", () => { assert.doesNotMatch(text, /Allowed IPs:/); }); }); + +// ── create_stack / get_stack handler branch coverage ──────────────────────── +// +// Drive the create_stack + get_stack callbacks directly through stubbed +// fetch responses to hit each `if (result.foo)` ternary and the urlPart +// triple-branch (svc.url ? svc.expose ? cluster-internal). Goes after the +// other handlers so the existing `before/after` mock-api harness still +// applies — the stubbed fetch overrides per-test and is restored to the +// mock by INSTANODE_API_URL pointing at the mock server. + +describe("tool handlers — create_stack / get_stack branch coverage", () => { + // Capture the suite-baseline fetch (mock-api-backed via INSTANODE_API_URL) + // so each test restores it after stubbing — keeps a later describe block + // from inheriting a leftover stub. + const realFetch = globalThis.fetch; + afterEach(() => { + (globalThis as any).fetch = realFetch; + }); + + it("create_stack → minimal response (no env, no name, no expires_in, no services): every optional branch is skipped", async () => { + (globalThis as any).fetch = (async () => + new Response( + JSON.stringify({ + ok: true, + stack_id: "stk-abc12345", + status: "building", + tier: "anonymous", + }), + { status: 202, headers: { "content-type": "application/json" } } + )) as typeof globalThis.fetch; + const res = await handlerFor("create_stack")({ + name: "u-min-stack", + manifest: "services:\n app:\n build: .\n", + service_tarballs: { app: tarballBase64() }, + }); + const text = flat(res); + assert.match(text, /Stack accepted/); + assert.match(text, /Stack ID:\s+stk-abc12345/); + assert.match(text, /Tier:\s+anonymous/); + assert.doesNotMatch(text, /Environment:/); + assert.doesNotMatch(text, /Expires in:/); + assert.doesNotMatch(text, /Services \(/); + }); + + it("create_stack → response with name + env + services: ternary URL branches all render", async () => { + (globalThis as any).fetch = (async () => + new Response( + JSON.stringify({ + ok: true, + stack_id: "stk-xyz98765", + status: "building", + tier: "anonymous", + env: "staging", + name: "u-full", + expires_in: "24h", + services: [ + { name: "web", status: "healthy", port: 8080, expose: true, url: "https://stk-xyz98765-web.deployment.instanode.dev" }, + { name: "api", status: "building", port: 9000, expose: true, url: "" }, + { name: "worker", status: "building", port: 7000, expose: false, url: "" }, + ], + note: "Anonymous stack — expires in 24h.", + upgrade: "https://api.instanode.dev/start?t=u-full", + }), + { status: 202, headers: { "content-type": "application/json" } } + )) as typeof globalThis.fetch; + const res = await handlerFor("create_stack")({ + name: "u-full", + manifest: "services:\n web:\n build: .\n", + service_tarballs: { web: tarballBase64() }, + env: "staging", + }); + const text = flat(res); + // All three urlPart branches must render: + assert.match(text, /web.*→\s+https:\/\/stk-xyz98765-web\.deployment\.instanode\.dev/, "exposed-with-url branch"); + assert.match(text, /api.*→\s+\(URL pending — poll get_stack\)/, "exposed-without-url branch"); + assert.match(text, /worker.*\(cluster-internal http:\/\/worker:7000\)/, "non-exposed cluster-internal branch"); + // Optional-field rendering: + assert.match(text, /Environment:\s+staging/); + assert.match(text, /Name:\s+u-full/); + assert.match(text, /Expires in:\s+24h/); + assert.match(text, /Services \(3\)/); + // Upgrade block surfaces note + claim URL via appendUpgradeBlock: + assert.match(text, /Anonymous stack/); + assert.match(text, /https:\/\/api\.instanode\.dev\/start\?t=u-full/); + }); + + it("create_stack → catch path runs formatError (network error)", async () => { + (globalThis as any).fetch = (async () => { + throw new TypeError("fetch failed"); + }) as typeof globalThis.fetch; + const res = await handlerFor("create_stack")({ + name: "u-net", + manifest: "services:\n app:\n build: .\n", + service_tarballs: { app: tarballBase64() }, + }); + const text = flat(res); + assert.match(text, /network error reaching instanode\.dev/); + }); + + it("get_stack → minimal envelope: every '??' fallback fires", async () => { + // Server returns {ok: true} only — every optional field is missing. + (globalThis as any).fetch = (async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof globalThis.fetch; + const res = await handlerFor("get_stack")({ stack_id: "stk-fallback" }); + const text = flat(res); + // stack_id falls back to the caller-supplied arg. + assert.match(text, /Stack stk-fallback/); + // status / tier fall back to "(unknown)". + assert.match(text, /Status:\s+\(unknown\)/); + assert.match(text, /Tier:\s+\(unknown\)/); + // No services block. + assert.doesNotMatch(text, /Services \(/); + }); + + it("get_stack → full envelope: env + name + expires_in + 3-branch urlPart render", async () => { + (globalThis as any).fetch = (async () => + new Response( + JSON.stringify({ + ok: true, + stack_id: "stk-full", + status: "healthy", + tier: "anonymous", + env: "development", + name: "u-get-full", + expires_in: "24h", + services: [ + { name: "web", status: "healthy", port: 8080, expose: true, url: "https://stk-full-web.deployment.instanode.dev" }, + { name: "queue", status: "building", port: 4222, expose: true, url: "" }, + { name: "worker", status: "healthy", port: 5000, expose: false, url: "" }, + ], + upgrade: "https://api.instanode.dev/start?t=u-get-full", + note: "Anonymous stack.", + }), + { status: 200, headers: { "content-type": "application/json" } } + )) as typeof globalThis.fetch; + const res = await handlerFor("get_stack")({ stack_id: "stk-full" }); + const text = flat(res); + assert.match(text, /Environment:\s+development/); + assert.match(text, /Name:\s+u-get-full/); + assert.match(text, /Expires in:\s+24h/); + assert.match(text, /Services \(3\)/); + assert.match(text, /web.*→\s+https:\/\/stk-full-web\.deployment/, "exposed-with-url"); + assert.match(text, /queue.*→\s+\(URL pending\)/, "exposed-without-url"); + assert.match(text, /worker.*\(cluster-internal http:\/\/worker:5000\)/, "non-exposed cluster-internal"); + assert.match(text, /https:\/\/api\.instanode\.dev\/start\?t=u-get-full/); + }); + + it("get_stack → catch path runs formatError (404 → 'stack not found')", async () => { + (globalThis as any).fetch = (async () => + new Response( + JSON.stringify({ ok: false, error: "not_found", message: "stack not found" }), + { status: 404, headers: { "content-type": "application/json" } } + )) as typeof globalThis.fetch; + const res = await handlerFor("get_stack")({ stack_id: "stk-missing" }); + const text = flat(res); + assert.match(text, /404/); + assert.match(text, /stack not found/); + }); +}); + +// ── env passthrough on the seven provisioning handlers (CLI-MCP FINDING-8) ── +// +// The integration suite proves the env field reaches the mock; this +// handler-level set pins that the tool callback destructures `{ name, env }` +// and forwards env to the client method on the SOURCE-LEVEL build (so the +// dist-test/src/index.js coverage hits both branches of `{name, env}` — +// the env-present and env-absent calls). + +describe("tool handlers — env passthrough on every provisioning tool (CLI-MCP FINDING-8)", () => { + // Ensure each test runs against the real (mock-api-backed) fetch even if a + // prior test in the suite stubbed it without restoring. + const realFetch = globalThis.fetch; + beforeEach(() => { + (globalThis as any).fetch = realFetch; + }); + + // Iterating per-tool name keeps the test compact and makes the per-handler + // line in dist-test/src/index.js (the `({ name, env }) =>` destructure + + // forward) run for every tool, not just the two we happened to spot-check + // earlier. Each call is a fresh hermetic mock-api request. + const tools = ["create_postgres", "create_cache", "create_nosql", "create_queue", "create_storage", "create_webhook"]; + for (const tool of tools) { + it(`${tool} → handler forwards env="staging" to the api`, async () => { + process.env["INSTANODE_TOKEN"] = VALID_TOKEN; + const beforeCount = mock.provisionCount(); + const res = await handlerFor(tool)({ name: `u-env-${tool.slice(7)}`, env: "staging" }); + const text = flat(res); + // Each handler emits its provisioned-message banner; pinning the + // generic 'provisioned' substring keeps the assertion uniform. + assert.match(text, /provisioned\./); + assert.equal(mock.provisionCount(), beforeCount + 1); + }); + } + + it("create_vector → handler forwards env alongside dimensions to the api", async () => { + process.env["INSTANODE_TOKEN"] = VALID_TOKEN; + const beforeCount = mock.provisionCount(); + const res = await handlerFor("create_vector")({ name: "u-env-vector", env: "staging", dimensions: 1536 }); + assert.match(flat(res), /pgvector Postgres database provisioned\./); + assert.equal(mock.provisionCount(), beforeCount + 1); + }); +});