From c7c3e68e73b9b2020306c19a8fd4bc1c5132b0ba Mon Sep 17 00:00:00 2001 From: jwfing Date: Mon, 20 Jul 2026 16:12:04 -0700 Subject: [PATCH] feat(services): add rename command --- src/commands/services.ts | 19 +++++++++++++++++++ src/index.ts | 3 +++ test/services.test.ts | 12 +++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/commands/services.ts b/src/commands/services.ts index dacee14..c09095f 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) @@ -84,6 +89,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 895154b..351c4e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,6 +111,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 36568a2..594f1a2 100644 --- a/test/services.test.ts +++ b/test/services.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { assertType, parseCount, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES } from '../src/commands/services.js' +import { assertType, assertServiceName, parseCount, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES } from '../src/commands/services.js' describe('assertType', () => { it('accepts valid service types', () => { @@ -28,6 +28,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)