diff --git a/package-lock.json b/package-lock.json index be6996b..4b99863 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.31", "license": "Apache-2.0", "dependencies": { + "@clack/prompts": "^0.9.1", "commander": "^12.1.0" }, "bin": { @@ -24,6 +25,27 @@ "node": ">=18" } }, + "node_modules/@clack/core": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.1.tgz", + "integrity": "sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.9.1.tgz", + "integrity": "sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==", + "license": "MIT", + "dependencies": { + "@clack/core": "0.4.1", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1185,7 +1207,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/postcss": { @@ -1269,6 +1290,12 @@ "dev": true, "license": "ISC" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 1e220b7..115a681 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@clack/prompts": "^0.9.1", "commander": "^12.1.0" }, "devDependencies": { diff --git a/src/commands/services.ts b/src/commands/services.ts index e5bdf03..f62ebcc 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -28,6 +28,16 @@ export function parseCount(raw: string): number { return n } +// Parse a TCP port. Junk fails here rather than reaching the API as NaN (the parseCpu lesson). +// Decimal digits only, as parseVolumeGib: `Number()` alone would quietly read 0x1f90 as 8080 and +// 1e3 as 1000, and a port written in hex is a typo worth reporting, not one worth honouring. +export function parsePort(raw: string): number { + const m = /^\s*(\d+)\s*$/.exec(raw) + const n = m ? Number(m[1]) : NaN + if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`port must be an integer between 1 and 65535, got: ${raw}`) + return n +} + // Parse a volume size in whole Gi: "10" or "10Gi" (suffix case-insensitive — unlike the db // quantity strings this is not a provider pass-through; the wire value is an integer). Volumes // are provisioned block disks, so fractional and Mi values are rejected locally with an example @@ -62,7 +72,7 @@ export function resolveComputeServiceId(services: Array<{ id: string; type: stri // ---- commands ---- -export type ServicesAddOpts = { branch?: string; public?: boolean; image?: string; port?: string; region?: string; alwaysOn?: boolean; volume?: string } +export type ServicesAddOpts = { branch?: string; public?: boolean; image?: string; port?: string; region?: string; alwaysOn?: boolean; volume?: string; json?: boolean } // Map service-add options to the platform POST body. Pure, so it's unit-tested without a network // mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which @@ -70,7 +80,7 @@ export type ServicesAddOpts = { branch?: string; public?: boolean; image?: strin export function servicesAddRequestBody(type: string, name: string, branch: string | undefined, opts: ServicesAddOpts): Record { return { type, name, ...(branch ? { branch } : {}), public: !!opts.public, - ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: Number(opts.port) } : {}), + ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}), ...(opts.region ? { region: opts.region } : {}), ...(opts.alwaysOn ? { alwaysOn: true } : {}), ...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}), @@ -82,7 +92,10 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO if (opts.public && type !== 'storage') throw new Error('--public is only valid for storage services') if (opts.region && type === 'storage') throw new Error('--region is not valid for storage services') if (opts.image && type !== 'compute') throw new Error('--image is only valid for compute services') - if (opts.port && type !== 'compute') throw new Error('--port is only valid for compute services') + if (opts.port) { + if (type !== 'compute') throw new Error('--port is only valid for compute services') + parsePort(opts.port) // junk fails here, before any config/network access + } if (opts.alwaysOn && type !== 'compute') throw new Error('--always-on is only valid for compute services (for postgres, use `insta db always-on on` after creation)') if (opts.volume !== undefined) { if (type !== 'compute') throw new Error('--volume is only valid for compute services (postgres has one by default — grow it with `insta db volume --size`)') @@ -93,6 +106,7 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO const branch = opts.branch ?? p.branch const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, servicesAddRequestBody(type, name, branch, opts)) if (handleApproval(res)) return + if (opts.json) return printJson(res.body.service) const svc = res.body.service const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '' const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '' diff --git a/src/index.ts b/src/index.ts index 35c8e49..c0b991c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import * as org from './commands/org.js' import * as project from './commands/project.js' import * as branch from './commands/branch.js' import * as services from './commands/services.js' +import { resolveServiceArgs, serviceArgsDeps } from './resolve-service.js' import * as regions from './commands/regions.js' import * as secretsCmd from './commands/secrets.js' import { deploy } from './commands/deploy.js' @@ -116,7 +117,10 @@ br.command('merge ').description('Merge a branch service set into anothe // ---- services (opt-in postgres/storage/compute) ---- const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute)') -svc.command('add ').description('Provision a service on demand (assigns a default domain for postgres/compute)') +// [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('--public', 'storage only: serve the bucket with anonymous public-read (default private)') @@ -124,7 +128,11 @@ svc.command('add ').description('Provision a service on demand (ass .option('--port ', 'compute only: port the image listens on (default 8080)') .option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)') .option('--volume ', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume --size `; any plan may attach at the default 1; larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle') - .action(guard((type, name, o) => services.servicesAdd(type, name, o))) + .option('--json') + .action(guard(async (type, name, o) => { + const a = await resolveServiceArgs(type, name, serviceArgsDeps(o.json), o) + return services.servicesAdd(a.type, a.name, { ...o, image: a.image ?? o.image, port: a.port ?? o.port }) + })) svc.command('list').option('--json').option('--branch ', 'branch (default: current)') .action(guard((o) => services.servicesList(o))) svc.command('remove ').description('Remove a service and destroy its resources') diff --git a/src/resolve-service.ts b/src/resolve-service.ts new file mode 100644 index 0000000..8b5b378 --- /dev/null +++ b/src/resolve-service.ts @@ -0,0 +1,184 @@ +// `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 +// "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 +// as an error, because nothing was created and a silent exit 0 would read as success. +import * as clack from '@clack/prompts' +import { SERVICE_TYPES, assertServiceName, parsePort, type ServiceType } from './commands/services.js' + +export type ServiceKind = { + id: string + label: string + type: ServiceType + hint: string + // Docker Image derives its name from the ref, so it carries no fixed default. + defaultName?: string + needsImage?: boolean +} + +// Same order, labels and default names as the dashboard's Add Service menu. Github Repo is left +// out: the platform has no repo path yet, so a CLI entry could only say "coming soon". +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: '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' }, +] + +// The platform's own default; the dialog prefills the same number. +export const DEFAULT_IMAGE_PORT = '8080' + +export type ResolvedServiceArgs = { type: string; name: string; image?: string; port?: string } + +export type ServiceArgsDeps = { + selectKind: (kinds: readonly ServiceKind[]) => Promise + askImage: () => Promise + askName: (kind: ServiceKind, suggested: string) => Promise + askPort: (fallback: string) => Promise + tty: boolean +} + +/** Registry refs aren't URLs — quietly strip a pasted scheme prefix (mirrors the dashboard). */ +export function normalizeImageRef(raw: string): string { + return raw.trim().replace(/^https?:\/\//, '') +} + +/** + * Name from an image ref: last path segment, sans tag/digest, kebab-safe (the dashboard's rule). + * Also capped at the 39 chars `assertServiceName` allows — a suggestion the user cannot accept + * unchanged is worse than none. + */ +export function suggestServiceName(ref: string): string { + const last = ref.split('@')[0]!.split('/').pop() ?? '' + return last + .split(':')[0]! + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 39) + .replace(/-+$/g, '') +} + +/** The non-interactive command for a kind — what an agent should run instead of being asked. */ +export function kindCommand(k: ServiceKind): string { + if (k.needsImage) return `insta services add compute --image --port ` + return `insta services add ${k.type} ${k.defaultName}` +} + +/** The kind list, one line each — what a terminal picks from and an agent reads. */ +export function serviceKindLines(): string[] { + return SERVICE_KINDS.map((k) => ` ${k.label.padEnd(14)} ${kindCommand(k)}`) +} + +/** What to say when there is no terminal to ask: the missing half, and how to supply it. */ +export function missingArgsMessage(type?: string): string { + // A bare type names the plain kind, never Docker Image — that one is reached with --image. + const known = SERVICE_KINDS.find((k) => k.type === type && !k.needsImage) + if (known) return `name the service: ${kindCommand(known)}` + return ['what to add:', ...serviceKindLines()].join('\n') +} + +/** + * Fill in whatever `insta services add` was not given. An unknown type passes straight through so + * `assertType` — not this — reports it, keeping one wording for a bad type everywhere. Flags that + * were already supplied are never asked for again. + */ +export async function resolveServiceArgs( + type: string | undefined, + name: string | undefined, + deps: ServiceArgsDeps, + given: { image?: string; port?: string } = {}, +): Promise { + if (type && name) return { type, name } + if (type && !SERVICE_TYPES.includes(type as ServiceType)) return { type, name: name ?? '' } + if (!deps.tty) throw new Error(missingArgsMessage(type)) + // A bad --port is a typo in the command, not an answer: fail before asking anything. + if (given.port !== undefined) parsePort(given.port) + const kind = type + ? SERVICE_KINDS.find((k) => k.type === type && !k.needsImage) + : await deps.selectKind(SERVICE_KINDS) + if (!kind) return { type: type!, name: name ?? '' } + if (!kind.needsImage) { + return { type: kind.type, name: name ?? (await deps.askName(kind, kind.defaultName ?? '')) } + } + // The prompt validates a typed ref; a --image that normalizes away would slip past it and + // provision a plain empty compute instead (servicesAddRequestBody drops a falsy image). + const image = normalizeImageRef(given.image ?? (await deps.askImage())) + if (!image) throw new Error('an image reference is required') + return { + type: kind.type, + name: name ?? (await deps.askName(kind, suggestServiceName(image))), + image, + port: given.port ?? (await deps.askPort(DEFAULT_IMAGE_PORT)), + } +} + +/** Real prompts (clack, as the InsForge CLI's `create`); cancelling exits without provisioning. */ +export async function promptServiceKind(kinds: readonly ServiceKind[]): Promise { + const picked = await clack.select({ + message: 'What do you want to add?', + options: kinds.map((k) => ({ value: k.id, label: k.label, hint: k.hint })), + }) + if (clack.isCancel(picked)) process.exit(0) + // Resolve against the list that was displayed — a subset must not fall through to the registry. + return kinds.find((k) => k.id === picked)! +} + +export async function promptImageRef(): Promise { + const answer = await clack.text({ + message: 'Image reference:', + placeholder: 'nginx:latest', + validate: (v) => (normalizeImageRef(v) ? undefined : 'an image reference is required'), + }) + if (clack.isCancel(answer)) process.exit(0) + return answer +} + +export async function promptServiceName(kind: ServiceKind, suggested: string): Promise { + const answer = await clack.text({ + message: `Name this ${kind.type} service:`, + initialValue: suggested, + // The same rule the command enforces, reported before Enter rather than after a round trip. + validate: (v) => { + try { + assertServiceName(v.trim()) + return undefined + } catch (e) { + return (e as Error).message + } + }, + }) + if (clack.isCancel(answer)) process.exit(0) + return answer.trim() +} + +export async function promptPort(fallback: string): Promise { + const answer = await clack.text({ + message: 'Port the image listens on:', + initialValue: fallback, + // The rule the command enforces, so the prompt and a --port can never disagree. + validate: (v) => { + try { + parsePort(v.trim()) + return undefined + } catch (e) { + return (e as Error).message + } + }, + }) + if (clack.isCancel(answer)) process.exit(0) + return answer.trim() +} + +/** Prompts on a real terminal only — an agent's stdin is not one, and must never block. */ +export function serviceArgsDeps(json?: boolean): ServiceArgsDeps { + return { + selectKind: promptServiceKind, + askImage: promptImageRef, + askName: promptServiceName, + askPort: promptPort, + // --json asked for parseable output, so a caller that happens to own a TTY still gets the error. + tty: !json && !!process.stdin.isTTY && !!process.stdout.isTTY, + } +} diff --git a/test/resolve-service.test.ts b/test/resolve-service.test.ts new file mode 100644 index 0000000..ff16fa5 --- /dev/null +++ b/test/resolve-service.test.ts @@ -0,0 +1,160 @@ +// `insta services add` used to answer a missing type with commander's "missing required argument", +// which never says what the types are. Resolution: both args given → untouched (no prompt anywhere +// near the fast path); TTY → the dashboard's Add Service kinds, then a name (and for Docker Image, +// the ref first and the port after); no TTY → the kind list as an error, because nothing was +// created; a bad type → straight through, so assertType keeps owning that wording. +import { test, expect } from 'vitest' +import { + DEFAULT_IMAGE_PORT, + SERVICE_KINDS, + missingArgsMessage, + normalizeImageRef, + resolveServiceArgs, + serviceArgsDeps, + serviceKindLines, + suggestServiceName, + type ServiceArgsDeps, + type ServiceKind, +} from '../src/resolve-service.js' +import { SERVICE_TYPES, assertServiceName } from '../src/commands/services.js' + +const kind = (id: string): ServiceKind => SERVICE_KINDS.find((k) => k.id === id)! + +const deps = (over: Partial = {}): ServiceArgsDeps => ({ + selectKind: async () => { throw new Error('selectKind must not be called') }, + askImage: async () => { throw new Error('askImage must not be called') }, + askName: async () => { throw new Error('askName must not be called') }, + askPort: async () => { throw new Error('askPort must not be called') }, + tty: true, + ...over, +}) + +test('every service type is reachable from some kind', () => { + for (const t of SERVICE_TYPES) expect(SERVICE_KINDS.some((k) => k.type === t)).toBe(true) +}) + +// 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(kind('image').needsImage).toBe(true) + expect(kind('image').type).toBe('compute') + expect(kind('compute').needsImage).toBeUndefined() +}) + +// 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('storage').defaultName).toBe('assets') + expect(kind('compute').defaultName).toBe('compute') + expect(kind('image').defaultName).toBeUndefined() +}) + +test('both arguments given: returned as-is, nothing is asked', async () => { + const r = await resolveServiceArgs('postgres', 'main-db', deps()) + expect(r).toEqual({ type: 'postgres', name: 'main-db' }) +}) + +test('no arguments + TTY: asks what, then the name for that kind', async () => { + const asked: string[] = [] + const r = await resolveServiceArgs(undefined, undefined, deps({ + selectKind: async (kinds) => { asked.push('kind'); return kinds.find((k) => k.id === 'storage')! }, + askName: async (k, suggested) => { asked.push(`name:${k.id}`); return suggested }, + })) + expect(r).toEqual({ type: 'storage', name: 'assets' }) + expect(asked).toEqual(['kind', 'name:storage']) +}) + +test('Docker Image: asks for the ref, suggests a name from it, then the port', async () => { + const asked: string[] = [] + const r = await resolveServiceArgs(undefined, undefined, deps({ + selectKind: async () => { asked.push('kind'); return kind('image') }, + askImage: async () => { asked.push('image'); return 'ghcr.io/insforge/postgres:v15.13.4' }, + askName: async (_k, suggested) => { asked.push('name'); return suggested }, + askPort: async (fallback) => { asked.push('port'); return fallback }, + })) + expect(r).toEqual({ type: 'compute', name: 'postgres', image: 'ghcr.io/insforge/postgres:v15.13.4', port: DEFAULT_IMAGE_PORT }) + expect(asked).toEqual(['kind', 'image', 'name', 'port']) +}) + +// A flag already on the command line is an answer — asking for it again would be a regression. +test('Docker Image: --image and --port already given are not asked for', async () => { + const r = await resolveServiceArgs(undefined, undefined, deps({ + selectKind: async () => kind('image'), + askName: async (_k, suggested) => suggested, + }), { image: 'https://nginx:1.27', port: '3000' }) + expect(r).toEqual({ type: 'compute', name: 'nginx', image: 'nginx:1.27', port: '3000' }) +}) + +test('a bare compute type means Empty Service, never the image flow', async () => { + const r = await resolveServiceArgs('compute', undefined, deps({ askName: async (_k, s) => s })) + expect(r).toEqual({ type: 'compute', name: 'compute' }) +}) + +test('no TTY: throws, and the message lists every kind with its command', async () => { + await expect(resolveServiceArgs(undefined, undefined, deps({ tty: false }))).rejects.toThrow(/what to add/) + 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('--image ') +}) + +test('no TTY with a type: asks for the missing half, not the whole list', () => { + expect(missingArgsMessage('storage')).toBe('name the service: insta services add storage assets') +}) + +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: '' }) +}) + +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') +}) + +// Same rules as the dashboard's helpers, so a ref names the service identically in both. +test('image refs normalize and suggest the dashboard name', () => { + expect(normalizeImageRef(' https://ghcr.io/insforge/app:v2 ')).toBe('ghcr.io/insforge/app:v2') + expect(suggestServiceName('nginx:latest')).toBe('nginx') + expect(suggestServiceName('ghcr.io/insforge/postgres-all:latest')).toBe('postgres-all') + expect(suggestServiceName('registry.io/team/My_App@sha256:abc')).toBe('my-app') +}) + +// A suggestion the name rule would reject is worse than none — it can't be accepted unchanged. +test('a long repo segment is capped at what assertServiceName accepts', () => { + const suggested = suggestServiceName(`ghcr.io/org/${'a'.repeat(50)}:latest`) + expect(suggested).toHaveLength(39) + expect(() => assertServiceName(suggested)).not.toThrow() + // Truncation must not leave a trailing hyphen, which the rule also rejects. + expect(suggestServiceName(`ghcr.io/org/${'ab-'.repeat(20)}:latest`)).not.toMatch(/-$/) +}) + +// --image that normalizes away would otherwise be dropped from the body and quietly build an +// empty compute service instead of the image the user asked for. +test('an --image that normalizes to nothing is rejected, not silently dropped', async () => { + await expect(resolveServiceArgs(undefined, undefined, deps({ + selectKind: async () => kind('image'), + }), { image: 'https://' })).rejects.toThrow(/image reference is required/) +}) + +// A bad --port is a typo in the command; answering three questions first would be wasted work. +test('an invalid --port fails before any prompt', async () => { + await expect(resolveServiceArgs(undefined, undefined, deps(), { port: '70000' })) + .rejects.toThrow(/between 1 and 65535/) + await expect(resolveServiceArgs(undefined, undefined, deps(), { port: 'abc' })) + .rejects.toThrow(/between 1 and 65535/) +}) + +// --json promises parseable stdout; a prompt would corrupt it and hang an agent that owns a TTY. +test('--json opts out of the prompts even on a terminal', () => { + const io = [process.stdin, process.stdout] as Array<{ isTTY?: boolean }> + const saved = io.map((s) => s.isTTY) + for (const s of io) s.isTTY = true + try { + expect(serviceArgsDeps().tty).toBe(true) + expect(serviceArgsDeps(true).tty).toBe(false) + } finally { + io.forEach((s, i) => { s.isTTY = saved[i] }) + } +}) diff --git a/test/services.test.ts b/test/services.test.ts index 74da71f..222933d 100644 --- a/test/services.test.ts +++ b/test/services.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { - assertType, assertServiceName, parseCount, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES, + assertType, assertServiceName, parseCount, parsePort, parseAccess, resolveServiceId, resolveComputeServiceId, SERVICE_TYPES, servicesAddRequestBody, servicesAdd, serviceListLine, } from '../src/commands/services.js' @@ -31,6 +31,32 @@ describe('parseCount', () => { }) }) +describe('parsePort', () => { + it('parses ports in range', () => { + expect(parsePort('8080')).toBe(8080) + expect(parsePort('1')).toBe(1) + expect(parsePort('65535')).toBe(65535) + }) + // Junk used to reach the API as NaN, which serializes to null. + it('rejects out-of-range and non-integer ports', () => { + expect(() => parsePort('0')).toThrow(/between 1 and 65535/) + expect(() => parsePort('65536')).toThrow(/between 1 and 65535/) + expect(() => parsePort('8080.5')).toThrow(/between 1 and 65535/) + expect(() => parsePort('abc')).toThrow(/between 1 and 65535/) + }) + // Number() would read these as 8080 and 1000 — a port in hex is a typo, not a port. + it('rejects non-decimal spellings Number() would have accepted', () => { + expect(() => parsePort('0x1f90')).toThrow(/between 1 and 65535/) + expect(() => parsePort('1e3')).toThrow(/between 1 and 65535/) + expect(() => parsePort('0o17620')).toThrow(/between 1 and 65535/) + expect(() => parsePort('')).toThrow(/between 1 and 65535/) + }) + // Surrounding whitespace is shell noise, not a typo — parseVolumeGib tolerates it too. + it('tolerates surrounding whitespace', () => { + expect(parsePort(' 8080 ')).toBe(8080) + }) +}) + describe('assertServiceName', () => { it('accepts lower-kebab service names', () => { expect(() => assertServiceName('primary-db')).not.toThrow()