From 74a3c80c67d227d8679c72d047d083e5531346fd Mon Sep 17 00:00:00 2001 From: jwfing Date: Tue, 18 Aug 2026 15:07:49 -0700 Subject: [PATCH] Add MySQL and MongoDB service commands --- src/commands/services.ts | 12 ++++++++---- src/index.ts | 6 +++--- src/resolve-service.ts | 4 +++- test/resolve-service.test.ts | 12 +++++++++--- test/services.test.ts | 8 ++++++++ 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/commands/services.ts b/src/commands/services.ts index 1a2bd09..257a2f5 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 | redis). +// `insta services` — manage a project's opt-in services (postgres | storage | compute | redis | mysql | mongodb). import { ApiClient, requireProject } from '../api.js' import { info, printJson, handleApproval, renderNextActions } from '../util.js' -export const SERVICE_TYPES = ['postgres', 'storage', 'compute', 'redis'] as const +export const SERVICE_TYPES = ['postgres', 'storage', 'compute', 'redis', 'mysql', 'mongodb'] as const export type ServiceType = (typeof SERVICE_TYPES)[number] const SERVICE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,38}$/ @@ -75,6 +75,10 @@ export function resolveComputeServiceId(services: Array<{ id: string; type: stri return resolveSoleService(services, 'compute', name).id } +function defaultDatabasePort(type: string): number { + return type === 'mysql' ? 3306 : type === 'mongodb' ? 27017 : 6379 +} + // ---- commands ---- export type ServicesAddOpts = { branch?: string; public?: boolean; image?: string; port?: string; region?: string; alwaysOn?: boolean; volume?: string; json?: boolean } @@ -125,7 +129,7 @@ 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 === 'redis' ? ` tcp/${s.port ?? 6379}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}` + : ['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'}` : '' return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}` } @@ -136,7 +140,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 7fb5afc..e2a918a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,14 +117,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/redis) ---- -const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis)') +// ---- services (opt-in postgres/storage/compute/redis/mysql/mongodb) ---- +const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis|mysql|mongodb)') // [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/redis, e.g. us-east (see `insta regions`)') + .option('--region ', 'region for postgres/compute/managed databases, 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 61b2a1d..2973ea0 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|redis`, so missing arguments answer +// guessing wrong and reading `type must be postgres|storage|compute|redis|mysql|mongodb`, 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 @@ -23,6 +23,8 @@ 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: 'mysql', label: 'MySQL', type: 'mysql', hint: 'private MySQL database', defaultName: 'mysql-db' }, + { id: 'mongodb', label: 'MongoDB', type: 'mongodb', hint: 'private MongoDB database', defaultName: 'mongo-db' }, { 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 561ba6a..fbdf63c 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', 'Redis', 'Storage', 'Empty Service']) + expect(SERVICE_KINDS.map((k) => k.label)).toEqual(['Docker Image', 'Postgres', 'Redis', 'MySQL', 'MongoDB', 'Storage', 'Empty Service']) expect(kind('image').needsImage).toBe(true) expect(kind('image').type).toBe('compute') expect(kind('compute').needsImage).toBeUndefined() @@ -45,6 +45,8 @@ test('Docker Image is its own kind, at the same level as Empty Service', () => { test('default names match the Add Service placeholders', () => { expect(kind('postgres').defaultName).toBe('main-db') expect(kind('redis').defaultName).toBe('cache') + expect(kind('mysql').defaultName).toBe('mysql-db') + expect(kind('mongodb').defaultName).toBe('mongo-db') expect(kind('storage').defaultName).toBe('assets') expect(kind('compute').defaultName).toBe('compute') expect(kind('image').defaultName).toBeUndefined() @@ -97,6 +99,8 @@ test('no TTY: throws, and the message lists every kind with its command', async 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('insta services add mysql mysql-db') + expect(msg).toContain('insta services add mongodb mongo-db') expect(msg).toContain('--image ') }) @@ -105,8 +109,8 @@ test('no TTY with a type: asks for the missing half, not the whole list', () => }) test('unknown type: passed through for assertType to report, prompts untouched', async () => { - const r = await resolveServiceArgs('mysql', undefined, deps({ tty: false })) - expect(r).toEqual({ type: 'mysql', name: '' }) + const r = await resolveServiceArgs('lambda', undefined, deps({ tty: false })) + expect(r).toEqual({ type: 'lambda', name: '' }) }) test('kind lines stay one per kind and carry a runnable command', () => { @@ -114,6 +118,8 @@ test('kind lines stay one per kind and carry a runnable command', () => { 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') + expect(lines.join('\n')).toContain('insta services add mysql mysql-db') + expect(lines.join('\n')).toContain('insta services add mongodb mongo-db') }) // Same rules as the dashboard's helpers, so a ref names the service identically in both. diff --git a/test/services.test.ts b/test/services.test.ts index 222933d..79c4adf 100644 --- a/test/services.test.ts +++ b/test/services.test.ts @@ -183,4 +183,12 @@ describe('serviceListLine', () => { 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') }) + it.each([ + ['redis', 'cache', 6379], + ['mysql', 'mysql-db', 3306], + ['mongodb', 'mongo-db', 27017], + ])('renders a managed %s row with its default TCP port', (type, name, port) => { + const line = serviceListLine({ type, name, status: 'active', id: 'svc_4', volume_gib: 1 }) + expect(line).toBe(`${type}/${name} [active] tcp/${port} vol 1Gi svc_4`) + }) })