Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/commands/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The SERVICE_NAME_RE regex allows service names to end with a hyphen (e.g., my-db-) or contain consecutive hyphens (test--double). The error message describes the convention as "lower-kebab" but kebab-case typically doesn't allow trailing or consecutive hyphens. Consider tightening the regex to reject trailing hyphens so the CLI catches these before the platform API does, keeping the error message consistent and actionable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/services.ts, line 7:

<comment>The `SERVICE_NAME_RE` regex allows service names to end with a hyphen (e.g., `my-db-`) or contain consecutive hyphens (`test--double`). The error message describes the convention as "lower-kebab" but kebab-case typically doesn't allow trailing or consecutive hyphens. Consider tightening the regex to reject trailing hyphens so the CLI catches these before the platform API does, keeping the error message consistent and actionable.</comment>

<file context>
@@ -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 {
</file context>


export function q(branch?: string): string {
return branch ? `?branch=${encodeURIComponent(branch)}` : ''
Expand All @@ -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)
Expand Down Expand Up @@ -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<void> {
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
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ svc.command('list').option('--json').option('--branch <branch>', 'branch (defaul
svc.command('remove <type> <name>').description('Remove a service and destroy its resources')
.option('--branch <branch>', 'branch (default: current)')
.action(guard((type, name, o) => services.servicesRemove(type, name, o)))
svc.command('rename <type> <name> <new-name>').description('Rename a service and re-key its managed secret names')
.option('--json').option('--branch <branch>', 'branch (default: current)')
.action(guard((type, name, newName, o) => services.servicesRename(type, name, newName, o)))
svc.command('set-access <type> <name> <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 <type> <name> <number> [region]').description('Set a compute service machine count (paid plans only)')
Expand Down
12 changes: 11 additions & 1 deletion test/services.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
Expand Down
Loading