diff --git a/src/commands/services.ts b/src/commands/services.ts index 8b31a2e..e31802f 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -45,20 +45,44 @@ export function resolveComputeServiceId(services: Array<{ id: string; type: stri // ---- commands ---- -export async function servicesAdd(type: string, name: string, opts: { branch?: string; public?: boolean } = {}): Promise { +export type ServicesAddOpts = { branch?: string; public?: boolean; image?: string; port?: string } + +// Map service-add options to the platform POST body. Pure, so it's unit-tested without a network +// mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which +// type) stays in servicesAdd, ahead of any network/config access. +export function servicesAddRequestBody(type: string, name: string, branch: string | undefined, opts: ServicesAddOpts): Record { + return { + type, name, ...(branch ? { branch } : {}), public: !!opts.public, + ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: Number(opts.port) } : {}), + } +} + +export async function servicesAdd(type: string, name: string, opts: ServicesAddOpts = {}): Promise { assertType(type) if (opts.public && type !== 'storage') throw new Error('--public is only valid for storage services') + if (opts.image && type !== 'compute') throw new Error('--image is only valid for compute services') + if (opts.port && type !== 'compute') throw new Error('--port is only valid for compute services') const api = await ApiClient.load() const p = await requireProject() const branch = opts.branch ?? p.branch - const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, { type, name, ...(branch ? { branch } : {}), public: !!opts.public }) + const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, servicesAddRequestBody(type, name, branch, opts)) if (handleApproval(res)) return const svc = res.body.service const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '' - info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.domain ? ` — ${svc.domain}` : ''}`) + const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '' + info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${img}${svc.domain ? ` — ${svc.domain}` : ''}`) renderNextActions(res.body.nextActions) } +// 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 }): string { + const extra = s.type === 'compute' + ? ` x${s.machine_count}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}` + : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '' + return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}` +} + export async function servicesList(opts: { json?: boolean; branch?: string }): Promise { const api = await ApiClient.load() const p = await requireProject() @@ -66,10 +90,7 @@ export async function servicesList(opts: { json?: boolean; branch?: string }): P const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) if (opts.json) return printJson(services) if (!services.length) return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add \`)`) - for (const s of services) { - const extra = s.type === 'compute' ? ` x${s.machine_count}` : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '' - info(`${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`) - } + for (const s of services) info(serviceListLine(s)) } export async function servicesRemove(type: string, name: string, opts: { branch?: string } = {}): Promise { diff --git a/src/index.ts b/src/index.ts index fdff722..4c19bff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -105,6 +105,8 @@ const svc = program.command('services').alias('svc').description('Manage project svc.command('add ').description('Provision a service on demand (assigns a default domain for postgres/compute)') .option('--branch ', 'target branch (default: current)') .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)') + .option('--image ', 'compute only: run this container image at creation') + .option('--port ', 'compute only: port the image listens on (default 8080)') .action(guard((type, name, o) => services.servicesAdd(type, name, o))) svc.command('list').option('--json').option('--branch ', 'branch (default: current)') .action(guard((o) => services.servicesList(o))) diff --git a/test/services.test.ts b/test/services.test.ts index 36568a2..9b035ca 100644 --- a/test/services.test.ts +++ b/test/services.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest' -import { assertType, parseCount, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES } from '../src/commands/services.js' +import { + assertType, parseCount, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES, + servicesAddRequestBody, servicesAdd, serviceListLine, +} from '../src/commands/services.js' describe('assertType', () => { it('accepts valid service types', () => { @@ -75,3 +78,59 @@ describe('resolveComputeServiceId', () => { expect(() => resolveComputeServiceId([{ id: 'a', type: 'postgres', name: 'db' }])).toThrow(/no compute service/) }) }) + +describe('servicesAddRequestBody', () => { + it('omits image/port when not passed', () => { + const b = servicesAddRequestBody('compute', 'api', 'main', {}) + expect(b).toEqual({ type: 'compute', name: 'api', branch: 'main', public: false }) + }) + it('sends image and port (as a number) when passed', () => { + const b = servicesAddRequestBody('compute', 'api', 'main', { image: 'ghcr.io/acme/api:latest', port: '3000' }) + expect(b).toMatchObject({ image: 'ghcr.io/acme/api:latest', port: 3000 }) + expect(b.port).toBe(3000) // Number, not the raw string + }) + it('omits branch when undefined', () => { + expect(servicesAddRequestBody('postgres', 'db', undefined, {})).toEqual({ type: 'postgres', name: 'db', public: false }) + }) + it('carries --public through unchanged', () => { + expect(servicesAddRequestBody('storage', 'bkt', 'main', { public: true })).toMatchObject({ public: true }) + }) +}) + +describe('servicesAdd validation (throws before any network/config access)', () => { + it('rejects --image for a non-compute type', async () => { + await expect(servicesAdd('storage', 'bkt', { image: 'ghcr.io/acme/api:latest' })).rejects.toThrow(/--image is only valid for compute services/) + }) + it('rejects --port for a non-compute type', async () => { + await expect(servicesAdd('postgres', 'db', { port: '3000' })).rejects.toThrow(/--port is only valid for compute services/) + }) + it('rejects --public for a non-storage type', async () => { + await expect(servicesAdd('compute', 'api', { public: true })).rejects.toThrow(/--public is only valid for storage services/) + }) + it('rejects an unknown service type before any option checks', async () => { + await expect(servicesAdd('lambda', 'x', {})).rejects.toThrow(/postgres\|storage\|compute/) + }) +}) + +describe('serviceListLine', () => { + 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') + }) + it('renders a compute row without a port suffix when port is absent', () => { + const line = serviceListLine({ type: 'compute', name: 'api', status: 'active', id: 'svc_1', machine_count: 1, image: 'ghcr.io/acme/api:latest' }) + expect(line).toBe('compute/api [active] x1 running ghcr.io/acme/api:latest svc_1') + }) + it('renders a compute row unchanged when no image is reported', () => { + const line = serviceListLine({ type: 'compute', name: 'api', status: 'active', id: 'svc_1', machine_count: 2 }) + expect(line).toBe('compute/api [active] x2 svc_1') + }) + it('renders a storage row with access, unaffected by the image change', () => { + const line = serviceListLine({ type: 'storage', name: 'bkt', status: 'active', id: 'svc_2', public: true }) + expect(line).toBe('storage/bkt [active] public svc_2') + }) + it('renders a postgres row with domain', () => { + const line = serviceListLine({ type: 'postgres', name: 'db', status: 'active', id: 'svc_3', domain: 'db.example.com' }) + expect(line).toBe('postgres/db [active] db.example.com svc_3') + }) +})