From b2feccb4fac3b935dd03c01562b0cc8895c608ee Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 29 Sep 2026 23:21:23 -0400 Subject: [PATCH 1/2] feat(project): add runtime-endpoint TUI wizard `agentcore add runtime-endpoint` without flags in a TTY now opens a wizard: pick the Runtime (each listed with the endpoints it already has), name the endpoint (checked live against the name rule and the Runtime's existing endpoints), give the version and an optional description on one step (version prefilled with the flag's default), review, submit. The flag handler and the wizard build the endpoint through one shared toAddRuntimeEndpointInput, so both default the version and drop a blank description the same way. MultiTextField inputs gain the whole-number check TextField already had. --- src/components/Root.tsx | 5 + src/components/wizard/fields.tsx | 3 + src/handlers/project/add/add.screen.test.tsx | 1 + src/handlers/project/add/index.ts | 1 + .../project/add/runtime-endpoint/index.ts | 47 ++- .../runtime-endpoint.screen.test.tsx | 382 ++++++++++++++++++ .../project/add/runtime-endpoint/screen.tsx | 205 ++++++++++ 7 files changed, 633 insertions(+), 11 deletions(-) create mode 100644 src/handlers/project/add/runtime-endpoint/runtime-endpoint.screen.test.tsx create mode 100644 src/handlers/project/add/runtime-endpoint/screen.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 2102251ef3..5b345e8da7 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -130,6 +130,7 @@ import { DeployProjectScreen } from "../handlers/project/deploy/screen.tsx"; import { ProjectCreateScreen } from "../handlers/project/create/screen.tsx"; import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx"; import { AddRuntimeScreen } from "../handlers/project/add/runtime/screen.tsx"; +import { AddRuntimeEndpointScreen } from "../handlers/project/add/runtime-endpoint/screen.tsx"; import { AddMemoryScreen } from "../handlers/project/add/memory/screen.tsx"; import { AddGatewayScreen } from "../handlers/project/add/gateway/screen.tsx"; import { AddGatewayTargetScreen } from "../handlers/project/add/gateway-target/screen.tsx"; @@ -914,6 +915,10 @@ function RouteTable({ ctx, core }: ScreenProps) { } /> } /> } /> + } + /> } /> } /> void; required?: boolean; schema?: z.ZodType; + // number rejects anything but digits before the schema sees the value. + number?: boolean; }; export type MultiTextFieldProps = { @@ -465,6 +467,7 @@ export function MultiTextField({ inputs }: MultiTextFieldProps) { label: input.label, required: input.required ?? false, schema: input.schema, + number: input.number ?? false, }); useInput((_input, key) => { diff --git a/src/handlers/project/add/add.screen.test.tsx b/src/handlers/project/add/add.screen.test.tsx index 7d7c136327..b5a7260056 100644 --- a/src/handlers/project/add/add.screen.test.tsx +++ b/src/handlers/project/add/add.screen.test.tsx @@ -23,6 +23,7 @@ function addSubcommands(): string[] { // "cli" divider and opens its help instead. const WITH_SCREENS = [ "runtime", + "runtime-endpoint", "memory", "gateway", "gateway-target", diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 3010516a4c..20f6136c29 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -29,6 +29,7 @@ export function createAddProjectResourceHandler( // CliOnlyScreen). const projectAdd = new Router("add", "add project resources").supportedTuiCommands( "runtime", + "runtime-endpoint", "memory", "gateway", "gateway-target", diff --git a/src/handlers/project/add/runtime-endpoint/index.ts b/src/handlers/project/add/runtime-endpoint/index.ts index a5fb5d6b93..7515113455 100644 --- a/src/handlers/project/add/runtime-endpoint/index.ts +++ b/src/handlers/project/add/runtime-endpoint/index.ts @@ -1,8 +1,36 @@ import z from "zod"; import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddResourceInput } from "../../types"; import type { AddProjectResourceConfig } from "../types"; import { addProjectResource } from "../shared"; +// The version an endpoint points to when none is given — what --version +// documents as its default, and what the wizard prefills. +export const DEFAULT_ENDPOINT_VERSION = 1; + +// RuntimeEndpointInput is the endpoint as the flags state it. +export type RuntimeEndpointInput = { + runtime: string; + name: string; + version?: number; + description?: string; +}; + +// toAddRuntimeEndpointInput is the one place a runtime endpoint is built from +// user input — the flags, or the wizard's answers — so both default the version +// and drop an empty description the same way. +export function toAddRuntimeEndpointInput(input: RuntimeEndpointInput): AddResourceInput { + return { + resourceType: "runtime-endpoint", + runtimeName: input.runtime, + resourceConfig: { + name: input.name, + version: input.version ?? DEFAULT_ENDPOINT_VERSION, + ...(input.description ? { description: input.description } : {}), + }, + }; +} + export const createAddRuntimeEndpointHandler = (config: AddProjectResourceConfig) => createHandler({ name: "runtime-endpoint", @@ -12,28 +40,25 @@ export const createAddRuntimeEndpointHandler = (config: AddProjectResourceConfig flag("name", "the endpoint name (e.g., prod, staging)", z.string().min(1)), flag( "version", - "the runtime version this endpoint points to (default: 1)", + `the runtime version this endpoint points to (default: ${DEFAULT_ENDPOINT_VERSION})`, z.number().int().min(1).optional(), ), flag("description", "description of the endpoint", z.string().optional()), ], handle: async (ctx, flags) => { const project = ctx.require(ProjectKey); - const version = flags.version ?? 1; + const version = flags.version ?? DEFAULT_ENDPOINT_VERSION; await addProjectResource( ctx, config, project, - { - resourceType: "runtime-endpoint", - runtimeName: flags.runtime, - resourceConfig: { - name: flags.name, - version, - ...(flags.description ? { description: flags.description } : {}), - }, - }, + toAddRuntimeEndpointInput({ + runtime: flags.runtime, + name: flags.name, + version, + description: flags.description, + }), `added runtime endpoint '${flags.name}' (version ${version}) to runtime '${flags.runtime}' in '${project.name}'`, ); }, diff --git a/src/handlers/project/add/runtime-endpoint/runtime-endpoint.screen.test.tsx b/src/handlers/project/add/runtime-endpoint/runtime-endpoint.screen.test.tsx new file mode 100644 index 0000000000..7cb573193f --- /dev/null +++ b/src/handlers/project/add/runtime-endpoint/runtime-endpoint.screen.test.tsx @@ -0,0 +1,382 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + cleanupScreens, + createSilentLogger, + flatFrame, + initProject, + renderScreen, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, + ttyTestIO, + waitFor, + waitForFlatText, + waitForText, + type RenderScreenResult, +} from "../../../../testing"; +import { createRootHandler } from "../../../index"; +import { InputValidationError } from "../../../../errors"; +import type { AppIO } from "../../../../io"; +import { projectSpec, writeProjectSpec } from "../gateway-test-support"; +import { endpointNameSchema } from "./screen"; + +type Endpoints = Record; + +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); +afterEach(cleanupScreens); + +function buildRoot(io: AppIO) { + return createRootHandler(new TestCoreClient(), { + io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); +} + +async function run(args: string[]) { + const io = testIO(); + await buildRoot(io.io).route(["node", "agentcore", ...args]); + return io; +} + +// A minimal runtime is enough for an endpoint's parent; scaffolding a real one +// would install dependencies the endpoint path never touches. +function runtimeSpec(name: string, endpoints?: Endpoints) { + return { + name, + build: "CodeZip", + entrypoint: "main.py", + codeLocation: `app/${name}`, + runtimeVersion: "PYTHON_3_13", + ...(endpoints ? { endpoints } : {}), + }; +} + +// withRuntimes starts a project with exactly the given runtimes — none, when +// called bare — so the first step's list is known. +async function withRuntimes(...runtimes: ReturnType[]): Promise { + const { projectRoot, cleanup } = await initProject({ + prefix: "agentcore-add-runtime-endpoint-wizard-", + }); + cleanups.push(cleanup); + const spec = await projectSpec(projectRoot); + await writeProjectSpec(projectRoot, { ...spec, runtimes }); + return projectRoot; +} + +async function endpointsOf(projectRoot: string, runtime: string): Promise { + const spec = await projectSpec(projectRoot); + const runtimes = spec.runtimes as Array<{ name: string; endpoints?: Endpoints }>; + return runtimes.find((candidate) => candidate.name === runtime)?.endpoints; +} + +// reachDetailsStep confirms the first runtime and names the endpoint, leaving +// the wizard on the version step with its prefilled 1. +async function reachDetailsStep(screen: RenderScreenResult, name: string): Promise { + await waitForText(screen.lastFrame, "which Runtime should this endpoint belong to?"); + await screen.press("return"); + await waitForText(screen.lastFrame, "what should this endpoint be called?"); + await screen.write(name); + await screen.press("return"); + await waitForText(screen.lastFrame, "which Runtime version does it point to?"); +} + +describe("runtime-endpoint wizard helpers", () => { + test("endpointNameSchema applies the name rule and refuses a name the Runtime already has", () => { + const schema = endpointNameSchema(runtimeSpec("agent", { prod: { version: 1 } }) as never); + expect(schema.safeParse("staging").success).toBe(true); + expect(schema.safeParse("9lives").success).toBe(false); + const taken = schema.safeParse("prod"); + expect(taken.success).toBe(false); + expect(taken.error?.issues[0]?.message).toBe( + "an endpoint named 'prod' already exists on runtime 'agent'", + ); + // Without a runtime to check against, only the name rule applies. + expect(endpointNameSchema(undefined).safeParse("prod").success).toBe(true); + }); +}); + +describe("project add runtime-endpoint wizard", () => { + test("adds the same endpoint as the flags, on the Runtime picked from the list", async () => { + const projectRoot = await withRuntimes( + runtimeSpec("agent", { prod: { version: 1 } }), + runtimeSpec("worker"), + ); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + // Each Runtime shows the endpoints it already has. + await waitForText(screen.lastFrame, "which Runtime should this endpoint belong to?"); + expect(screen.lastFrame()).toContain("❯ ● agent"); + expect(screen.lastFrame()).toContain("endpoint: prod"); + expect(screen.lastFrame()).toContain("no endpoints yet"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● worker"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "what should this endpoint be called?"); + await screen.write("staging"); + await screen.press("return"); + + // Version and description share a step; the version is prefilled with 1. + await waitForText(screen.lastFrame, "which Runtime version does it point to?"); + expect(screen.lastFrame()).toContain("Version"); + expect(screen.lastFrame()).toContain("Description"); + await screen.press("backspace"); + await screen.write("3"); + await screen.press("return"); + await screen.write("Production traffic"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "this endpoint will be added to agentcore.json"); + const review = flatFrame(screen.lastFrame); + expect(review).toContain("runtime worker"); + expect(review).toContain("endpoint staging"); + expect(review).toContain("version 3"); + expect(review).toContain("description Production traffic"); + await screen.press("return"); + + await waitForFlatText( + screen.lastFrame, + "added runtime endpoint 'staging' (version 3) to runtime 'worker' in 'TestProject'", + ); + expect(screen.lastFrame()).toContain("agentcore deploy"); + expect(await endpointsOf(projectRoot, "worker")).toEqual({ + staging: { version: 3, description: "Production traffic" }, + }); + expect(await endpointsOf(projectRoot, "agent")).toEqual({ prod: { version: 1 } }); + + // The flags, given the same answers, write the same endpoint. + await run([ + "add", + "runtime-endpoint", + "--runtime", + "agent", + "--name", + "staging", + "--version", + "3", + "--description", + "Production traffic", + ]); + expect((await endpointsOf(projectRoot, "agent"))?.staging).toEqual( + (await endpointsOf(projectRoot, "worker"))?.staging, + ); + screen.unmount(); + }); + + test("version defaults to 1 and the description is optional", async () => { + const projectRoot = await withRuntimes(runtimeSpec("agent")); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await reachDetailsStep(screen, "staging"); + await screen.press("return"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "this endpoint will be added to agentcore.json"); + const review = flatFrame(screen.lastFrame); + expect(review).toContain("version 1"); + expect(review).toContain("description (none)"); + await screen.press("return"); + + await waitForFlatText( + screen.lastFrame, + "added runtime endpoint 'staging' (version 1) to runtime 'agent' in 'TestProject'", + ); + expect(await endpointsOf(projectRoot, "agent")).toEqual({ staging: { version: 1 } }); + screen.unmount(); + }); + + test("a name the chosen Runtime already has is refused as it is typed", async () => { + await withRuntimes(runtimeSpec("agent", { prod: { version: 2 } })); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await waitForText(screen.lastFrame, "which Runtime should this endpoint belong to?"); + await screen.press("return"); + await waitForText(screen.lastFrame, "what should this endpoint be called?"); + await screen.write("prod"); + await waitForText( + screen.lastFrame, + "an endpoint named 'prod' already exists on runtime 'agent'", + ); + await screen.press("return"); + expect(screen.lastFrame()).toContain("what should this endpoint be called?"); + + // Any other well-formed name goes through. + await screen.press("backspace"); + await screen.write("uction"); + await screen.press("return"); + await waitForText(screen.lastFrame, "which Runtime version does it point to?"); + screen.unmount(); + }); + + test("the version must be a whole number of at least 1", async () => { + await withRuntimes(runtimeSpec("agent")); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await reachDetailsStep(screen, "staging"); + await screen.press("backspace"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Version is required"); + + await screen.write("0"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Version must be 1 or higher"); + + await screen.press("backspace"); + await screen.write("v2"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Version must be a whole number"); + + await screen.press("backspace"); + await screen.press("backspace"); + await screen.write("2"); + await screen.press("return"); + await screen.press("return"); + await waitForText(screen.lastFrame, "this endpoint will be added to agentcore.json"); + expect(flatFrame(screen.lastFrame)).toContain("version 2"); + screen.unmount(); + }); + + test("without a Runtime the first step says what to add and esc returns to the menu", async () => { + await withRuntimes(); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await waitForText(screen.lastFrame, "no Runtimes in this project"); + expect(screen.lastFrame()).toContain("agentcore add runtime"); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "add project resources"); + screen.unmount(); + }); + + test("a rejected add reports itself and hands the form back", async () => { + const projectRoot = await withRuntimes(runtimeSpec("agent")); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await reachDetailsStep(screen, "prod"); + await screen.press("return"); + await screen.press("return"); + await waitForText(screen.lastFrame, "this endpoint will be added to agentcore.json"); + + // The name was free when typed; it is taken by the time the review submits. + await run([ + "add", + "runtime-endpoint", + "--runtime", + "agent", + "--name", + "prod", + "--version", + "4", + ]); + await screen.press("return"); + + await waitForFlatText( + screen.lastFrame, + "a runtime-endpoint named 'prod' already exists on runtime 'agent'", + ); + await screen.press("escape"); + await waitForText(screen.lastFrame, "this endpoint will be added to agentcore.json"); + expect(flatFrame(screen.lastFrame)).toContain("endpoint prod"); + + expect(await endpointsOf(projectRoot, "agent")).toEqual({ prod: { version: 4 } }); + screen.unmount(); + }); + + test("esc on the first step returns to the add menu", async () => { + await withRuntimes(runtimeSpec("agent")); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await waitForText(screen.lastFrame, "which Runtime should this endpoint belong to?"); + await screen.press("escape"); + + await waitForText(screen.lastFrame, "add project resources"); + screen.unmount(); + }); +}); + +describe("project add runtime-endpoint dispatch", () => { + const MISSING_RUNTIME = "required option '--runtime' not specified"; + + async function routeError(io: AppIO, args: string[]): Promise { + return buildRoot(io) + .route(["node", "agentcore", "add", "runtime-endpoint", ...args]) + .then( + () => undefined, + (caught: unknown) => caught, + ); + } + + test("bare add runtime-endpoint in a TTY session opens the wizard", async () => { + await withRuntimes(runtimeSpec("agent")); + const { streams, stdin } = ttyTestIO(); + + const outcome = buildRoot(streams.io) + .route(["node", "agentcore", "add", "runtime-endpoint"]) + .then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + let settled = false; + void outcome.finally(() => { + settled = true; + }); + + await waitFor( + () => { + if (!settled) stdin.write("\x03"); + return settled; + }, + 5000, + 150, + ); + expect(await outcome).toEqual({ ok: true }); + expect(streams.stderr()).not.toContain("required option"); + }, 10000); + + test("bare add runtime-endpoint without a TTY stays headless and reports the missing --runtime", async () => { + await withRuntimes(runtimeSpec("agent")); + + const error = await routeError(testIO().io, []); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_RUNTIME); + }); + + test("any user-supplied flag stays headless even in a TTY", async () => { + await withRuntimes(runtimeSpec("agent")); + + const error = await routeError(ttyTestIO().streams.io, ["--name", "prod"]); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_RUNTIME); + }); + + test("--json stays headless even in a TTY", async () => { + await withRuntimes(runtimeSpec("agent")); + + const error = await routeError(ttyTestIO().streams.io, ["--json"]); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_RUNTIME); + }); + + test("flag-driven add runtime-endpoint still runs headless in a TTY session", async () => { + const projectRoot = await withRuntimes(runtimeSpec("agent")); + const { streams } = ttyTestIO(); + + await buildRoot(streams.io).route([ + "node", + "agentcore", + "add", + "runtime-endpoint", + "--runtime", + "agent", + "--name", + "prod", + ]); + + expect(await endpointsOf(projectRoot, "agent")).toEqual({ prod: { version: 1 } }); + }); +}); diff --git a/src/handlers/project/add/runtime-endpoint/screen.tsx b/src/handlers/project/add/runtime-endpoint/screen.tsx new file mode 100644 index 0000000000..7fabccb449 --- /dev/null +++ b/src/handlers/project/add/runtime-endpoint/screen.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import z from "zod"; +import { + MultiTextField, + ResourceChoiceField, + Step, + Summary, + TextField, + Wizard, + type Choice, +} from "../../../../components/wizard"; +import { + RuntimeEndpointNameSchema, + RuntimeEndpointSchema, + type ProjectRuntime, +} from "../../../../projectSchemas/runtime"; +import { ProjectKey } from "../../../../router"; +import type { ScreenProps } from "../../../types"; +import type { Project } from "../../types"; +import { ProjectGate, projectQueryKey } from "../../ProjectGate"; +import { + DEFAULT_ENDPOINT_VERSION, + toAddRuntimeEndpointInput, + type RuntimeEndpointInput, +} from "./index"; + +const BREADCRUMB = ["agentcore", "add", "runtime-endpoint"]; +const DESCRIPTION = "add a named endpoint (version alias) to a runtime"; +const ADD_MENU = "/agentcore/add"; + +// The version rule the spec applies, with a message that names the input; the +// whole-number check itself happens before the schema sees the value. +const VERSION_SCHEMA = z.number().int().min(1, "Version must be 1 or higher"); + +type RuntimeEndpointFormValues = { + runtime: string; + name: string; + version: string; + description: string; +}; + +// endpointNameSchema is the endpoint name rule plus the check the manager makes +// on submit — no two endpoints of one Runtime share a name — so a clash is +// caught as it is typed rather than after the review. +export function endpointNameSchema(runtime: ProjectRuntime | undefined): z.ZodType { + const taken = new Set(Object.keys(runtime?.endpoints ?? {})); + return RuntimeEndpointNameSchema.superRefine((name, ctx) => { + if (runtime !== undefined && taken.has(name)) { + ctx.addIssue({ + code: "custom", + message: `an endpoint named '${name}' already exists on runtime '${runtime.name}'`, + }); + } + }); +} + +// toRuntimeEndpointInput is the answers as the flag path would state them: the +// version parsed from its digits, a blank description left out. +export function toRuntimeEndpointInput(values: RuntimeEndpointFormValues): RuntimeEndpointInput { + const description = values.description.trim(); + return { + runtime: values.runtime, + name: values.name, + version: Number(values.version), + description: description === "" ? undefined : description, + }; +} + +function summaryOf(values: RuntimeEndpointFormValues): Record { + return { + runtime: values.runtime, + endpoint: values.name, + version: values.version, + description: values.description.trim() || "(none)", + }; +} + +// runtimeChoices lists each Runtime with the endpoints it already has, so the +// name step's uniqueness rule is no surprise. +function runtimeChoices(runtimes: readonly ProjectRuntime[]): Choice[] { + return runtimes.map((runtime) => { + const names = Object.keys(runtime.endpoints ?? {}); + return { + value: runtime.name, + label: runtime.name, + description: + names.length === 0 + ? "no endpoints yet" + : `${names.length === 1 ? "endpoint" : "endpoints"}: ${names.join(", ")}`, + }; + }); +} + +export function AddRuntimeEndpointScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); + return ( + navigate(ADD_MENU)} + > + {(project) => } + + ); +} + +function AddRuntimeEndpointWizard({ + project, + core, +}: { + project: Project; + core: ScreenProps["core"]; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const runtimes = project.spec.runtimes; + const [values, setValues] = useState({ + runtime: runtimes[0]?.name ?? "", + name: "", + version: String(DEFAULT_ENDPOINT_VERSION), + description: "", + }); + const set = (update: Partial) => + setValues((current) => ({ ...current, ...update })); + const runtime = runtimes.find((candidate) => candidate.name === values.runtime); + + return ( + navigate(ADD_MENU)} + onSubmit={async function* () { + const updated = yield* core.projectManager.addResource( + project, + toAddRuntimeEndpointInput(toRuntimeEndpointInput(values)), + ); + queryClient.setQueryData(projectQueryKey(), updated); + return updated; + }} + runningLabel={`adding runtime endpoint ${values.name}…`} + successLabel={`added runtime endpoint '${values.name}' (version ${values.version}) to runtime '${values.runtime}' in '${project.name}'`} + successNextSteps={["agentcore deploy"]} + onDone={() => navigate(ADD_MENU)} + doneLabel="go back" + > + + set({ runtime })} + emptyMessage="no Runtimes in this project" + emptyHint="add one with agentcore add runtime" + /> + + + + set({ name })} + required + schema={endpointNameSchema(runtime)} + live + /> + + + + set({ version }), + required: true, + number: true, + schema: VERSION_SCHEMA, + }, + { + key: "description", + label: "Description", + help: "optional · up to 200 characters", + placeholder: "production traffic", + value: values.description, + onChange: (description) => set({ description }), + schema: RuntimeEndpointSchema.shape.description, + }, + ]} + /> + + + + + + + ); +} From a2bb3e990568c2aa3f7003fa6256d1d53aeee852 Mon Sep 17 00:00:00 2001 From: notgitika Date: Wed, 30 Sep 2026 03:03:58 -0400 Subject: [PATCH 2/2] refactor(project): take review notes on the runtime-endpoint wizard The flag handler reads the resolved version from the built input rather than applying the default a second time; the version help says "defaults to 1" instead of pointing at the flag; the TextInputSpec.number comment says what the option does (fails validation on non-digits) rather than implying it restricts typing. --- src/components/wizard/fields.tsx | 3 ++- .../project/add/runtime-endpoint/index.ts | 20 ++++++++++--------- .../project/add/runtime-endpoint/screen.tsx | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/components/wizard/fields.tsx b/src/components/wizard/fields.tsx index 48e3d326c0..dfbc04aa92 100644 --- a/src/components/wizard/fields.tsx +++ b/src/components/wizard/fields.tsx @@ -437,7 +437,8 @@ export type TextInputSpec = { onChange: (value: string) => void; required?: boolean; schema?: z.ZodType; - // number rejects anything but digits before the schema sees the value. + // number makes validation fail unless the value is all digits, and hands the + // schema a Number rather than the string; typing itself is not restricted. number?: boolean; }; diff --git a/src/handlers/project/add/runtime-endpoint/index.ts b/src/handlers/project/add/runtime-endpoint/index.ts index 7515113455..110a4fd241 100644 --- a/src/handlers/project/add/runtime-endpoint/index.ts +++ b/src/handlers/project/add/runtime-endpoint/index.ts @@ -16,10 +16,12 @@ export type RuntimeEndpointInput = { description?: string; }; +type AddRuntimeEndpoint = Extract; + // toAddRuntimeEndpointInput is the one place a runtime endpoint is built from // user input — the flags, or the wizard's answers — so both default the version // and drop an empty description the same way. -export function toAddRuntimeEndpointInput(input: RuntimeEndpointInput): AddResourceInput { +export function toAddRuntimeEndpointInput(input: RuntimeEndpointInput): AddRuntimeEndpoint { return { resourceType: "runtime-endpoint", runtimeName: input.runtime, @@ -47,19 +49,19 @@ export const createAddRuntimeEndpointHandler = (config: AddProjectResourceConfig ], handle: async (ctx, flags) => { const project = ctx.require(ProjectKey); - const version = flags.version ?? DEFAULT_ENDPOINT_VERSION; + const input = toAddRuntimeEndpointInput({ + runtime: flags.runtime, + name: flags.name, + version: flags.version, + description: flags.description, + }); await addProjectResource( ctx, config, project, - toAddRuntimeEndpointInput({ - runtime: flags.runtime, - name: flags.name, - version, - description: flags.description, - }), - `added runtime endpoint '${flags.name}' (version ${version}) to runtime '${flags.runtime}' in '${project.name}'`, + input, + `added runtime endpoint '${flags.name}' (version ${input.resourceConfig.version}) to runtime '${flags.runtime}' in '${project.name}'`, ); }, }); diff --git a/src/handlers/project/add/runtime-endpoint/screen.tsx b/src/handlers/project/add/runtime-endpoint/screen.tsx index 7fabccb449..8dd874ddb7 100644 --- a/src/handlers/project/add/runtime-endpoint/screen.tsx +++ b/src/handlers/project/add/runtime-endpoint/screen.tsx @@ -176,7 +176,7 @@ function AddRuntimeEndpointWizard({ { key: "version", label: "Version", - help: `a whole number, 1 or higher · prefilled with the flag's default, ${DEFAULT_ENDPOINT_VERSION}`, + help: `a whole number, 1 or higher · defaults to ${DEFAULT_ENDPOINT_VERSION}`, placeholder: String(DEFAULT_ENDPOINT_VERSION), value: values.version, onChange: (version) => set({ version }),