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
29 changes: 28 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@clack/prompts": "^0.9.1",
"commander": "^12.1.0"
},
"devDependencies": {
Expand Down
20 changes: 17 additions & 3 deletions src/commands/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}

// 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
Expand Down Expand Up @@ -62,15 +72,15 @@ 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
// type) stays in servicesAdd, ahead of any network/config access.
export function servicesAddRequestBody(type: string, name: string, branch: string | undefined, opts: ServicesAddOpts): Record<string, unknown> {
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) } : {}),
Expand All @@ -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`)')
Expand All @@ -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}` : ''}` : ''
Expand Down
12 changes: 10 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -116,15 +117,22 @@ br.command('merge <source>').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 <type> <name>').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 <branch>', 'target branch (default: current)')
.option('--region <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)')
.option('--image <url>', 'compute only: run this container image at creation')
.option('--port <n>', '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 <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`; 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>', 'branch (default: current)')
.action(guard((o) => services.servicesList(o)))
svc.command('remove <type> <name>').description('Remove a service and destroy its resources')
Expand Down
184 changes: 184 additions & 0 deletions src/resolve-service.ts
Original file line number Diff line number Diff line change
@@ -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<ServiceKind>
askImage: () => Promise<string>
askName: (kind: ServiceKind, suggested: string) => Promise<string>
askPort: (fallback: string) => Promise<string>
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, '')
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
.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 <name> --image <ref> --port <n>`
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<ResolvedServiceArgs> {
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()))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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)),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
}

/** Real prompts (clack, as the InsForge CLI's `create`); cancelling exits without provisioning. */
export async function promptServiceKind(kinds: readonly ServiceKind[]): Promise<ServiceKind> {
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<string> {
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<string> {
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<string> {
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,
}
}
Loading
Loading