diff --git a/package-lock.json b/package-lock.json index da1bd233..e24246de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "insta", - "version": "0.0.56", + "version": "0.0.57", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "insta", - "version": "0.0.56", + "version": "0.0.57", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^0.9.1", diff --git a/package.json b/package.json index 0aa42598..e8e9a7fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "insta", - "version": "0.0.56", + "version": "0.0.57", "type": "module", "description": "InstaCloud CLI — a thin client of the platform control-plane API.", "keywords": [ diff --git a/src/commands/manifest.ts b/src/commands/manifest.ts index c4a67da8..01b97c36 100644 --- a/src/commands/manifest.ts +++ b/src/commands/manifest.ts @@ -1,4 +1,5 @@ import { ApiClient, requireProject } from '../api.js' +import { pgBadge } from './services.js' import { info, printJson } from '../util.js' // A resource row as the platform's project-detail read returns it. @@ -10,7 +11,9 @@ export type ManifestResource = { name?: string | null branchId?: string | null status?: string - ref?: { url?: string; bucket?: string; neonProjectId?: string } | null + // pgVersion: the Postgres MAJOR a database row runs (root and branch rows alike); absent on + // older platforms and on legacy rows that never recorded one. + ref?: { url?: string; bucket?: string; neonProjectId?: string; pgVersion?: number } | null } /** @@ -35,7 +38,9 @@ export function resourceLabel(r: ManifestResource): string { // One `manifest` resource line. Pure, so the label is unit-tested without a network mock. export function resourceLine(r: ManifestResource): string { const where = r.ref?.url ?? r.ref?.bucket ?? r.ref?.neonProjectId ?? '' - return ` - ${resourceLabel(r)} ${where} [${r.status}]` + // Database rows only: the platform stamps ref.pgVersion on insta-db (and legacy neon) resources. + const pg = r.kind === 'insta-db' || r.kind === 'neon' ? pgBadge(r.ref?.pgVersion) : '' + return ` - ${resourceLabel(r)} ${where}${pg} [${r.status}]` } // Agent-legible view of each environment's databases / storage / compute. diff --git a/src/commands/services.ts b/src/commands/services.ts index 7fc684b7..d3e32197 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -121,10 +121,7 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body.service) const svc = res.body.service - const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '' - const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '' - const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at /data` : '' - info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${svc.domain ? ` — ${svc.domain}` : ''}`) + info(serviceAddedLine(type, name, branch, svc)) // Discoverability: the DB is directly dialable, but its DSN is deliberately absent from the // general `insta secrets` bundle — without this line nothing in the product says how to reach it. if (type === 'postgres') { @@ -136,13 +133,34 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO renderNextActions(res.body.nextActions) } +// The `services add` success line. Pure, so the badge's placement is unit-tested: a template string +// nothing asserts on silently loses a segment. +export function serviceAddedLine(type: string, name: string, branch: string | undefined, svc: { id: string; type: string; public?: boolean; image?: string; port?: number; volume_gib?: number | null; region?: string; domain?: string; pg_version?: number | null }): string { + const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '' + const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '' + const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at /data` : '' + // The major belongs next to the connect hint: it decides which psql/pg_dump to reach for. + const pg = svc.type === 'postgres' ? pgBadge(svc.pg_version) : '' + return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${svc.domain ? ` — ${svc.domain}` : ''}` +} + +// ` pg ` for a postgres row, or '' when the platform sent nothing usable. The field arrives +// as untyped API JSON: only a positive integer renders, so a malformed or nonsense value (true, +// '16', 16.4, 0, -1) can never print as a version an agent would pick tooling by. +export function pgBadge(v: unknown): string { + return typeof v === 'number' && Number.isInteger(v) && v > 0 ? ` pg ${v}` : '' +} + // Render one `services list` row. Pure, so it's unit-tested without a network mock (mirrors // billingLines in billing.ts). Compute rows show the running image when the platform reports one. -export function serviceListLine(s: { type: string; name: string; status: string; id: string; domain?: string; machine_count?: number; public?: boolean; image?: string; port?: number; volume_gib?: number | null }): string { +export function serviceListLine(s: { type: string; name: string; status: string; id: string; domain?: string; machine_count?: number; public?: boolean; image?: string; port?: number; volume_gib?: number | null; pg_version?: number | null }): string { const extra = s.type === 'compute' ? ` x${s.machine_count}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}` : ['redis', 'mysql', 'mongodb'].includes(s.type) ? ` tcp/${s.port ?? defaultDatabasePort(s.type)}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}` - : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '' + : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` + // Postgres major, so the reader picks matching pg_dump/psql BEFORE connecting (a newer client + // dumps statements an older server cannot restore). Older platforms send no pg_version. + : s.type === 'postgres' ? pgBadge(s.pg_version) : '' return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}` } diff --git a/test/manifest-label.test.ts b/test/manifest-label.test.ts index c38a3e1e..f5159fde 100644 --- a/test/manifest-label.test.ts +++ b/test/manifest-label.test.ts @@ -48,6 +48,18 @@ describe('resourceLabel', () => { }) describe('resourceLine', () => { + it('names the Postgres major on a database row when the platform sends one', () => { + expect(resourceLine({ kind: 'insta-db', name: 'db', status: 'active', ref: { pgVersion: 16 } })).toBe(' - insta-db(db) pg 16 [active]') + expect(resourceLine({ kind: 'insta-db', name: 'db', status: 'active', ref: {} })).toBe(' - insta-db(db) [active]') + }) + it('never badges a non-database row, whatever its ref carries', () => { + expect(resourceLine({ kind: 's3', name: 'assets', status: 'active', ref: { bucket: 'b', pgVersion: 16 } })).toBe(' - s3(assets) b [active]') + }) + it('omits the badge when ref.pgVersion is not a positive integer (untyped API JSON)', () => { + for (const bad of [true, '16', 16.4, NaN, 0, -1] as unknown[]) { + expect(resourceLine({ kind: 'insta-db', name: 'db', status: 'active', ref: { pgVersion: bad as number } })).toBe(' - insta-db(db) [active]') + } + }) it('renders the microvm row exactly as staging should have shown it', () => { expect(resourceLine({ kind: 'fly', provider: 'microvm', name: 'api', status: 'active', diff --git a/test/services.test.ts b/test/services.test.ts index 58cda0b3..49d9c295 100644 --- a/test/services.test.ts +++ b/test/services.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { assertType, assertServiceName, parseCount, parsePort, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES, - servicesAddRequestBody, servicesAdd, serviceListLine, + servicesAddRequestBody, servicesAdd, serviceListLine, serviceAddedLine, } from '../src/commands/services.js' describe('assertType', () => { @@ -163,7 +163,33 @@ describe('servicesAdd validation (throws before any network/config access)', () }) }) +describe('serviceAddedLine', () => { + it('carries the Postgres major next to the connect hint for a postgres service', () => { + expect(serviceAddedLine('postgres', 'db', 'main', { id: 'svc_pg', type: 'postgres', region: 'us-east', domain: 'db.example.test', pg_version: 16 })) + .toBe('added postgres service db on main (svc_pg) us-east pg 16 — db.example.test') + }) + it('never badges a non-postgres service, and omits the badge when the platform sent no major', () => { + expect(serviceAddedLine('storage', 'assets', undefined, { id: 'svc_s3', type: 'storage', public: false, pg_version: 16 })) + .toBe('added storage service assets on default (svc_s3) [private]') + expect(serviceAddedLine('postgres', 'db', 'main', { id: 'svc_pg', type: 'postgres', pg_version: null })).not.toContain('pg ') + }) +}) + describe('serviceListLine', () => { + it('shows the Postgres major on a postgres row, so the reader picks matching client tooling', () => { + const line = serviceListLine({ type: 'postgres', name: 'db', status: 'active', id: 'svc_pg', domain: 'db.example.test', pg_version: 16 }) + expect(line).toContain('pg 16') + expect(line).toContain('db.example.test') + }) + it('omits the badge when the platform sent no pg_version (older platform, legacy row)', () => { + expect(serviceListLine({ type: 'postgres', name: 'db', status: 'active', id: 'svc_pg', pg_version: null })).not.toContain('pg ') + expect(serviceListLine({ type: 'storage', name: 'assets', status: 'active', id: 'svc_s3', pg_version: 16 })).not.toContain('pg 16') + }) + it('omits the badge when pg_version is not a positive integer (the API JSON is untyped on the wire)', () => { + for (const bad of [true, '16', 16.4, NaN, {}, 0, -1] as unknown[]) { + expect(serviceListLine({ type: 'postgres', name: 'db', status: 'active', id: 'svc_pg', pg_version: bad as number })).not.toContain('pg ') + } + }) it('renders a compute row with the running image when present', () => { const line = serviceListLine({ type: 'compute', name: 'api', status: 'active', id: 'svc_1', machine_count: 1, image: 'ghcr.io/acme/api:latest', port: 8080 }) expect(line).toBe('compute/api [active] x1 running ghcr.io/acme/api:latest:8080 svc_1')