diff --git a/src/commands/services.ts b/src/commands/services.ts index 4a32bd2..cc645fa 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -4,6 +4,7 @@ import { info, printJson, handleApproval, renderNextActions } from '../util.js' export const SERVICE_TYPES = ['postgres', 'storage', 'compute'] as const export type ServiceType = (typeof SERVICE_TYPES)[number] +const SERVICE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,38}$/ export function q(branch?: string): string { return branch ? `?branch=${encodeURIComponent(branch)}` : '' @@ -16,6 +17,10 @@ export function assertType(type: string, allowed: readonly string[] = SERVICE_TY if (!allowed.includes(type)) throw new Error(`type must be ${allowed.join('|')}`) } +export function assertServiceName(name: string): void { + if (!SERVICE_NAME_RE.test(name)) throw new Error('service name must be lower-kebab (a-z, 0-9, -)') +} + // Parse a positive-integer machine count. export function parseCount(raw: string): number { const n = Number(raw) @@ -107,6 +112,20 @@ export async function servicesRemove(type: string, name: string, opts: { branch? info(`removed ${type} service ${name} from ${branch ?? 'default'}`) } +export async function servicesRename(type: string, name: string, newName: string, opts: { branch?: string; json?: boolean } = {}): Promise { + assertType(type) + assertServiceName(newName) + const api = await ApiClient.load() + const p = await requireProject() + const branch = opts.branch ?? p.branch + const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) + const id = resolveServiceId(services, type, name) + const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/rename`, { name: newName }) + if (handleApproval(res)) return + if (opts.json) return printJson(res.body.service) + info(`renamed ${type} service ${name} to ${newName}`) +} + // Validate a bucket access-mode argument. export function parseAccess(raw: string): boolean { if (raw === 'public') return true diff --git a/src/index.ts b/src/index.ts index b090722..b494e9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -115,6 +115,9 @@ svc.command('list').option('--json').option('--branch ', 'branch (defaul svc.command('remove ').description('Remove a service and destroy its resources') .option('--branch ', 'branch (default: current)') .action(guard((type, name, o) => services.servicesRemove(type, name, o))) +svc.command('rename ').description('Rename a service and re-key its managed secret names') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((type, name, newName, o) => services.servicesRename(type, name, newName, o))) svc.command('set-access ').description('Set a storage service bucket access mode (access: public|private)') .option('--json').action(guard((type, name, access, o) => services.servicesSetAccess(type, name, access, o))) svc.command('scale [region]').description('Set a compute service machine count (paid plans only)') diff --git a/test/services.test.ts b/test/services.test.ts index 8f8b34c..73785a7 100644 --- a/test/services.test.ts +++ b/test/services.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { - assertType, parseCount, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES, + assertType, assertServiceName, parseCount, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES, servicesAddRequestBody, servicesAdd, serviceListLine, } from '../src/commands/services.js' @@ -31,6 +31,16 @@ describe('parseCount', () => { }) }) +describe('assertServiceName', () => { + it('accepts lower-kebab service names', () => { + expect(() => assertServiceName('primary-db')).not.toThrow() + }) + it('rejects names outside the platform service-name rule', () => { + expect(() => assertServiceName('Primary')).toThrow(/lower-kebab/) + expect(() => assertServiceName('-db')).toThrow(/lower-kebab/) + }) +}) + describe('parseAccess', () => { it('maps public/private to a boolean', () => { expect(parseAccess('public')).toBe(true)