-
Notifications
You must be signed in to change notification settings - Fork 0
feat(services): answer a bare services add with the kinds, and give it --json
#91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
38435b1
feat(services): answer `services add` with no type by asking what to add
CarmenDou 6535cca
feat(services): `services add --json` — the created service, machine-…
CarmenDou ed05552
feat(services): lift Docker Image to its own kind, matching Add Service
CarmenDou fd3bdcc
fix(services): reject empty image refs and out-of-range ports (cubic)
CarmenDou 6119996
fix(services): decimal-only ports, and resolve the kind from the list…
CarmenDou File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, '') | ||
|
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())) | ||
|
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)), | ||
|
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, | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.