diff --git a/src/commands/services.ts b/src/commands/services.ts index 8e9e587..1a2bd09 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -1,8 +1,8 @@ -// `insta services` — manage a project's opt-in services (postgres | storage | compute). +// `insta services` — manage a project's opt-in services (postgres | storage | compute | redis). import { ApiClient, requireProject } from '../api.js' import { info, printJson, handleApproval, renderNextActions } from '../util.js' -export const SERVICE_TYPES = ['postgres', 'storage', 'compute'] as const +export const SERVICE_TYPES = ['postgres', 'storage', 'compute', 'redis'] as const export type ServiceType = (typeof SERVICE_TYPES)[number] const SERVICE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,38}$/ @@ -125,7 +125,8 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO 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 { 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}` : ''}` : ''}` - : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '' + : s.type === 'redis' ? ` tcp/${s.port ?? 6379}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}` + : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '' return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}` } @@ -135,7 +136,7 @@ export async function servicesList(opts: { json?: boolean; branch?: string }): P const branch = opts.branch ?? p.branch 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 \`)`) + if (!services.length) return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add \`)`) for (const s of services) info(serviceListLine(s)) } diff --git a/src/index.ts b/src/index.ts index e8a5d72..41813e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -116,14 +116,14 @@ br.command('delete ').action(guard((name) => branch.branchDelete(name))) br.command('merge ').description('Merge a branch service set into another (structural, no data)') .option('--into ', 'target branch (default: current)').action(guard((source, o) => branch.branchMerge(source, o))) -// ---- services (opt-in postgres/storage/compute) ---- -const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute)') +// ---- services (opt-in postgres/storage/compute/redis) ---- +const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis)') // [type] [name] are optional so the command can answer "what can I add?" — a terminal is walked // through the dashboard's Add Service kinds, anything else gets that list back as an error // (resolve-service.ts). Picking Docker Image also fills in --image/--port from the answers. svc.command('add [type] [name]').description('Provision a service on demand (assigns a default domain for postgres/compute); with no type/name, a terminal picks from the service kinds') .option('--branch ', 'target branch (default: current)') - .option('--region ', 'region for postgres/compute, e.g. us-east (see `insta regions`)') + .option('--region ', 'region for postgres/compute/redis, e.g. us-east (see `insta regions`)') .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)') diff --git a/src/resolve-service.ts b/src/resolve-service.ts index 8b5b378..61b2a1d 100644 --- a/src/resolve-service.ts +++ b/src/resolve-service.ts @@ -1,5 +1,5 @@ // `insta services add` with no type (or no name): the kinds are otherwise only discoverable by -// guessing wrong and reading `type must be postgres|storage|compute`, so missing arguments answer +// guessing wrong and reading `type must be postgres|storage|compute|redis`, so missing arguments answer // "what can I add?" instead. The list mirrors the dashboard's Add Service menu (frontend // `add-service-button.tsx`) — Docker Image sits BESIDE Empty Service, not under it, because // picking an image is a different intent rather than a compute flag. An agent gets the same list @@ -22,6 +22,7 @@ export type ServiceKind = { export const SERVICE_KINDS: readonly ServiceKind[] = [ { id: 'image', label: 'Docker Image', type: 'compute', hint: 'run an existing container image', needsImage: true }, { id: 'postgres', label: 'Postgres', type: 'postgres', hint: 'relational DB, usable as soon as it is added', defaultName: 'main-db' }, + { id: 'redis', label: 'Redis', type: 'redis', hint: 'private Redis-compatible cache', defaultName: 'cache' }, { id: 'storage', label: 'Storage', type: 'storage', hint: 'S3-compatible bucket, private by default', defaultName: 'assets' }, { id: 'compute', label: 'Empty Service', type: 'compute', hint: 'an app to deploy code to (empty until `insta deploy`)', defaultName: 'compute' }, ] diff --git a/test/resolve-service.test.ts b/test/resolve-service.test.ts index ff16fa5..561ba6a 100644 --- a/test/resolve-service.test.ts +++ b/test/resolve-service.test.ts @@ -35,7 +35,7 @@ test('every service type is reachable from some kind', () => { // The dashboard's Add Service lists Docker Image beside Empty Service, not under it. test('Docker Image is its own kind, at the same level as Empty Service', () => { - expect(SERVICE_KINDS.map((k) => k.label)).toEqual(['Docker Image', 'Postgres', 'Storage', 'Empty Service']) + expect(SERVICE_KINDS.map((k) => k.label)).toEqual(['Docker Image', 'Postgres', 'Redis', 'Storage', 'Empty Service']) expect(kind('image').needsImage).toBe(true) expect(kind('image').type).toBe('compute') expect(kind('compute').needsImage).toBeUndefined() @@ -44,6 +44,7 @@ test('Docker Image is its own kind, at the same level as Empty Service', () => { // Default names are the dashboard dialog's placeholders — they must not drift apart. test('default names match the Add Service placeholders', () => { expect(kind('postgres').defaultName).toBe('main-db') + expect(kind('redis').defaultName).toBe('cache') expect(kind('storage').defaultName).toBe('assets') expect(kind('compute').defaultName).toBe('compute') expect(kind('image').defaultName).toBeUndefined() @@ -95,6 +96,7 @@ test('no TTY: throws, and the message lists every kind with its command', async const msg = missingArgsMessage() for (const k of SERVICE_KINDS) expect(msg).toContain(k.label) expect(msg).toContain('insta services add postgres main-db') + expect(msg).toContain('insta services add redis cache') expect(msg).toContain('--image ') }) @@ -111,6 +113,7 @@ test('kind lines stay one per kind and carry a runnable command', () => { const lines = serviceKindLines() expect(lines).toHaveLength(SERVICE_KINDS.length) expect(lines.join('\n')).toContain('insta services add storage assets') + expect(lines.join('\n')).toContain('insta services add redis cache') }) // Same rules as the dashboard's helpers, so a ref names the service identically in both.