| // `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 } from './commands/services.js'; | ||
| // 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 = [ | ||
| { 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'; | ||
| /** Registry refs aren't URLs — quietly strip a pasted scheme prefix (mirrors the dashboard). */ | ||
| export function normalizeImageRef(raw) { | ||
| 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) { | ||
| 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) { | ||
| 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() { | ||
| 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) { | ||
| // 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, name, deps, given = {}) { | ||
| if (type && name) | ||
| return { type, name }; | ||
| if (type && !SERVICE_TYPES.includes(type)) | ||
| 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) { | ||
| 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() { | ||
| 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, suggested) { | ||
| 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.message; | ||
| } | ||
| }, | ||
| }); | ||
| if (clack.isCancel(answer)) | ||
| process.exit(0); | ||
| return answer.trim(); | ||
| } | ||
| export async function promptPort(fallback) { | ||
| 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.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) { | ||
| 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, | ||
| }; | ||
| } | ||
| //# sourceMappingURL=resolve-service.js.map |
+12
-8
@@ -7,4 +7,5 @@ import { ApiClient, ApiError, requireProject } from '../api.js'; | ||
| // actual usage). Thin wrapper over PATCH /database/settings {scaleToZero} — insta-db-backed | ||
| // postgres only; Neon-backed services manage their own autosuspend and the platform returns an | ||
| // error for them. | ||
| // postgres only. Legacy Neon path: Neon is no longer used by any environment (postgres is 100% | ||
| // insta-db) and this code is retained, not live — Neon-backed services managed their own | ||
| // autosuspend and the platform returned an error for them. | ||
| export async function dbAlwaysOn(mode, opts) { | ||
@@ -57,3 +58,4 @@ if (mode !== 'on' && mode !== 'off') | ||
| // The platform answers a provider-shaped 502 for services with no manageable instance | ||
| // (Neon-backed): a soft case, not a failure. Everything else stays an error — an expired | ||
| // (the legacy Neon path — Neon is no longer used by any environment; this branch is retained, | ||
| // not live): a soft case, not a failure. Everything else stays an error — an expired | ||
| // token must not render as "no ceiling set" — but wrapped so the user sees what failed. | ||
@@ -83,3 +85,3 @@ if (e instanceof ApiError && e.status === 502) | ||
| if (read.kind === 'no-instance') { | ||
| info(`postgres ${opts.group ?? 'default'}: no manageable instance (Neon-backed services manage their own resources)`); | ||
| info(`postgres ${opts.group ?? 'default'}: no manageable instance (this service manages its own resources)`); | ||
| return; | ||
@@ -167,5 +169,7 @@ } | ||
| // hit rate, database size. Read-only. insta-db-backed: a suspended instance answers from the | ||
| // provider's control plane (shown as "(suspended)" with structural zeros), never dialed. | ||
| // Neon-backed: the platform reads over a direct SQL connection, so a one-shot call may wake a | ||
| // suspended endpoint — acceptable for an explicit command, which is why nothing here polls. | ||
| // provider's control plane (shown as "(suspended)" with structural zeros), never dialed. That is | ||
| // every environment today — the Neon-backed contrast below is historical: Neon is no longer used | ||
| // anywhere, and the code that handled it is retained, not live. Neon-backed: the platform read | ||
| // over a direct SQL connection, so a one-shot call could wake a suspended endpoint — acceptable | ||
| // for an explicit command, which is why nothing here polls. | ||
| export async function dbStats(opts) { | ||
@@ -214,3 +218,3 @@ const api = await ApiClient.load(); | ||
| if (read.kind === 'no-instance') { | ||
| info(`postgres ${opts.group ?? 'default'}: no manageable instance (Neon-backed services manage their own storage)`); | ||
| info(`postgres ${opts.group ?? 'default'}: no manageable instance (this service manages its own storage)`); | ||
| return; | ||
@@ -217,0 +221,0 @@ } |
@@ -100,3 +100,5 @@ import { ApiClient, requireProject } from '../api.js'; | ||
| // current billing cycle. Shows the whole ORG by default (with a per-project breakdown); pass --proj | ||
| // [id] for a single project (the linked one, or a given id). Billed dimensions, not raw fly/neon meters. | ||
| // [id] for a single project (the linked one, or a given id). Billed dimensions, not raw provider | ||
| // meters. (Historical: those were fly/neon meters — Neon is no longer used by any environment, | ||
| // though the adapter code is retained, not live.) | ||
| export async function usage(opts) { | ||
@@ -103,0 +105,0 @@ const api = await ApiClient.load(); |
@@ -26,2 +26,12 @@ // `insta services` — manage a project's opt-in services (postgres | storage | compute). | ||
| } | ||
| // 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) { | ||
| 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 | ||
@@ -68,3 +78,3 @@ // quantity strings this is not a provider pass-through; the wire value is an integer). Volumes | ||
| 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 } : {}), | ||
@@ -83,4 +93,7 @@ ...(opts.alwaysOn ? { alwaysOn: true } : {}), | ||
| 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') | ||
@@ -99,2 +112,4 @@ throw new Error('--always-on is only valid for compute services (for postgres, use `insta db always-on on` after creation)'); | ||
| return; | ||
| if (opts.json) | ||
| return printJson(res.body.service); | ||
| const svc = res.body.service; | ||
@@ -101,0 +116,0 @@ const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : ''; |
+11
-3
@@ -16,2 +16,3 @@ #!/usr/bin/env node | ||
| import * as services from './commands/services.js'; | ||
| import { resolveServiceArgs, serviceArgsDeps } from './resolve-service.js'; | ||
| import * as regions from './commands/regions.js'; | ||
@@ -112,3 +113,6 @@ import * as secretsCmd from './commands/secrets.js'; | ||
| 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)') | ||
@@ -121,3 +125,7 @@ .option('--region <region>', 'region for postgres/compute, e.g. us-east (see `insta regions`)') | ||
| .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)') | ||
@@ -135,3 +143,3 @@ .action(guard((o) => services.servicesList(o))); | ||
| .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((type, name, number, region, o) => services.servicesScale(type, name, number, region, o))); | ||
| svc.command('upgrade <type> <name> <spec>').description('Change a compute/postgres service spec (paid plans only)') | ||
| svc.command('upgrade <type> <name> <spec>').description('Change a compute service spec (paid plans only). Postgres upgrades are rejected by the platform — use `insta db limits` instead') | ||
| .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((type, name, spec, o) => services.servicesUpgrade(type, name, spec, o))); | ||
@@ -138,0 +146,0 @@ svc.command('secrets <type> <name>').description("List a service's secret names") |
+2
-1
| { | ||
| "name": "insta", | ||
| "version": "0.0.31", | ||
| "version": "0.0.32", | ||
| "type": "module", | ||
@@ -45,2 +45,3 @@ "description": "InstaCloud CLI — a thin client of the platform control-plane API.", | ||
| "dependencies": { | ||
| "@clack/prompts": "^0.9.1", | ||
| "commander": "^12.1.0" | ||
@@ -47,0 +48,0 @@ }, |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
217521
4.48%36
2.86%3731
5.34%2
100%+ Added
+ Added
+ Added
+ Added
+ Added