diff --git a/.oxlintrc.json b/.oxlintrc.json index 0be6af4aa..9e4fb5c8b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -4,6 +4,20 @@ "categories": { "correctness": "error" }, + "rules": { + "no-restricted-globals": [ + "error", + { + "name": "Bun", + "message": "The npm bundle runs under Node. Use node:* APIs in src/ and keep Bun to scripts/, src/testing/, and tests." + } + ] + }, "ignorePatterns": ["dist/", "node_modules/", "src/assets/"], - "overrides": [] + "overrides": [ + { + "files": ["scripts/**", "src/testing/**", "**/*.test.*", "**/*-test-support.ts"], + "rules": { "no-restricted-globals": "off" } + } + ] } diff --git a/README.md b/README.md index 53f426695..4e086c12c 100644 --- a/README.md +++ b/README.md @@ -916,6 +916,19 @@ npm pack # builds via prepublishOnly, creates the .tgz npm i -g ./agentcore-1.0.0.tgz ``` +## Windows notes + +- **`agentcore.ps1 cannot be loaded because running scripts is disabled`**: the + npm shim is a PowerShell script and Windows Server defaults to a `Restricted` + execution policy. Run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` + once, or call `agentcore.cmd`. The compiled `.exe` has no shim. +- **The CLI looks frozen in a PowerShell window**: legacy conhost pauses all + output while text is selected (the title bar shows `Select`). Press `Esc`. + Windows Terminal does not do this. +- **`project create` refuses a long path**: Windows caps paths at 260 characters + unless `LongPathsEnabled` is set, and the CDK app's `node_modules` needs about + 100 of them. Create the project higher in the tree or enable long paths. + # Build Run `make` to verify bun is installed, build the Node bundle, and compile all native binaries: diff --git a/src/components/CliOnlyScreen.tsx b/src/components/CliOnlyScreen.tsx index 80e40fa74..5a8a85f0d 100644 --- a/src/components/CliOnlyScreen.tsx +++ b/src/components/CliOnlyScreen.tsx @@ -77,7 +77,7 @@ export function CliOnlyScreen({ ctx, path }: CliOnlyScreenProps) { keyHints={[ { key: "↑↓", label: "scroll" }, { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > diff --git a/src/components/ConfirmAction.tsx b/src/components/ConfirmAction.tsx index a9407e85c..302451eff 100644 --- a/src/components/ConfirmAction.tsx +++ b/src/components/ConfirmAction.tsx @@ -6,7 +6,7 @@ import { Spinner } from "./ui/spinner"; import { Confirm } from "./ui/confirm"; import { TaskList, type Task } from "./ui/task-list"; import { KeyValueTable } from "./KeyValueTable"; -import { darkTheme } from "./ui/_core.js"; +import { darkTheme, glyphs } from "./ui/_core.js"; import { driveProgress, type ProgressEvent } from "../tui/progress"; const theme = darkTheme; @@ -127,18 +127,18 @@ export function ConfirmAction({ ? [ { key: "y/n", label: "confirm" }, { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] : phase.kind === "success" ? [{ key: "enter", label: doneLabel }] : phase.kind === "error" ? [ { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] : // Nothing listens for esc while the action runs (or is about to): // an operation in flight is not abandoned by leaving the screen. - [{ key: "ctl+c", label: "quit" }]; + [{ key: "ctrl+c", label: "quit" }]; return ( @@ -231,7 +231,7 @@ function SuccessBody({ return ( - ✔ {title} + {glyphs.check} {title} {Object.keys(rows).length > 0 && ( @@ -262,7 +262,9 @@ function ErrorBody({ message, onBack }: { message: string; onBack: () => void }) return ( - ✗ {message} + + {glyphs.cross} {message} + press esc to go back diff --git a/src/components/EndpointWizard.tsx b/src/components/EndpointWizard.tsx index ccc1e2690..45156824b 100644 --- a/src/components/EndpointWizard.tsx +++ b/src/components/EndpointWizard.tsx @@ -11,10 +11,11 @@ import { coreOptsFromCtx } from "../handlers/utils"; import { Layout } from "./Layout"; import { FormRadioGroup, type FormRadioOption } from "./FormRadioGroup"; import { FormTextInput } from "./FormTextInput"; +import { ErrorPanel } from "./ErrorPanel"; import { Stepper, type Step } from "./ui/stepper"; import { Spinner } from "./ui/spinner"; import { CodeBlock } from "./ui/code-block"; -import { darkTheme } from "./ui/_core.js"; +import { darkTheme, glyphs } from "./ui/_core.js"; const theme = darkTheme; @@ -185,16 +186,16 @@ function hintsFor( phase: WizardPhase, verb: string, ): { key: string; label: string }[] { - if (phase.kind === "submitting") return [{ key: "ctl+c", label: "quit" }]; + if (phase.kind === "submitting") return [{ key: "ctrl+c", label: "quit" }]; if (phase.kind === "success") return [{ key: "enter", label: "continue" }]; if (phase.kind === "error") return [ { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]; const base = [ { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]; if (stepKey === "version") { return [{ key: "↑↓", label: "choose" }, { key: "enter", label: "continue" }, ...base]; @@ -321,7 +322,9 @@ function VersionStep({ ) : versions.isError ? ( <> - ✗ {(versions.error as Error).message} + + {glyphs.cross} {(versions.error as Error).message} + ) : options.length === 0 ? ( <> @@ -392,7 +395,7 @@ function SuccessPanel({ return ( - ✔ endpoint {verb}d + {glyphs.check} endpoint {verb}d {" "} @@ -404,16 +407,3 @@ function SuccessPanel({ ); } - -function ErrorPanel({ message, onBack }: { message: string; onBack: () => void }) { - useInput((_input, key) => { - if (key.escape || key.return) onBack(); - }); - - return ( - - ✗ {message} - {" esc returns to the form"} - - ); -} diff --git a/src/components/ErrorPanel.tsx b/src/components/ErrorPanel.tsx index 48add5c14..7ce73159b 100644 --- a/src/components/ErrorPanel.tsx +++ b/src/components/ErrorPanel.tsx @@ -1,16 +1,27 @@ import { Box, Text, useInput } from "ink"; -import { darkTheme } from "./ui/_core.js"; +import { darkTheme, glyphs } from "./ui/_core.js"; const theme = darkTheme; -export function ErrorPanel({ message, onBack }: { message: string; onBack: () => void }) { - useInput((_input, key) => { +export function ErrorPanel({ + message, + onBack, + onRetry, +}: { + message: string; + onBack: () => void; + onRetry?: () => void; +}) { + useInput((input, key) => { if (key.escape || key.return) onBack(); + if (input === "r" && onRetry) onRetry(); }); return ( - ✗ {message} + + {glyphs.cross} {message} + {" esc returns to the form"} ); diff --git a/src/components/FormCheckboxMultiSelect.tsx b/src/components/FormCheckboxMultiSelect.tsx index 5055e2d7d..f99611e24 100644 --- a/src/components/FormCheckboxMultiSelect.tsx +++ b/src/components/FormCheckboxMultiSelect.tsx @@ -1,5 +1,5 @@ import { Box, Text } from "ink"; -import { darkTheme } from "./ui/_core.js"; +import { darkTheme, glyphs } from "./ui/_core.js"; const theme = darkTheme; @@ -46,10 +46,10 @@ export function FormCheckboxMultiSelect({ return ( - {isCursor ? "❯ " : " "} + {isCursor ? `${glyphs.pointer} ` : " "} - {option.checked ? "[✓] " : "[ ] "} + {option.checked ? `[${glyphs.done}] ` : "[ ] "} {option.label.padEnd(columnWidth)} diff --git a/src/components/HarnessWizard.tsx b/src/components/HarnessWizard.tsx index fc63a4dd9..d10c50ddd 100644 --- a/src/components/HarnessWizard.tsx +++ b/src/components/HarnessWizard.tsx @@ -20,7 +20,7 @@ import { Stepper, type Step } from "./ui/stepper"; import { Spinner } from "./ui/spinner"; import { CodeBlock } from "./ui/code-block"; import { ScrollView } from "ink-scroll-view"; -import { darkTheme } from "./ui/_core.js"; +import { darkTheme, glyphs } from "./ui/_core.js"; import { Divider } from "./ui/divider/Divider.js"; import { KeyValueTable } from "./KeyValueTable.js"; @@ -392,16 +392,16 @@ function hintsFor( phase: WizardPhase, verb: string, ): { key: string; label: string }[] { - if (phase.kind === "submitting") return [{ key: "ctl+c", label: "quit" }]; + if (phase.kind === "submitting") return [{ key: "ctrl+c", label: "quit" }]; if (phase.kind === "success") return [{ key: "enter", label: "continue" }]; if (phase.kind === "error") return [ { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]; const base = [ { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]; switch (stepKey) { case "name": @@ -417,7 +417,7 @@ function hintsFor( ...base, ]; case "prompt": - return [{ key: "enter", label: "newline" }, { key: "ctl+d", label: "continue" }, ...base]; + return [{ key: "enter", label: "newline" }, { key: "ctrl+d", label: "continue" }, ...base]; case "review": return [{ key: "enter", label: verb }, { key: "↑↓", label: "scroll" }, ...base]; default: @@ -1165,7 +1165,7 @@ function SuccessPanel({ return ( - ✔ harness {mode === "create" ? "created" : "updated"} + {glyphs.check} harness {mode === "create" ? "created" : "updated"} {mode === "create" diff --git a/src/components/JsonDetail.tsx b/src/components/JsonDetail.tsx index 1cd49409b..ecf9bbc78 100644 --- a/src/components/JsonDetail.tsx +++ b/src/components/JsonDetail.tsx @@ -63,7 +63,7 @@ export function JsonDetail({ { key: "↑↓/kj", label: "navigate" }, ...(error && onRetry ? [{ key: "r", label: "retry" }] : []), { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > {isPending ? ( diff --git a/src/components/PaginatedTablePicker.tsx b/src/components/PaginatedTablePicker.tsx index 8b373f158..151a30ce3 100644 --- a/src/components/PaginatedTablePicker.tsx +++ b/src/components/PaginatedTablePicker.tsx @@ -89,7 +89,7 @@ export function PaginatedTablePicker ...(list.isError && paging.pageIndex > 0 ? [{ key: "←/h", label: "previous page" }] : []), ...(list.isError ? [{ key: "r", label: "retry" }] : []), { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > {list.isPending ? ( diff --git a/src/components/ResourceDetailScreen.tsx b/src/components/ResourceDetailScreen.tsx index 1b967ff7f..a59588e06 100644 --- a/src/components/ResourceDetailScreen.tsx +++ b/src/components/ResourceDetailScreen.tsx @@ -3,7 +3,7 @@ import { Box, Text, useInput } from "ink"; import { useNavigate } from "react-router"; import { KeyValueTable } from "./KeyValueTable.js"; import { Layout } from "./Layout"; -import { darkTheme } from "./ui/_core.js"; +import { darkTheme, glyphs } from "./ui/_core.js"; import { Divider } from "./ui/divider/Divider.js"; import { Spinner } from "./ui/spinner"; @@ -69,7 +69,7 @@ export function ResourceDetailScreen({ ...(ready && actions.length > 0 ? [{ key: "enter", label: selectLabel }] : []), ...(error && onRetry ? [{ key: "r", label: "retry" }] : []), { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > {isPending ? ( @@ -91,7 +91,9 @@ export function ResourceDetailScreen({ const selected = actionIndex === selectedIndex; return ( - {selected ? "❯ " : " "} + + {selected ? `${glyphs.pointer} ` : " "} + @@ -171,7 +171,7 @@ export function RouterScreen({ ctx, path }: RouterScreenProps) { {startsCliOnly && } - {isHl ? "❯ " : " "} + {isHl ? `${glyphs.pointer} ` : " "} ", + check: "*", + cross: "x", + done: "*", + failed: "x", + bullet: "o", + star: "*", + enter: "enter", + shift: "shift+", + warning: "!", + info: "i", + file: "-", + folder: "+", + folderOpen: "-", +}; + +export const glyphs = unicode ? unicodeGlyphs : asciiGlyphs; + +const line = ["-", "\\", "|", "/"]; + +export const spinnerFrames = unicode + ? { + dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + line, + arc: ["◜", "◠", "◝", "◞", "◡", "◟"], + bounce: ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"], + } + : { dots: line, line, arc: line, bounce: line }; export type SpinnerType = keyof typeof spinnerFrames; diff --git a/src/components/ui/autocomplete/Autocomplete.tsx b/src/components/ui/autocomplete/Autocomplete.tsx index 7749e4f5d..08071c2b0 100644 --- a/src/components/ui/autocomplete/Autocomplete.tsx +++ b/src/components/ui/autocomplete/Autocomplete.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Box, Text, useInput } from "ink"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; export interface AutocompleteItem { @@ -94,7 +94,7 @@ export function Autocomplete({ {/* Input */} {label && {label} } - + {glyphs.pointer} {query || {placeholder}} @@ -119,7 +119,7 @@ export function Autocomplete({ return ( - {isHl ? "❯" : " "} + {isHl ? glyphs.pointer : " "} {item.label} diff --git a/src/components/ui/confirm/Confirm.tsx b/src/components/ui/confirm/Confirm.tsx index cd2c34c72..8ff732b18 100644 --- a/src/components/ui/confirm/Confirm.tsx +++ b/src/components/ui/confirm/Confirm.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Text, Box, useInput, useApp } from "ink"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; export interface ConfirmProps { @@ -47,7 +47,7 @@ export const Confirm: React.FC = ({ return ( - {answered ? "✔ Confirmed" : "✖ Cancelled"} + {answered ? `${glyphs.check} Confirmed` : `${glyphs.cross} Cancelled`} ); diff --git a/src/components/ui/core.test.ts b/src/components/ui/core.test.ts new file mode 100644 index 000000000..d0272cabf --- /dev/null +++ b/src/components/ui/core.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { unicodeSupported } from "./_core"; + +describe("unicodeSupported", () => { + test.each([ + [{}, "darwin", true], + [{ TERM: "linux" }, "linux", false], + [{}, "win32", false], + [{ WT_SESSION: "1" }, "win32", true], + [{ TERM_PROGRAM: "vscode" }, "win32", true], + [{ ConEmuTask: "{cmd::Cmder}" }, "win32", true], + [{ TERMINUS_SUBLIME: "1" }, "win32", true], + [{ CI: "true" }, "win32", true], + ] as const)("env %o on %s -> %s", (env, platform, expected) => { + expect(unicodeSupported(env, platform)).toBe(expected); + }); +}); diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index 6cd26e5db..e2fa76d21 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import cliTruncate from "cli-truncate"; import { Box, Text, useInput, useWindowSize } from "ink"; import stringWidth from "string-width"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; import { COLUMN_GAP, @@ -288,7 +288,7 @@ export function DataTable>({ color={isSelected ? theme.colors.primary : theme.colors.muted} wrap="truncate" > - {isSelected ? "❯" : " "} + {isSelected ? glyphs.pointer : " "} )} diff --git a/src/components/ui/key-hint/KeyHint.test.tsx b/src/components/ui/key-hint/KeyHint.test.tsx index a111ded3c..415683495 100644 --- a/src/components/ui/key-hint/KeyHint.test.tsx +++ b/src/components/ui/key-hint/KeyHint.test.tsx @@ -22,10 +22,10 @@ describe("KeyHint", () => { [ { key: "enter", label: "send" }, { key: "⇧↵", label: "newline" }, - { key: "ctl+t", label: "target" }, + { key: "ctrl+t", label: "target" }, { key: "↑↓", label: "scroll" }, { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ], 60, ); diff --git a/src/components/ui/key-hint/KeyHint.tsx b/src/components/ui/key-hint/KeyHint.tsx index 552c0be05..872258d5d 100644 --- a/src/components/ui/key-hint/KeyHint.tsx +++ b/src/components/ui/key-hint/KeyHint.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Text, Box, useWindowSize } from "ink"; import stringWidth from "string-width"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; const ITEM_GAP = 2; @@ -20,9 +20,9 @@ export interface KeyHintProps { function priority({ key, label }: KeyHintItem): number { if (key === "esc" || label === "back") return 0; - if (key === "enter" || key.includes("↵") || label === "select") return 1; + if (key === "enter" || key.includes(glyphs.enter) || label === "select") return 1; if (key.includes("↑") || key.includes("↓")) return 2; - if (key.includes("←") || key.includes("→") || key === "/" || key === "ctl+c") return 4; + if (key.includes("←") || key.includes("→") || key === "/" || key === "ctrl+c") return 4; return 3; } diff --git a/src/components/ui/select/Select.tsx b/src/components/ui/select/Select.tsx index 84d5c5c45..c46aef698 100644 --- a/src/components/ui/select/Select.tsx +++ b/src/components/ui/select/Select.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Box, Text, useInput, useApp, useStdin } from "ink"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; export interface SelectItem { @@ -45,7 +45,7 @@ function ListDisplay({ items, activeIndex, isFocused, theme }: ListDisplayPro labelColor = theme.colors.text; } - const indicator = isActive && isFocused ? "❯ " : " "; + const indicator = isActive && isFocused ? `${glyphs.pointer} ` : " "; return ( diff --git a/src/components/ui/status-indicator/StatusIndicator.tsx b/src/components/ui/status-indicator/StatusIndicator.tsx index fd8ba7e8a..31c9b4f72 100644 --- a/src/components/ui/status-indicator/StatusIndicator.tsx +++ b/src/components/ui/status-indicator/StatusIndicator.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { Text, Box } from "ink"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; export type StatusValue = "online" | "offline" | "loading" | "warning" | "error" | "idle"; @@ -41,7 +41,7 @@ function staticDot(status: StatusValue): string { case "error": return "●"; case "warning": - return "⚠"; + return glyphs.warning; case "loading": return "◉"; case "idle": diff --git a/src/components/ui/stepper/Stepper.tsx b/src/components/ui/stepper/Stepper.tsx index 9cebfc534..71fd57fcd 100644 --- a/src/components/ui/stepper/Stepper.tsx +++ b/src/components/ui/stepper/Stepper.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Box, Text } from "ink"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; export interface Step { @@ -30,8 +30,9 @@ export const Stepper: React.FC = ({ theme = darkTheme, }) => { const getIndicator = (step: Step, _index: number) => { - if (errorSteps.includes(step.key)) return { char: "✕", color: theme.colors.error }; - if (completedSteps.includes(step.key)) return { char: "✓", color: theme.colors.success }; + if (errorSteps.includes(step.key)) return { char: glyphs.failed, color: theme.colors.error }; + if (completedSteps.includes(step.key)) + return { char: glyphs.done, color: theme.colors.success }; if (step.key === currentStep) return { char: "●", color: theme.colors.primary }; return { char: "○", color: theme.colors.muted }; }; diff --git a/src/components/ui/task-list/TaskList.tsx b/src/components/ui/task-list/TaskList.tsx index db956c594..a35d1bb93 100644 --- a/src/components/ui/task-list/TaskList.tsx +++ b/src/components/ui/task-list/TaskList.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Box, Text, useStdout } from "ink"; import cliTruncate from "cli-truncate"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; import { Spinner } from "../spinner/Spinner.js"; @@ -50,7 +50,7 @@ export const TaskList: React.FC = ({ ) : ( - {task.state === "done" ? "✓" : "✕"} + {task.state === "done" ? glyphs.done : glyphs.failed} {task.title} diff --git a/src/components/ui/text-input/TextInput.tsx b/src/components/ui/text-input/TextInput.tsx index f9b1c3a12..adc9087ca 100644 --- a/src/components/ui/text-input/TextInput.tsx +++ b/src/components/ui/text-input/TextInput.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Box, Text, useInput, useApp, useStdin } from "ink"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; export interface TextInputProps { @@ -169,7 +169,7 @@ export const TextInput: React.FC = ({ password = false, focus = true, label, - prompt = "❯ ", + prompt = `${glyphs.pointer} `, theme = darkTheme, }) => { const { isRawModeSupported } = useStdin(); diff --git a/src/components/ui/toast/Toast.tsx b/src/components/ui/toast/Toast.tsx index 7f65430c1..2310937b3 100644 --- a/src/components/ui/toast/Toast.tsx +++ b/src/components/ui/toast/Toast.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from "react"; import { Text, Box } from "ink"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; export type ToastVariant = "success" | "warning" | "error" | "info"; @@ -15,10 +15,10 @@ export interface ToastProps { } const ICONS: Record = { - success: "✔", - warning: "⚠", - error: "✖", - info: "ℹ", + success: glyphs.check, + warning: glyphs.warning, + error: glyphs.cross, + info: glyphs.info, }; function variantColor(variant: ToastVariant, theme: InkUITheme): string { diff --git a/src/components/ui/tree-view/TreeView.tsx b/src/components/ui/tree-view/TreeView.tsx index 9a0415854..cb2310a41 100644 --- a/src/components/ui/tree-view/TreeView.tsx +++ b/src/components/ui/tree-view/TreeView.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Box, Text, useInput } from "ink"; -import { darkTheme } from "../_core.js"; +import { darkTheme, glyphs } from "../_core.js"; import type { InkUITheme } from "../_core.js"; export interface TreeNode { @@ -59,9 +59,9 @@ export function TreeView({ maxHeight, guides = true, showIcons = true, - leafIcon = "📄", - branchIcon = "📁", - branchOpenIcon = "📂", + leafIcon = glyphs.file, + branchIcon = glyphs.folder, + branchOpenIcon = glyphs.folderOpen, focus = true, theme = darkTheme, }: TreeViewProps): React.ReactElement { diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts index 585889d9f..297f8a4e5 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -60,12 +60,14 @@ function harness( ) { const calls: ProcessCall[] = []; const discoverCalls: string[][] = []; + const discoverEnvs: (NodeJS.ProcessEnv | undefined)[] = []; const fakeStreamProcess: ProcessStreamer = async function* (command, options) { calls.push({ command, options }); yield* output; }; const fakeRunProcess: ProcessRunner = async (command, options) => { discoverCalls.push(command); + discoverEnvs.push(options.env); if (site.fail) throw new Error("discovery failed"); for (const line of site.noise ?? []) options.onOutput?.(`${line}\n`); if (site.dir !== undefined) options.onOutput?.(`AGENTCORE_OTEL_SITECUSTOMIZE=${site.dir}\n`); @@ -73,6 +75,7 @@ function harness( return { calls, discoverCalls, + discoverEnvs, runner: new CodeZipDevRunner({ streamProcess: fakeStreamProcess, runProcess: fakeRunProcess }), }; } @@ -153,7 +156,13 @@ describe("CodeZipDevRunner", () => { ]); expect(calls[0]?.options).toMatchObject({ cwd: join(root, "app", "hello-world"), - env: { CUSTOM_ENV: "value", PORT: "9000", LOCAL_DEV: "1" }, + env: { + CUSTOM_ENV: "value", + PORT: "9000", + LOCAL_DEV: "1", + PYTHONUTF8: "1", + PYTHONUNBUFFERED: "1", + }, }); expect(events).toEqual([ { type: "status", message: "Starting development server" }, @@ -161,6 +170,15 @@ describe("CodeZipDevRunner", () => { ]); }); + test("lets the project's own env override the Python defaults", async () => { + const root = await projectRoot(); + const { calls, runner } = harness(); + + await collect(runner.run({ ...input(root, runtime()), env: { PYTHONUTF8: "0" } })); + + expect(calls[0]?.options.env).toMatchObject({ PYTHONUTF8: "0", PYTHONUNBUFFERED: "1" }); + }); + test.each(["MCP", "A2A", "AGUI"] as const)( "runs %s Python entrypoints directly", async (protocol) => { @@ -202,6 +220,7 @@ describe("CodeZipDevRunner", () => { expect(calls.map(({ command }) => command)).toEqual([ ["npm", "exec", "--", "tsx", "watch", "index.js"], ]); + expect(calls[0]?.options.env?.PYTHONUTF8).toBeUndefined(); }); test("runs the .ts source when a TypeScript runtime's entrypoint is the compiled .js", async () => { @@ -236,11 +255,12 @@ describe("CodeZipDevRunner OTEL instrumentation", () => { test("prepends the sitecustomize directory to PYTHONPATH when instrumentation is installed", async () => { const root = await projectRoot(); const directory = await sitecustomizeDir(); - const { calls, discoverCalls, runner } = harness([], { dir: directory }); + const { calls, discoverCalls, discoverEnvs, runner } = harness([], { dir: directory }); await collect(runner.run(otelInput(root))); expect(discoverCalls[0]?.slice(0, 4)).toEqual(["uv", "run", "python", "-c"]); + expect(discoverEnvs[0]).toMatchObject({ PYTHONUTF8: "1", PYTHONUNBUFFERED: "1" }); expect(calls[0]?.options.env?.PYTHONPATH).toBe(directory); }); diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index 08f1386d6..b3562f206 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -17,6 +17,8 @@ type CodeZipDevRunnerConfig = { }; const SITECUSTOMIZE_MARKER = "AGENTCORE_OTEL_SITECUSTOMIZE="; +// Windows Python defaults piped stdout to the ANSI code page and block buffering. +const PYTHON_ENV = { PYTHONUTF8: "1", PYTHONUNBUFFERED: "1" } as const; export class CodeZipDevRunner implements DevRunner { private readonly streamProcess: ProcessStreamer; @@ -53,7 +55,6 @@ export class CodeZipDevRunner implements DevRunner { yield* this.streamProcess(["npm", "install"], { cwd: directory, signal: input.signal, - shell: process.platform === "win32", }); } @@ -95,6 +96,7 @@ export class CodeZipDevRunner implements DevRunner { try { await this.runProcess(["uv", "run", "python", "-c", script], { cwd: directory, + env: { ...process.env, ...PYTHON_ENV }, onOutput: (chunk) => output.push(chunk), signal, }); @@ -120,6 +122,7 @@ function commandForRuntime( ): { command: string[]; options: StreamProcessOptions } { const env: NodeJS.ProcessEnv = { ...process.env, + ...(entrypoint.endsWith(".py") ? PYTHON_ENV : {}), ...input.env, PORT: String(input.port), LOCAL_DEV: "1", @@ -132,12 +135,7 @@ function commandForRuntime( if (!entrypoint.endsWith(".py")) { return { command: ["npm", "exec", "--", "tsx", "watch", entrypoint], - options: { - cwd: directory, - env, - signal: input.signal, - shell: process.platform === "win32", - }, + options: { cwd: directory, env, signal: input.signal }, }; } diff --git a/src/core/eval.tsx b/src/core/eval.tsx index 7c5032bd5..10d0796b2 100644 --- a/src/core/eval.tsx +++ b/src/core/eval.tsx @@ -153,6 +153,7 @@ import type { } from "../handlers/eval/types"; import { atomicWrite, atomicWriteStream, readTextFile } from "../io"; import { accountIdFromRuntimeArn, invokeRuntime } from "./invokeRuntime"; +import { isFile } from "./dev/path"; import { DatasetLoader } from "./eval/invokeDataset/load"; import { runExamples } from "./eval/invokeDataset/run"; import { renderJsonTemplate } from "./eval/invokeDataset/template"; @@ -168,8 +169,9 @@ import { accountIdFromRoleArn, executionPolicy, grantOnlineEvalScope, - onlineEvalExecutionRoleName, + isManagedOnlineEvalRole, revokeOnlineEvalScope, + roleNameFromArn, scopePolicyName, } from "./onlineEvalExecutionRole"; import { accountIdFromArn, deleteAbTestRole, provisionAbTestRole } from "./abTestExecutionRole"; @@ -926,7 +928,7 @@ export class EvalClient implements CoreEvalClient { options: CoreOptions, signal?: AbortSignal, ): Promise { - if (await Bun.file(ref).exists()) return readLocalDatasetFile(ref, signal); + if (isFile(ref)) return readLocalDatasetFile(ref, signal); const path = await this.downloadDatasetToTemp(ref, version, options, signal); try { return await readLocalDatasetFile(path, signal); @@ -1228,7 +1230,8 @@ export class EvalClient implements CoreEvalClient { const managedRoleName = configName !== undefined && update.evaluationExecutionRoleArn === undefined && - roleArn?.endsWith(`/${onlineEvalExecutionRoleName(configName)}`) === true + roleArn !== undefined && + isManagedOnlineEvalRole(roleArn, configName) ? configName : undefined; const refreshManagedRole = movedTo !== undefined && managedRoleName !== undefined; @@ -1275,6 +1278,7 @@ export class EvalClient implements CoreEvalClient { options.region, newLogGroups, kmsKeys, + roleNameFromArn(roleArn!), ); const oldPolicyName = scopePolicyName( executionPolicy( @@ -1295,11 +1299,15 @@ export class EvalClient implements CoreEvalClient { ); if (newPolicyName !== oldPolicyName) { - try { - await revokeOnlineEvalScope(iam, managedRoleName, oldPolicyName); - } catch { - // The config is already correct; the role just still grants a data - // source it no longer uses. + const revoked = await revokeOnlineEvalScope( + iam, + roleNameFromArn(managedRoleArn), + oldPolicyName, + ).catch(() => false); + // The config is already correct; the role just still grants a data + // source it no longer uses, either because the delete failed or because + // the policy was written under a legacy name this build cannot derive. + if (!revoked) { roleScopeWarning = { reason: "stale-scope", roleArn: roleArn!, diff --git a/src/core/onlineEvalExecutionRole.test.ts b/src/core/onlineEvalExecutionRole.test.ts index 594de9eac..7f1ec01ea 100644 --- a/src/core/onlineEvalExecutionRole.test.ts +++ b/src/core/onlineEvalExecutionRole.test.ts @@ -1,6 +1,7 @@ import { test, expect } from "bun:test"; import { executionPolicy, + isManagedOnlineEvalRole, onlineEvalExecutionRoleName, scopePolicyName, } from "./onlineEvalExecutionRole"; @@ -61,6 +62,31 @@ test("keeps role names within 64 characters and distinct", () => { expect(onlineEvalExecutionRoleName("short")).toBe("AgentCoreOnlineEval-short"); }); +// A role created by an earlier build carries a hash suffix this build cannot +// recompute, so a truncated name is recognised on its prefix. +test.each([ + ["short", "arn:aws:iam::123456789012:role/AgentCoreOnlineEval-short", true], + ["short", "arn:aws:iam::123456789012:role/AgentCoreOnlineEval-other", false], + ["short", "arn:aws:iam::123456789012:role/custom-role", false], + [ + "x".repeat(50), + `arn:aws:iam::123456789012:role/${onlineEvalExecutionRoleName("x".repeat(50))}`, + true, + ], + [ + "x".repeat(50), + `arn:aws:iam::123456789012:role/AgentCoreOnlineEval-${"x".repeat(35)}-20487c61`, + true, + ], + [ + "x".repeat(50), + `arn:aws:iam::123456789012:role/AgentCoreOnlineEval-${"y".repeat(35)}-20487c61`, + false, + ], +])("recognises the managed role for %s from %s: %s", (configName, roleArn, expected) => { + expect(isManagedOnlineEvalRole(roleArn, configName)).toBe(expected); +}); + // Each scope must map to its own policy name. Granting a new scope writes a new // policy rather than overwriting the current one, which is what lets an update // keep the old scope intact until the config change has landed. diff --git a/src/core/onlineEvalExecutionRole.tsx b/src/core/onlineEvalExecutionRole.tsx index 2d3b6f0e0..4f15beb01 100644 --- a/src/core/onlineEvalExecutionRole.tsx +++ b/src/core/onlineEvalExecutionRole.tsx @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { CreateRoleCommand, DeleteRolePolicyCommand, @@ -26,6 +27,10 @@ const ROLE_NAME_PREFIX = "AgentCoreOnlineEval-"; const ROLE_NAME_MAX = 64; const NAME_HASH_LENGTH = 8; +function fingerprint(text: string): string { + return createHash("sha256").update(text).digest("hex").slice(0, NAME_HASH_LENGTH); +} + // onlineEvalExecutionRoleName derives the default role's name from the online eval // config name. IAM caps role names at 64 characters, which leaves only 44 for the // config name — while config names run to 100 — so a name that would overflow is @@ -36,12 +41,26 @@ export function onlineEvalExecutionRoleName(configName: string): string { const full = `${ROLE_NAME_PREFIX}${configName}`; if (full.length <= ROLE_NAME_MAX) return full; - const hash = Bun.hash(configName) - .toString(16) - .padStart(NAME_HASH_LENGTH, "0") - .slice(-NAME_HASH_LENGTH); + return `${truncatedRolePrefix(configName)}${fingerprint(configName)}`; +} + +function truncatedRolePrefix(configName: string): string { const room = ROLE_NAME_MAX - ROLE_NAME_PREFIX.length - NAME_HASH_LENGTH - 1; - return `${ROLE_NAME_PREFIX}${configName.slice(0, room)}-${hash}`; + return `${ROLE_NAME_PREFIX}${configName.slice(0, room)}-`; +} + +export function roleNameFromArn(roleArn: string): string { + return roleArn.slice(roleArn.lastIndexOf("/") + 1); +} + +// isManagedOnlineEvalRole recognises the CLI's default role for a config. Roles +// created before the hash moved off Bun.hash carry a different suffix, so a +// truncated name is matched on its prefix rather than recomputed. +export function isManagedOnlineEvalRole(roleArn: string, configName: string): boolean { + const roleName = roleNameFromArn(roleArn); + const full = `${ROLE_NAME_PREFIX}${configName}`; + if (full.length <= ROLE_NAME_MAX) return roleName === full; + return roleName.startsWith(truncatedRolePrefix(configName)); } function trustPolicy(): string { @@ -182,11 +201,7 @@ export function accountIdFromRoleArn(arn: string): string { // policy can never clobber another's — a superseded scope stays intact until it // is explicitly revoked. export function scopePolicyName(policyDocument: string): string { - const fingerprint = Bun.hash(policyDocument) - .toString(16) - .padStart(NAME_HASH_LENGTH, "0") - .slice(-NAME_HASH_LENGTH); - return `${POLICY_PREFIX}-${fingerprint}`; + return `${POLICY_PREFIX}-${fingerprint(policyDocument)}`; } // grantOnlineEvalScope creates the execution role for `configName` if it does not @@ -199,9 +214,8 @@ export async function grantOnlineEvalScope( region: string, logGroupNames: string[], kmsKeyArns: string[] = [], + roleName = onlineEvalExecutionRoleName(configName), ): Promise<{ roleArn: string; policyName: string }> { - const roleName = onlineEvalExecutionRoleName(configName); - let roleArn: string; try { const existing = await iam.send(new GetRoleCommand({ RoleName: roleName })); @@ -237,20 +251,18 @@ export async function grantOnlineEvalScope( } // revokeOnlineEvalScope detaches a scope's inline policy, dropping the access it -// granted. A scope that is already absent is treated as revoked. +// granted. Returns false when no policy of that name was attached, which is how a +// policy written under a legacy name shows up: still granted, not removable here. export async function revokeOnlineEvalScope( iam: IAMClient, - configName: string, + roleName: string, policyName: string, -): Promise { +): Promise { try { - await iam.send( - new DeleteRolePolicyCommand({ - RoleName: onlineEvalExecutionRoleName(configName), - PolicyName: policyName, - }), - ); + await iam.send(new DeleteRolePolicyCommand({ RoleName: roleName, PolicyName: policyName })); + return true; } catch (error) { if ((error as Error).name !== "NoSuchEntityException") throw error; + return false; } } diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 74bbf5e0c..916b37cf7 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -159,7 +159,7 @@ export class CdkBackend implements ProjectBackend { if (!existsSync(join(cdkDir, "node_modules"))) { throw new ProjectStateError( `CDK dependencies are missing for project '${project.name}'. ` + - `Run 'cd ${cdkDir} && npm install'.`, + `Run 'npm install' in ${cdkDir}.`, ); } await this.checkTool("npm", "Install Node.js: https://nodejs.org/"); diff --git a/src/core/project/backends/cdk/toolkit.ts b/src/core/project/backends/cdk/toolkit.ts index 470c3bce0..db567459c 100644 --- a/src/core/project/backends/cdk/toolkit.ts +++ b/src/core/project/backends/cdk/toolkit.ts @@ -80,6 +80,7 @@ export type BootstrapTemplateLoader = () => Promise 0; return embedded ? new EmbeddedAssetSource() : new FsAssetSource(); } diff --git a/src/handlers/eval/ab-test/__fixtures__/DeleteRolePolicyCommand.3826bd85235b40f0.json b/src/handlers/eval/ab-test/__fixtures__/DeleteRolePolicyCommand.3826bd85235b40f0.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/src/handlers/eval/ab-test/__fixtures__/DeleteRolePolicyCommand.3826bd85235b40f0.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.446206df31252f99.json b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.446206df31252f99.json index 9e26dfeeb..0967ef424 100644 --- a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.446206df31252f99.json +++ b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.446206df31252f99.json @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.5263c2ec6602ec51.json b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.5263c2ec6602ec51.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.5263c2ec6602ec51.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.789111680399c05c.json b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.8b86c6ea7839fd24.json similarity index 100% rename from src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.789111680399c05c.json rename to src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.8b86c6ea7839fd24.json diff --git a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.8bf1be5c5e52a2ec.json b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.8bf1be5c5e52a2ec.json index 9e26dfeeb..0967ef424 100644 --- a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.8bf1be5c5e52a2ec.json +++ b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.8bf1be5c5e52a2ec.json @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.b75418e24aa092ce.json b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.b75418e24aa092ce.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.b75418e24aa092ce.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.bc0b72d6bad3afad.json b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.bc0b72d6bad3afad.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.bc0b72d6bad3afad.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.c44ecddea6eb7aaa.json b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.c44ecddea6eb7aaa.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.c44ecddea6eb7aaa.json @@ -0,0 +1 @@ +{} diff --git a/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.fe5d50d514fa5176.json b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.fe5d50d514fa5176.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/handlers/eval/ab-test/__fixtures__/PutRolePolicyCommand.fe5d50d514fa5176.json @@ -0,0 +1 @@ +{} diff --git a/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.5a7fd2344c4d345a.json b/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.5a7fd2344c4d345a.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.5a7fd2344c4d345a.json @@ -0,0 +1 @@ +{} diff --git a/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.5e12cb8fd951e3d2.json b/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.5e12cb8fd951e3d2.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.5e12cb8fd951e3d2.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.999eb3ded7d6f95.json b/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.999eb3ded7d6f95.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.999eb3ded7d6f95.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.ace7666da95be80c.json b/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.ace7666da95be80c.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/handlers/eval/online-eval/__fixtures__/PutRolePolicyCommand.ace7666da95be80c.json @@ -0,0 +1 @@ +{} diff --git a/src/handlers/gateway/invoke/invoke.screen.test.tsx b/src/handlers/gateway/invoke/invoke.screen.test.tsx index f1bcc4f0a..a0cf2b1d1 100644 --- a/src/handlers/gateway/invoke/invoke.screen.test.tsx +++ b/src/handlers/gateway/invoke/invoke.screen.test.tsx @@ -255,8 +255,8 @@ describe("Gateway invoke JSON console", () => { expect(invokeRequests(core)).toHaveLength(0); expect(screen.lastFrame()).toContain("{}"); - expect(screen.lastFrame()).toContain("[ctl+p] path"); - expect(screen.lastFrame()).toContain("[ctl+t] gateway"); + expect(screen.lastFrame()).toContain("[ctrl+p] path"); + expect(screen.lastFrame()).toContain("[ctrl+t] gateway"); }); test("blocks a non-READY Gateway with its current status", async () => { @@ -736,7 +736,7 @@ describe("Gateway invoke JSON console", () => { await waitForText(screen.lastFrame, "Ready"); await screen.resize(80, 24); expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); - expect(screen.lastFrame()).toContain("[ctl+p] path"); + expect(screen.lastFrame()).toContain("[ctrl+p] path"); await screen.resize(60, 24); expect(screen.lastFrame()).toContain("[enter] send"); diff --git a/src/handlers/gateway/invoke/screen.tsx b/src/handlers/gateway/invoke/screen.tsx index 15477ce3e..d4b677b6a 100644 --- a/src/handlers/gateway/invoke/screen.tsx +++ b/src/handlers/gateway/invoke/screen.tsx @@ -9,7 +9,7 @@ import { useNavigate, useParams } from "react-router"; import { GatewayPicker } from "../../../components/GatewayPicker"; import { Layout } from "../../../components/Layout"; import { MultilineInput } from "../../../components/MultilineInput"; -import { darkTheme } from "../../../components/ui/_core.js"; +import { darkTheme, glyphs } from "../../../components/ui/_core.js"; import { Divider } from "../../../components/ui/divider"; import { Spinner } from "../../../components/ui/spinner"; import { TextInput } from "../../../components/ui/text-input"; @@ -428,24 +428,24 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI ? [ { key: "enter", label: "save" }, { key: "esc", label: "cancel" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] : busy ? [ { key: "esc", label: "interrupt" }, { key: "↑↓", label: "scroll" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] : [ { key: "enter", label: "send" }, ...(canPrettyJson - ? [{ key: "ctl+v", label: prettyJson ? "raw JSON" : "pretty JSON" }] - : [{ key: "⇧↵", label: "newline" }]), - { key: "ctl+p", label: "path" }, - { key: "ctl+t", label: "gateway" }, + ? [{ key: "ctrl+v", label: prettyJson ? "raw JSON" : "pretty JSON" }] + : [{ key: `${glyphs.shift}${glyphs.enter}`, label: "newline" }]), + { key: "ctrl+p", label: "path" }, + { key: "ctrl+t", label: "gateway" }, { key: "↑↓", label: "scroll" }, { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] } > diff --git a/src/handlers/gateway/policy/screen.tsx b/src/handlers/gateway/policy/screen.tsx index e8dc680c6..ab93a2579 100644 --- a/src/handlers/gateway/policy/screen.tsx +++ b/src/handlers/gateway/policy/screen.tsx @@ -11,7 +11,7 @@ import { MultilineInput } from "../../../components/MultilineInput"; import { Divider } from "../../../components/ui/divider"; import { Spinner } from "../../../components/ui/spinner"; import { TaskList, type Task } from "../../../components/ui/task-list"; -import { darkTheme } from "../../../components/ui/_core.js"; +import { darkTheme, glyphs } from "../../../components/ui/_core.js"; import { UserCancellationError } from "../../../errors"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; @@ -129,20 +129,20 @@ function GeneratePolicyForm({ ctx, core, gatewayId }: ScreenProps & { gatewayId: phase.kind === "form" && engineArn ? [ { key: "enter", label: "generate" }, - { key: "⇧↵", label: "newline" }, + { key: `${glyphs.shift}${glyphs.enter}`, label: "newline" }, { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] : phase.kind === "result" ? [ { key: "↑↓/kj", label: "scroll" }, { key: "e", label: "edit prompt" }, { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] : [ { key: "esc", label: phase.kind === "running" ? "cancel" : "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]; return ( diff --git a/src/handlers/harness/endpoint/update/screen.tsx b/src/handlers/harness/endpoint/update/screen.tsx index c1bda9316..580ebaaa0 100644 --- a/src/handlers/harness/endpoint/update/screen.tsx +++ b/src/handlers/harness/endpoint/update/screen.tsx @@ -63,7 +63,7 @@ function UpdateWizard({ breadcrumb={["agentcore", "harness", "endpoint", "update", harnessId, endpointName]} keyHints={[ { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > {detail.isPending ? ( diff --git a/src/handlers/harness/exec/exec.screen.test.tsx b/src/handlers/harness/exec/exec.screen.test.tsx index 66892af8e..90de53110 100644 --- a/src/handlers/harness/exec/exec.screen.test.tsx +++ b/src/handlers/harness/exec/exec.screen.test.tsx @@ -134,11 +134,11 @@ describe("exec screen", () => { const r = renderScreen(EXEC_PATH, { core: execCore() }); await waitForText(r.lastFrame, "run a command…"); - expect(r.lastFrame()).toContain("[ctl+e] chat mode"); + expect(r.lastFrame()).toContain("[ctrl+e] chat mode"); await r.write(CTRL_E); await waitForText(r.lastFrame, "send a message…"); - expect(r.lastFrame()).toContain("[ctl+e] exec mode"); + expect(r.lastFrame()).toContain("[ctrl+e] exec mode"); await r.write(CTRL_E); await waitForText(r.lastFrame, "run a command…"); diff --git a/src/handlers/harness/invoke/screen.tsx b/src/handlers/harness/invoke/screen.tsx index a01fd1d62..5dd8cde4f 100644 --- a/src/handlers/harness/invoke/screen.tsx +++ b/src/handlers/harness/invoke/screen.tsx @@ -13,7 +13,7 @@ import { Markdown } from "../../../components/ui/markdown"; import { Spinner } from "../../../components/ui/spinner"; import { StatusIndicator, type StatusValue } from "../../../components/ui/status-indicator"; import { TextInput } from "../../../components/ui/text-input"; -import { darkTheme } from "../../../components/ui/_core.js"; +import { darkTheme, glyphs } from "../../../components/ui/_core.js"; import { applyEvent, applyExecEvent, @@ -326,15 +326,15 @@ export function HarnessChat({ streaming ? [ { key: "esc", label: "interrupt" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] : [ { key: "enter", label: mode === "exec" ? "run" : "send" }, - { key: "ctl+e", label: mode === "exec" ? "chat mode" : "exec mode" }, - { key: "ctl+t", label: "endpoint" }, + { key: "ctrl+e", label: mode === "exec" ? "chat mode" : "exec mode" }, + { key: "ctrl+t", label: "endpoint" }, { key: "↑↓", label: "scroll" }, { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] } > @@ -361,7 +361,7 @@ export function HarnessChat({ value={input} onChange={setInput} onSubmit={submit} - prompt={mode === "exec" ? "$ " : "❯ "} + prompt={mode === "exec" ? "$ " : `${glyphs.pointer} `} placeholder={mode === "exec" ? "run a command…" : "send a message…"} /> @@ -411,7 +411,7 @@ function ItemView({ item, width }: { item: TranscriptItem; width: number }) { case "user": return ( - + {glyphs.pointer} {item.text} @@ -421,7 +421,7 @@ function ItemView({ item, width }: { item: TranscriptItem; width: number }) { if (item.streaming) { return ( - + {glyphs.bullet} {item.text} @@ -431,7 +431,7 @@ function ItemView({ item, width }: { item: TranscriptItem; width: number }) { } return ( - + {glyphs.bullet} @@ -440,7 +440,7 @@ function ItemView({ item, width }: { item: TranscriptItem; width: number }) { case "reasoning": return ( - ✻ {item.text} + {glyphs.star} {item.text} {item.streaming ? "▌" : ""} ); @@ -483,7 +483,11 @@ function ItemView({ item, width }: { item: TranscriptItem; width: number }) { ); case "error": - return ✗ {item.message}; + return ( + + {glyphs.cross} {item.message} + + ); case "notice": return ( diff --git a/src/handlers/harness/update/screen.tsx b/src/handlers/harness/update/screen.tsx index 9b0b1465b..9b716fd59 100644 --- a/src/handlers/harness/update/screen.tsx +++ b/src/handlers/harness/update/screen.tsx @@ -47,7 +47,7 @@ function UpdateWizard({ ctx, core, harnessId }: ScreenProps & { harnessId: strin breadcrumb={["agentcore", "harness", "update", harnessId]} keyHints={[ { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > {detail.isPending ? ( diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 15dfbf7b3..0ac5a5053 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -11,7 +11,13 @@ import { createConfigHandler } from "./config/"; import { createProjectHandler } from "./project/index.ts"; import { createUpdateHandler } from "./update/index.tsx"; import { renderTui } from "../tui"; -import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware"; +import { + withRegion, + withJsonRenderer, + withLogging, + withGlobalConfigAccessor, + withPlatform, +} from "../middleware"; import type { AppIO } from "../io"; import type { Core } from "./types.tsx"; import type { Logger } from "../logging"; @@ -22,6 +28,8 @@ export interface RootHandlerConfig { io: AppIO; logger: Logger; globalConfigAccessor: GlobalConfigAccessor; + /** Host platform, defaults to `process.platform`. Tests pass "win32" to exercise Windows paths. */ + platform?: NodeJS.Platform; } export function createRootHandler(core: Core, config: RootHandlerConfig): Router { @@ -53,6 +61,9 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router // Pin the global config accessor on the context for any handler that needs it. root.use(withGlobalConfigAccessor(config.globalConfigAccessor)); + // Pin the host platform so Windows-specific behavior is decided from the context. + root.use(withPlatform(config.platform ?? process.platform)); + // Install sub handlers root.handler(createHarnessHandler(core, io)); root.handler(createIdentityHandler(core, io)); diff --git a/src/handlers/memory/record/list/screen.tsx b/src/handlers/memory/record/list/screen.tsx index c720ea36c..e0dc44ae0 100644 --- a/src/handlers/memory/record/list/screen.tsx +++ b/src/handlers/memory/record/list/screen.tsx @@ -119,7 +119,7 @@ function MemoryRecordScopeScreen({ memoryId }: MemoryRecordScopeScreenProps) { { key: "up/down", label: "scope type" }, { key: "enter", label: "list records" }, { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx index 4be2cfc2a..b7437f08c 100644 --- a/src/handlers/project/ProjectGate.tsx +++ b/src/handlers/project/ProjectGate.tsx @@ -3,7 +3,7 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { Box, Text, useInput } from "ink"; import { Layout } from "../../components/Layout"; import { Spinner } from "../../components/ui/spinner"; -import { darkTheme } from "../../components/ui/_core.js"; +import { darkTheme, glyphs } from "../../components/ui/_core.js"; import { ProjectStateError } from "../../errors/errors"; import { projectNotFoundMessage } from "../../middleware/withProject"; import type { Core } from "../types"; @@ -61,12 +61,14 @@ export function LoadingFrame({ keyHints={[ ...(query.isError ? [{ key: "r", label: "retry" }] : []), { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > {query.isError ? ( - ✗ {(query.error as Error).message} + + {glyphs.cross} {(query.error as Error).message} + ) : ( )} diff --git a/src/handlers/project/build/index.ts b/src/handlers/project/build/index.ts index 455493d1d..c6d5a0444 100644 --- a/src/handlers/project/build/index.ts +++ b/src/handlers/project/build/index.ts @@ -1,9 +1,8 @@ import { createHandler, ProjectKey } from "../../../router"; import type { AppIO } from "../../../io"; -import { JsonRendererKey } from "../../../tui"; import { runWithProgress } from "../../../tui/progress"; import { JsonKey } from "../../keys"; -import { renderJsonError } from "../../utils"; +import { renderJsonError, reportMessage } from "../../utils"; import type { Project, ProjectManager } from "../types"; type BuildProjectHandlerConfig = { @@ -40,8 +39,6 @@ export const createBuildProjectHandler = (config: BuildProjectHandlerConfig) => throw error; } - const message = builtMessage(project); - config.io.stderr.write(`${message}\n`); - if (jsonOutput) ctx.require(JsonRendererKey).renderJson({ message }); + reportMessage(ctx, config.io, builtMessage(project)); }, }); diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx index c283f0b2d..2881472f4 100644 --- a/src/handlers/project/buildDeploy.screen.test.tsx +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -176,7 +176,7 @@ describe("project build screen", () => { await waitForFlatText(r.lastFrame, "No AgentCore project found"); expect(flatFrame(r.lastFrame)).toContain("agentcore project create"); - // esc is a way off the error, not just ctl+c. + // esc is a way off the error, not just ctrl+c. await r.press("escape"); await waitForText(r.lastFrame, "manage an AgentCore project"); r.unmount(); diff --git a/src/handlers/project/create/create.screen.test.tsx b/src/handlers/project/create/create.screen.test.tsx index db7f3a26e..cc225071e 100644 --- a/src/handlers/project/create/create.screen.test.tsx +++ b/src/handlers/project/create/create.screen.test.tsx @@ -1,6 +1,6 @@ import { test, expect, describe, afterEach } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { @@ -12,12 +12,9 @@ import { TestGlobalConfigAccessor, testIO, ttyTestIO, - tick, waitFor, } from "../../../testing"; -import { renderTuiAt } from "../../../tui"; import { createRootHandler } from "../../index"; -import { ValueContext } from "../../../router"; import { InputValidationError } from "../../../errors"; import type { AppIO } from "../../../io"; import { resolveRuntimeTemplateShortcut } from "../shortcuts"; @@ -553,12 +550,20 @@ describe("project create wizard", () => { release(); }); - test("an error from create() renders after the streamed progress", async () => { + test("a create() error offers r to retry only before anything was written, esc returns to review with the input kept", async () => { + await inTempDirectory(); const core = new TestCoreClient(); - core.projectManager.create = () => { + const created: CreateProjectInput[] = []; + const real = core.projectManager.create.bind(core.projectManager); + core.projectManager.create = (input) => { + created.push(input); + if (created.length > 2) return real(input); + // First attempt fails before any step (a missing tool), the second after + // the tree was written. + const wroteTree = created.length === 2; return (async function* () { - yield { type: "step" as const, message: "creating project directory" }; - throw new Error("disk full"); + if (wroteTree) yield { type: "step" as const, message: "creating project directory" }; + throw new Error("'git' was not found on your PATH."); })(); }; const r = renderScreen("/agentcore/project/create", { core }); @@ -574,70 +579,52 @@ describe("project create wizard", () => { await waitForText(r.lastFrame, "this project will be created"); await r.press("return"); - // The error panel also requests app exit, which may unmount the screen; - // assert on the frame history rather than only the final frame. - await waitFor(() => - r.frames.some( - (frame) => frame.includes("✗ disk full") && frame.includes("creating project directory"), - ), - ); + await waitForText(r.lastFrame, "✗ 'git' was not found on your PATH."); + expect(r.lastFrame()).toContain("[r] retry"); + expect(r.lastFrame()).toContain("[esc] back"); + + await r.write("r"); + await waitFor(() => created.length === 2); + await waitForText(r.lastFrame, "creating project directory"); + await waitForText(r.lastFrame, "✗ 'git' was not found on your PATH."); + expect(r.lastFrame()).not.toContain("[r] retry"); + expect(r.lastFrame()).toContain("[esc] back"); + + await r.press("escape"); + await waitForText(r.lastFrame, "this project will be created"); + expect(r.lastFrame()).toContain("DemoApp"); + + await r.press("return"); + await waitForText(r.lastFrame, "project created in ./DemoApp"); + expect(created).toHaveLength(3); + expect(created[2]).toEqual(created[0]); r.unmount(); }); - test("a create() error tears the TUI down nonzero (renderTuiAt rejects)", async () => { - const core = new TestCoreClient(); - const created: CreateProjectInput[] = []; - core.projectManager.create = (input) => { - created.push(input); - return (async function* () { - yield { type: "step" as const, message: "creating project directory" }; - throw new Error("disk full"); - })(); - }; - const { streams, stdin } = ttyTestIO(); + test("on Windows a deep project root is refused before anything is written", async () => { + const deep = join(await inTempDirectory(), "n".repeat(120)); + await mkdir(deep); + process.chdir(deep); + const r = renderScreen("/agentcore/project/create", { platform: "win32" }); - // The settlement handler is attached before any input is sent: the app - // exits (rejecting waitUntilExit) while keys are still being paced, and a - // bare rejected promise would trip bun's unhandled-rejection detection. - const caught: Promise = renderTuiAt( - "/agentcore/project/create", - ValueContext.EmptyContext(), - core, - streams.io, - ).then( - () => undefined, - (error: unknown) => error, - ); - - // Walk the shortest path (harness defaults) by raw key writes — frames are - // not observable here (Ink suppresses incremental frames under CI), so the - // pacing is tick-based. Writes are spaced out so consecutive keys cannot - // coalesce into one stdin chunk (Ink parses a merged "\r\r" as text, not as - // return presses); a slow trailing pump re-sends return as a recovery for a - // key that landed before its step's input handler subscribed. - await tick(50); - stdin.write("DemoApp"); - // One return per step, plus one to enter the model field: - // name → type → provider → model → review → submit. - for (let press = 0; press < 5; press++) { - await tick(50); - stdin.write("\r"); - } - await waitFor( - () => { - if (created.length === 0) stdin.write("\r"); - return created.length > 0; - }, - 5000, - 150, - ); + await waitForText(r.lastFrame, "name your project"); + await r.write("DemoApp"); + await r.press("return"); + await waitForText(r.lastFrame, "what should the project be built around?"); + await r.press("return"); + await waitForText(r.lastFrame, "choose a model"); + await r.press("return"); + await r.press("return"); + await waitForText(r.lastFrame, "this project will be created"); + await r.press("return"); - // exit(error) rejects waitUntilExit, so the error takes the normal CLI - // path and the process exits nonzero. - const error = await caught; - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("disk full"); - }, 10000); + await waitForText(r.lastFrame, "too long for Windows"); + expect(r.lastFrame()).toContain("Create the project in a shorter directory."); + expect(r.lastFrame()).not.toContain("--skip-install"); + expect(r.lastFrame()).toContain("[r] retry"); + expect(await readdir(deep)).toEqual([]); + r.unmount(); + }); }); describe("project create dispatch", () => { diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index f889a2f70..53f1c51d3 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -1,5 +1,6 @@ import z from "zod"; -import { createHandler, flag } from "../../../router"; +import { createHandler, flag, PlatformKey } from "../../../router"; +import { assertProjectPathFits } from "./pathLimit"; import { SourceResolver, type AppIO } from "../../../io"; import { runWithProgress } from "../../../tui/progress"; import { @@ -195,6 +196,11 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = if (name === undefined) { throw new InputValidationError("required option '--name ' not specified"); } + if (!flags["skip-install"]) { + assertProjectPathFits(name, ctx.require(PlatformKey), { + alternative: "pass --skip-install and install the CDK dependencies yourself", + }); + } const presentRuntimeFlags: string[] = RUNTIME_PATH_FLAGS.filter( (f) => flags[f] !== undefined, @@ -305,7 +311,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = }); config.io.stderr.write(`Created project '${name}' in ./${name}\n`); - config.io.stderr.write(`To deploy it: cd ${name} && agentcore project deploy\n`); + config.io.stderr.write(`Next steps:\n cd ${name}\n agentcore project deploy\n`); }, }); diff --git a/src/handlers/project/create/pathLimit.test.ts b/src/handlers/project/create/pathLimit.test.ts new file mode 100644 index 000000000..187583bb7 --- /dev/null +++ b/src/handlers/project/create/pathLimit.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test"; +import { InputValidationError } from "../../../errors"; +import { assertProjectPathFits } from "./pathLimit"; + +const deep = "C:\\Users\\a\\OneDrive - Company\\" + "nested\\".repeat(20); + +test.each([ + ["win32", "C:\\Users\\a", "Demo", false], + ["win32", deep, "Demo", true], + ["darwin", deep, "Demo", false], +] as const)("on %s under %s creating %s throws: %s", (platform, cwd, name, throws) => { + const check = () => assertProjectPathFits(name, platform, { cwd }); + if (throws) expect(check).toThrow(InputValidationError); + else expect(check).not.toThrow(); +}); diff --git a/src/handlers/project/create/pathLimit.ts b/src/handlers/project/create/pathLimit.ts new file mode 100644 index 000000000..5d3fc26fe --- /dev/null +++ b/src/handlers/project/create/pathLimit.ts @@ -0,0 +1,25 @@ +import { join } from "node:path"; +import { InputValidationError } from "../../../errors"; + +const MAX_WINDOWS_PROJECT_PATH = 150; + +/** + Windows caps paths at 260 characters unless long paths are enabled, and npm + install under the CDK app needs about 100 of them, so a deep project root + fails half way through scaffolding. Refusing up front leaves nothing behind. + `alternative` names a way out the caller offers besides a shorter directory. +**/ +export function assertProjectPathFits( + name: string, + platform: NodeJS.Platform, + { cwd = process.cwd(), alternative }: { cwd?: string; alternative?: string } = {}, +): void { + const destination = join(cwd, name); + if (platform !== "win32" || destination.length <= MAX_WINDOWS_PROJECT_PATH) return; + const remedy = alternative ? `, or ${alternative}` : ""; + throw new InputValidationError( + `project path is too long for Windows (${destination.length} characters): npm install under ` + + `agentcore/cdk would exceed the 260 character MAX_PATH. Create the project in a shorter ` + + `directory${remedy}.`, + ); +} diff --git a/src/handlers/project/create/screen.tsx b/src/handlers/project/create/screen.tsx index 3550eeecd..db4bd9ba9 100644 --- a/src/handlers/project/create/screen.tsx +++ b/src/handlers/project/create/screen.tsx @@ -5,6 +5,8 @@ import { useNavigate } from "react-router"; import { ProjectNameSchema } from "../../../projectSchemas/project"; import type { HarnessModelProvider } from "../../../projectSchemas/harness"; import type { ScreenProps } from "../../types"; +import { PlatformKey } from "../../../router"; +import { assertProjectPathFits } from "./pathLimit"; import type { CreateProjectInput } from "../types"; import { RUNTIME_TEMPLATE_SHORTCUTS, @@ -14,6 +16,7 @@ import { } from "../shortcuts"; import { HARNESS_DEFAULT_MODEL_IDS, resolveScaffoldHarnessInput } from "./index"; import { Layout } from "../../../components/Layout"; +import { ErrorPanel } from "../../../components/ErrorPanel"; import { FormTextInput } from "../../../components/FormTextInput"; import { FormRadioGroup, type FormRadioOption } from "../../../components/FormRadioGroup"; import { KeyValueTable } from "../../../components/KeyValueTable"; @@ -22,7 +25,7 @@ import { Spinner } from "../../../components/ui/spinner"; import { TaskList, type Task } from "../../../components/ui/task-list"; import { Divider } from "../../../components/ui/divider"; import { driveProgress } from "../../../tui/progress"; -import { darkTheme } from "../../../components/ui/_core.js"; +import { darkTheme, glyphs } from "../../../components/ui/_core.js"; const theme = darkTheme; @@ -251,7 +254,7 @@ type WizardPhase = // core.projectManager.create with the same input the flag-driven handler // builds, so both entry points scaffold identical projects — in the current // working directory, npm install and git init included. -export function ProjectCreateScreen({ core }: ScreenProps) { +export function ProjectCreateScreen({ ctx, core }: ScreenProps) { const navigate = useNavigate(); const { exit } = useApp(); @@ -293,12 +296,14 @@ export function ProjectCreateScreen({ core }: ScreenProps) { const submit = async () => { let input: CreateProjectInput; try { + assertProjectPathFits(values.name, ctx.require(PlatformKey)); input = buildCreateInput(values); } catch (error) { setPhase({ kind: "error", error: toError(error) }); return; } setPhase({ kind: "running" }); + setTasks([]); try { await driveProgress(core.projectManager.create(input), setTasks); setPhase({ kind: "success" }); @@ -308,7 +313,10 @@ export function ProjectCreateScreen({ core }: ScreenProps) { }; return ( - + {phase.kind === "form" && ( <> @@ -339,7 +347,13 @@ export function ProjectCreateScreen({ core }: ScreenProps) { {phase.kind === "success" && ( exit()} /> )} - {phase.kind === "error" && } + {phase.kind === "error" && ( + setPhase({ kind: "form" })} + /> + )} )} @@ -351,13 +365,24 @@ function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } -function hintsFor(stepKey: string, phase: WizardPhase): { key: string; label: string }[] { - if (phase.kind === "running") return [{ key: "ctl+c", label: "quit" }]; +// A retry is only offered while nothing has been written yet: once a step has +// run, the scaffolded directory exists and re-submitting would fail on it. +function hintsFor( + stepKey: string, + phase: WizardPhase, + retryable: boolean, +): { key: string; label: string }[] { + if (phase.kind === "running") return [{ key: "ctrl+c", label: "quit" }]; if (phase.kind === "success") return [{ key: "enter", label: "exit" }]; - if (phase.kind === "error") return [{ key: "ctl+c", label: "quit" }]; + if (phase.kind === "error") + return [ + ...(retryable ? [{ key: "r", label: "retry" }] : []), + { key: "esc", label: "back" }, + { key: "ctrl+c", label: "quit" }, + ]; const base = [ { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]; switch (stepKey) { case "name": @@ -795,7 +820,7 @@ function SuccessPanel({ name, onContinue }: { name: string; onContinue: () => vo return ( - ✔ project created in ./{name} + {glyphs.check} project created in ./{name} next steps @@ -806,17 +831,3 @@ function SuccessPanel({ name, onContinue }: { name: string; onContinue: () => vo ); } - -// ErrorPanel reports the failure and tears the TUI down through the same -// exit(error) pattern the not-implemented project stubs use: exit(error) -// rejects the waitUntilExit() that renderTuiAt awaits, so the error takes the -// normal CLI path and the process exits nonzero. -function ErrorPanel({ error }: { error: Error }) { - const { exit } = useApp(); - - useEffect(() => { - exit(error); - }, [exit, error]); - - return ✗ {error.message}; -} diff --git a/src/handlers/project/deploy/screen.tsx b/src/handlers/project/deploy/screen.tsx index 8d3c55bcc..9f64098b0 100644 --- a/src/handlers/project/deploy/screen.tsx +++ b/src/handlers/project/deploy/screen.tsx @@ -87,7 +87,7 @@ function DeployTarget({ { key: "↑↓", label: "navigate" }, { key: "enter", label: "select" }, { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > { expect(frame).toContain(directory); expect(frame).toContain("agentcore project create"); expect(frame).not.toContain("Resolving project"); - // esc is a way off the error, not just ctl+c. + // esc is a way off the error, not just ctrl+c. await screen.press("escape"); await waitForText(screen.lastFrame, "manage an AgentCore project"); }); diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index c4a0246e4..428ce5852 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -5,6 +5,7 @@ import { Layout } from "../../../components/Layout"; import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; import { DataTable, type DataTableColumn } from "../../../components/ui/data-table"; import { Spinner } from "../../../components/ui/spinner"; +import { glyphs } from "../../../components/ui/_core.js"; import { ProjectKey, type Context } from "../../../router"; import { HarnessChat } from "../../harness/invoke/screen"; import { RegionKey } from "../../keys"; @@ -162,10 +163,12 @@ function ProjectInvokePicker({ description="unable to load deployed resources" keyHints={[ { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > - ✗ {error} + + {glyphs.cross} {error} + ); } @@ -177,7 +180,7 @@ function ProjectInvokePicker({ description="resolving deployed resources" keyHints={[ { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > @@ -194,7 +197,7 @@ function ProjectInvokePicker({ { key: "/", label: "filter" }, { key: "enter", label: "select" }, { key: "esc", label: "cancel" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ]} > diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index f053cbebe..a666c3fad 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1,6 +1,6 @@ import { afterEach, test, expect, describe } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createRootHandler } from "../index"; @@ -13,13 +13,17 @@ import { import { InputValidationError } from "../../errors"; import type { BedrockAgentImportPlan } from "../../core/project/bedrockAgentImport"; -async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: string }) { +async function run( + args: string[], + opts?: { core?: TestCoreClient; stdin?: string; platform?: NodeJS.Platform }, +) { const io = testIO({ stdin: opts?.stdin }); const core = opts?.core ?? new TestCoreClient(); const root = createRootHandler(core, { io: io.io, globalConfigAccessor: new TestGlobalConfigAccessor(), logger: createSilentLogger(), + platform: opts?.platform, }); await root.route(["node", "agentcore", "project", ...args]); return { io, core }; @@ -100,6 +104,20 @@ describe("project create", () => { expect(io.stderr()).toContain("Creating a harness project"); }); + test("refuses a project root that would exceed MAX_PATH on Windows, leaving nothing behind", async () => { + const deep = join(await inTempDirectory(), "n".repeat(120)); + await mkdir(deep); + process.chdir(deep); + + await expect(run(["create", "--name", "Deep"], { platform: "win32" })).rejects.toThrow( + /too long for Windows/, + ); + expect(await readdir(deep)).toEqual([]); + + await run(["create", "--name", "Deep", "--skip-install", "--skip-git"], { platform: "win32" }); + expect(await readdir(deep)).toEqual(["Deep"]); + }); + test("rejects the removed --defaults flag", async () => { await inTempDirectory(); await expect(run(["create", "--name", "MyAgent", "--defaults"])).rejects.toThrow( @@ -375,6 +393,7 @@ describe("project create", () => { expect(io.stderr()).toContain("Syncing Python dependencies with uv"); expect(io.stderr()).toContain("Initializing git repository"); expect(io.stderr()).toContain("Created project 'MyAgent' in ./MyAgent"); + expect(io.stderr()).toContain("Next steps:\n cd MyAgent\n agentcore project deploy"); }); test("--skip-install and --skip-git run no commands", async () => { diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index ef54af689..612ae5958 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -221,7 +221,7 @@ describe("project remove", () => { expect((await projectSpec(projectRoot)).credentials).toEqual([]); expect(await Bun.file(envPath).text()).not.toContain(envKey); expect(io.stderr()).toContain(`removed '${envKey}' from ${ENV_LOCAL_RELATIVE_PATH}`); - expect(io.stdout()).toContain("removed credential with name 'svc-key' from project"); + expect(io.stderr()).toContain("removed credential with name 'svc-key' from project"); }); test("removing a secret-reference credential leaves .env.local alone", async () => { @@ -536,7 +536,16 @@ describe("project remove all", () => { expect(existsSync(join(projectRoot, "app", "agent_python"))).toBe(true); expect(await Bun.file(envPath).text()).not.toContain(envKey); expect(io.stderr()).toContain(`removed '${envKey}' from ${ENV_LOCAL_RELATIVE_PATH}`); - expect(io.stdout()).toContain("removed all resources from project"); + expect(io.stderr()).toContain("removed all resources from project"); + expect(io.stdout()).toBe(""); + }); + + test("reports the removal as JSON under --json", async () => { + await populatedProject(); + + const { io } = await run(["remove", "all", "--yes", "--json"]); + + expect(JSON.parse(io.stdout())).toEqual({ message: "removed all resources from project" }); }); test("prompts on a TTY and proceeds on 'y'", async () => { diff --git a/src/handlers/project/remove/index.ts b/src/handlers/project/remove/index.ts index 5c0a49f79..b515b80b6 100644 --- a/src/handlers/project/remove/index.ts +++ b/src/handlers/project/remove/index.ts @@ -5,6 +5,7 @@ import z from "zod"; import type { AppIO } from "../../../io"; import { ENV_LOCAL_RELATIVE_PATH } from "../../../core/project/envLocal"; import { JsonKey } from "../../keys"; +import { reportMessage } from "../../utils"; import type { ProjectManager } from "../types"; type RemoveProjectResourceConfig = { @@ -82,7 +83,7 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) await confirmRemoveAll(config.io, ctx.require(JsonKey), flags.yes, project.name); const result = await config.projectManager.removeAllResources(project); reportEnvCleanup(config.io, result.removedEnvKeys); - config.io.stdout.write(`removed all resources from project`); + reportMessage(ctx, config.io, "removed all resources from project"); return; } @@ -122,7 +123,7 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) } reportEnvCleanup(config.io, result.removedEnvKeys); - config.io.stdout.write(`removed ${resource} with name '${name}' from project`); + reportMessage(ctx, config.io, `removed ${resource} with name '${name}' from project`); }, }); diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index a642a84e8..c28eaabdc 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -344,7 +344,7 @@ describe("Runtime invoke JSON console", () => { await screen.resize(80, 24); expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); expect(screen.lastFrame()).toContain( - "[enter] send [⇧↵] newline [ctl+t] target [↑↓] scroll [esc] back", + "[enter] send [⇧↵] newline [ctrl+t] target [↑↓] scroll [esc] back", ); await screen.resize(60, 24); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 6c7d123f9..e40815e64 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -12,7 +12,7 @@ import { Layout } from "../../../components/Layout"; import { MultilineInput } from "../../../components/MultilineInput"; import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; import { RuntimePicker } from "../../../components/RuntimePicker"; -import { darkTheme } from "../../../components/ui/_core.js"; +import { darkTheme, glyphs } from "../../../components/ui/_core.js"; import { Divider } from "../../../components/ui/divider"; import { Spinner } from "../../../components/ui/spinner"; import type { RuntimeInvokeResponse } from "../types"; @@ -384,22 +384,22 @@ export function RuntimeInvokeConsole({ ? [ { key: "esc", label: "interrupt" }, { key: "↑↓", label: "scroll" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] : [ { key: "enter", label: "send" }, ...(canPrettyJson ? [ { - key: "ctl+v", + key: "ctrl+v", label: prettyJson ? "raw JSON" : "pretty JSON", }, ] - : [{ key: "⇧↵", label: "newline" }]), - { key: "ctl+t", label: "target" }, + : [{ key: `${glyphs.shift}${glyphs.enter}`, label: "newline" }]), + { key: "ctrl+t", label: "target" }, { key: "↑↓", label: "scroll" }, { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, + { key: "ctrl+c", label: "quit" }, ] } > diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index a87ec614e..92c325797 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,9 +1,10 @@ import type { Context } from "../router"; import type z from "zod"; import type { CoreOptions } from "../core/types"; +import type { AppIO } from "../io"; import { AgentCoreCLIError, InputValidationError, SilentCLIError } from "../errors"; import { formatZodError } from "../router/schema"; -import { EndpointKey, RegionKey } from "./keys"; +import { EndpointKey, JsonKey, RegionKey } from "./keys"; import { JsonRendererKey } from "../tui"; // coreOptsFromCtx builds the standard CoreOptions handed to Core operations from @@ -135,3 +136,10 @@ export function renderJsonError(ctx: Context, error: unknown): void { if (cliError instanceof SilentCLIError) return; ctx.require(JsonRendererKey).renderJson({ error: cliError.message }); } + +// reportMessage is the success-side twin: the human line goes to stderr and, +// under --json, the same message becomes the machine-readable result on stdout. +export function reportMessage(ctx: Context, io: AppIO, message: string): void { + io.stderr.write(`${message}\n`); + if (ctx.require(JsonKey)) ctx.require(JsonRendererKey).renderJson({ message }); +} diff --git a/src/io/exec.test.ts b/src/io/exec.test.ts index d4ab96454..787f96473 100644 --- a/src/io/exec.test.ts +++ b/src/io/exec.test.ts @@ -2,15 +2,17 @@ import { afterAll, describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { + cmdSpawnArgs, MissingToolError, ProcessFailedError, requireTool, runProcess, streamProcess, toolAvailable, + windowsCommandScript, type ProcessEvent, } from "./exec"; @@ -67,7 +69,7 @@ describe("runProcess", () => { const promise = runProcess(["node", failing], { cwd: process.cwd() }); await expect(promise).rejects.toBeInstanceOf(ProcessFailedError); - await expect(promise).rejects.toThrow(/exit code 3/); + await expect(promise).rejects.toThrow(/failed in .*\(exit code 3\)/); await expect(promise).rejects.toThrow(/boom/); }); @@ -256,3 +258,56 @@ function processRunning(pid: number): boolean { return false; } } + +describe("windowsCommandScript", () => { + const PATHEXT = ".COM;.EXE;.BAT;.CMD"; + + test.each([ + ["npm", "npm.CMD", "npm.CMD"], + ["npm.cmd", "npm.cmd", "npm.cmd"], + ["uv", "uv.EXE", undefined], + ["missing", "other.CMD", undefined], + ])("resolves %s on PATH", async (executable, present, expected) => { + const dir = await mkdtemp(join(scriptsDir, "path-")); + await writeFile(join(dir, present), ""); + const found = windowsCommandScript(executable, { PATH: dir, PATHEXT }); + expect(found).toBe(expected === undefined ? undefined : join(dir, expected)); + }); + + test("takes the first PATH entry that matches", async () => { + const first = await mkdtemp(join(scriptsDir, "first-")); + const second = await mkdtemp(join(scriptsDir, "second-")); + await writeFile(join(first, "tool.EXE"), ""); + await writeFile(join(second, "tool.CMD"), ""); + const PATH = [first, second].join(delimiter); + expect(windowsCommandScript("tool", { PATH, PATHEXT })).toBeUndefined(); + }); +}); + +describe("cmdSpawnArgs", () => { + test("quotes each argument and escapes cmd.exe metacharacters", () => { + const { file, args } = cmdSpawnArgs("C:\\Program Files\\nodejs\\npm.cmd", [ + "run", + "cdk", + "--", + "synth", + "--output", + "C:\\Users\\a\\My Agents (x)\\cdk.out", + 'import m; print("A=" + m.x)', + ]); + expect(file).toMatch(/cmd\.exe$/i); + expect(args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(args[3]).toBe( + '"C:\\Program^ Files\\nodejs\\npm.cmd ^"run^" ^"cdk^" ^"--^" ^"synth^" ^"--output^" ' + + '^"C:\\Users\\a\\My^ Agents^ ^(x^)\\cdk.out^" ' + + '^"import^ m^;^ print^(\\^"A=\\^"^ +^ m.x^)^""', + ); + }); + + test.each([ + [['a\\"b', "trail\\"], '"x.cmd ^"a\\\\\\^"b^" ^"trail\\\\^""'], + [['a\\\\"b', "trail\\\\"], '"x.cmd ^"a\\\\\\\\\\^"b^" ^"trail\\\\\\\\^""'], + ])("doubles every backslash run before a quote and at the end: %j", (input, expected) => { + expect(cmdSpawnArgs("x.cmd", input).args[3]).toBe(expected); + }); +}); diff --git a/src/io/exec.ts b/src/io/exec.ts index 4496975df..d43bd3ca9 100644 --- a/src/io/exec.ts +++ b/src/io/exec.ts @@ -1,14 +1,18 @@ // Local subprocess execution. Uses node:child_process (not Bun.$/Bun.spawn) // because the npm bundle targets Node — Bun APIs are unavailable there. -import { type ChildProcess, execFileSync, spawn } from "node:child_process"; +import { type ChildProcess, execFileSync, spawn, type SpawnOptions } from "node:child_process"; +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; import { createInterface } from "node:readline"; import { AgentCoreCLIError, ERROR_SOURCE } from "../errors"; -// cmd.exe resolves PATHEXT executables (npm.cmd, uv.exe) that a bare spawn misses. -const useShell = process.platform === "win32"; +const isWindows = process.platform === "win32"; const KILL_GRACE_MS = 2000; const MAX_ERROR_OUTPUT_LINES = 20; +const CMD_META = /([()\][%!^"`<>&|;, *?])/g; +const COMMAND_SCRIPT = /\.(cmd|bat)$/i; + /** Error raised when a required executable is not found on PATH. */ export class MissingToolError extends AgentCoreCLIError { constructor(tool: string, installHint: string) { @@ -22,20 +26,61 @@ export class MissingToolError extends AgentCoreCLIError { /** Error raised when a subprocess exits non-zero, carrying its captured output. */ export class ProcessFailedError extends AgentCoreCLIError { constructor(command: string[], cwd: string, exitCode: number | null, output: string) { - const rendered = command.join(" "); super( - `'${rendered}' failed in ${cwd} (exit code ${exitCode ?? "unknown"}).\n\n` + - `${output.trim()}\n\n` + - `Fix the issue and run 'cd ${cwd} && ${rendered}' to retry.`, + `'${command.join(" ")}' failed in ${cwd} (exit code ${exitCode ?? "unknown"}).\n\n${output.trim()}`, { source: ERROR_SOURCE.USER, meta: { command, cwd, exitCode } }, ); } } +/** + Windows cannot spawn .cmd/.bat wrappers such as npm.cmd directly. This finds the + script cmd.exe would run for a bare name (PATH then PATHEXT order), or undefined + when the match is a real executable that spawns on its own. +**/ +export function windowsCommandScript( + executable: string, + env: NodeJS.ProcessEnv, +): string | undefined { + const extensions = (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean); + const lower = executable.toLowerCase(); + const names = extensions.some((ext) => lower.endsWith(ext.toLowerCase())) + ? [executable] + : extensions.map((ext) => executable + ext); + for (const dir of (env.PATH || "").split(delimiter)) { + if (!dir) continue; + for (const name of names) { + const candidate = join(dir, name); + if (existsSync(candidate)) return COMMAND_SCRIPT.test(candidate) ? candidate : undefined; + } + } + return undefined; +} + +// Quoting follows https://qntm.org/cmd: C runtime rules for the argument, then +// every cmd.exe metacharacter escaped with ^ because /s strips the outer quotes. +function escapeArgument(arg: string): string { + const quoted = `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`; + return quoted.replace(CMD_META, "^$1"); +} + +// Single escape: a % inside an argument would still be expanded by the batch file. +export function cmdSpawnArgs(script: string, args: string[]): { file: string; args: string[] } { + const commandLine = [script.replace(CMD_META, "^$1"), ...args.map(escapeArgument)].join(" "); + return { file: process.env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", `"${commandLine}"`] }; +} + +function spawnCommand(executable: string, args: string[], options: SpawnOptions): ChildProcess { + const script = isWindows ? windowsCommandScript(executable, process.env) : undefined; + if (!script) return spawn(executable, args, options); + const resolved = cmdSpawnArgs(script, args); + return spawn(resolved.file, resolved.args, { ...options, windowsVerbatimArguments: true }); +} + /** Returns true if running `tool` with probeArgs (`--version` by default) exits 0. */ export function toolAvailable(tool: string, probeArgs: string[] = ["--version"]): Promise { return new Promise((resolve) => { - const child = spawn(tool, probeArgs, { stdio: "ignore", shell: useShell }); + const child = spawnCommand(tool, probeArgs, { stdio: "ignore" }); child.on("error", () => resolve(false)); child.on("close", (exitCode) => resolve(exitCode === 0)); }); @@ -53,6 +98,7 @@ export async function requireTool( export type RunProcessOptions = { /** Working directory the process runs in. */ cwd: string; + env?: NodeJS.ProcessEnv; /** Receives each chunk of combined stdout/stderr as it streams (e.g. into a logger). */ onOutput?: (chunk: string) => void; /** Terminates the process and rejects when aborted, so callers can cancel a slow run. */ @@ -70,8 +116,6 @@ export type StreamProcessOptions = { signal?: AbortSignal; /** Command rendered in errors when the actual arguments contain sensitive values. */ redactedCommand?: string[]; - /** Required on Windows for command scripts such as npm.cmd. */ - shell?: boolean; }; export type ProcessStreamer = ( @@ -83,16 +127,19 @@ export type ProcessStreamer = ( * Runs a subprocess, streaming combined stdout/stderr to `onOutput` while also * capturing it; rejects with {@link ProcessFailedError} on a non-zero exit. */ -export const runProcess: ProcessRunner = ([executable, ...args], { cwd, onOutput, signal }) => { +export const runProcess: ProcessRunner = ( + [executable, ...args], + { cwd, env, onOutput, signal }, +) => { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(abortReason(signal)); return; } - const child = spawn(executable!, args, { + const child = spawnCommand(executable!, args, { cwd, + env, stdio: ["ignore", "pipe", "pipe"], - shell: useShell, }); let output = ""; @@ -101,8 +148,8 @@ export const runProcess: ProcessRunner = ([executable, ...args], { cwd, onOutput output += text; onOutput?.(text); }; - child.stdout.on("data", collect); - child.stderr.on("data", collect); + child.stdout!.on("data", collect); + child.stderr!.on("data", collect); const onAbort = () => killTree(child, "SIGTERM"); signal?.addEventListener("abort", onAbort, { once: true }); @@ -137,12 +184,11 @@ export async function* streamProcess( let child: ChildProcess; try { - child = spawn(executable, args, { + child = spawnCommand(executable, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], - shell: options.shell ?? false, - detached: !useShell, + detached: !isWindows, }); } catch (error) { throw new ProcessFailedError(errorCommand, options.cwd, null, String(error)); @@ -249,7 +295,7 @@ function abortReason(signal: AbortSignal): unknown { function killTree(child: ChildProcess, signal: NodeJS.Signals): void { if (!child.pid) return; try { - if (useShell) { + if (isWindows) { execFileSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); } else { process.kill(-child.pid, signal); @@ -260,7 +306,7 @@ function killTree(child: ChildProcess, signal: NodeJS.Signals): void { } function processTreeAlive(child: ChildProcess): boolean { - if (useShell || !child.pid) return false; + if (isWindows || !child.pid) return false; try { process.kill(-child.pid, 0); return true; diff --git a/src/middleware/index.tsx b/src/middleware/index.tsx index 4838ad322..9a87c9d68 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -3,4 +3,5 @@ export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs"; export { withJsonRenderer } from "./withJsonRenderer"; export { withLogging } from "./withLogging"; export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; +export { withPlatform } from "./withPlatform"; export { withProject } from "./withProject"; diff --git a/src/middleware/withPlatform.test.ts b/src/middleware/withPlatform.test.ts new file mode 100644 index 000000000..dca99df5f --- /dev/null +++ b/src/middleware/withPlatform.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { createHandler, PlatformKey, Router } from "../router"; +import { withPlatform } from "./withPlatform"; + +test("pins the configured platform on the context", async () => { + let seen: NodeJS.Platform | undefined; + const app = new Router("myapp").use(withPlatform("win32")); + app.handler( + createHandler({ + name: "whoami", + description: "", + handle: async (ctx) => { + seen = ctx.require(PlatformKey); + }, + }), + ); + + await app.route(["node", "myapp", "whoami"]); + + expect(seen).toBe("win32"); +}); diff --git a/src/middleware/withPlatform.tsx b/src/middleware/withPlatform.tsx new file mode 100644 index 000000000..a83ece7b3 --- /dev/null +++ b/src/middleware/withPlatform.tsx @@ -0,0 +1,20 @@ +import { PlatformKey, type Middleware } from "../router"; + +/** + * Middleware that pins the host platform on the context so handlers make + * Windows-specific decisions from `ctx.require(PlatformKey)` rather than + * `process.platform`, which lets tests drive those branches on any host. + */ +export function withPlatform(platform: NodeJS.Platform): Middleware { + return (h) => ({ + name: () => h.name(), + description: () => h.description(), + flags: () => h.flags(), + arguments: () => h.arguments(), + doesSupportTui: () => h.doesSupportTui(), + children: () => h.children(), + handle: async (ctx, flags, args) => { + await h.handle(ctx.withValue(PlatformKey, platform), flags, args); + }, + }); +} diff --git a/src/middleware/withRegion.tsx b/src/middleware/withRegion.tsx index dfa83eae0..3a38a0cee 100644 --- a/src/middleware/withRegion.tsx +++ b/src/middleware/withRegion.tsx @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { RegionKey } from "../handlers/keys"; +import { readTextFile } from "../io"; import { type Middleware } from "../router"; // DEFAULT_REGION is the final fallback when no region is configured anywhere. @@ -14,7 +15,7 @@ async function regionFromConfigFile(): Promise { const path = process.env.AWS_CONFIG_FILE || join(homedir(), ".aws", "config"); let text: string; try { - text = await Bun.file(path).text(); + text = await readTextFile(path); } catch { return undefined; // no config file } diff --git a/src/router/index.tsx b/src/router/index.tsx index 9f4ed3625..f1c6b78a4 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -4,6 +4,7 @@ export { CommandKey, PathKey, LoggerKey, + PlatformKey, GlobalConfigAccessorKey, CommandRunMetricEventKey, ProjectKey, diff --git a/src/router/router.test.ts b/src/router/router.test.ts index ced99b7d3..7071d68ef 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -170,6 +170,18 @@ test("the root's default handler runs when invoked with no subcommand", async () expect(ran).toBe(true); }); +test("a stray positional on a group with a default handler is an unknown command", async () => { + const root = new Router("app").default(async () => {}); + root.handler(leaf("harness", () => {})); + root.handler(leaf("runtime", () => {})); + + await expect(root.route(["node", "app", "projekt", "status"])).rejects.toThrow( + new InputValidationError( + "unknown command 'projekt' for 'app'. Available commands: harness, runtime", + ), + ); +}); + test("middleware wraps the default handler ancestor-first", async () => { const log: string[] = []; diff --git a/src/router/router.tsx b/src/router/router.tsx index 5b7f41e86..d8b238365 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -5,6 +5,7 @@ import { applyGlobalFlags, formatParameterDetails, parseFlags, toOption } from " import { parseArguments, toCommanderArgument } from "./args"; import { Command, CommanderError } from "commander"; +import { InputValidationError } from "../errors"; import type { Logger } from "../logging"; import type { GlobalConfigAccessor } from "../globalConfig"; import type { Project } from "../handlers/project/types"; @@ -16,6 +17,7 @@ export const CommandKey: ContextKey = contextKey("commander.co export const PathKey: ContextKey = contextKey("path"); export const LoggerKey = contextKey("logger"); +export const PlatformKey = contextKey("platform"); export const CommandRunMetricEventKey = contextKey>("commandRunMetricEvent"); @@ -120,6 +122,15 @@ function attachAction( recordCommandPath(ctx); + // A group's default action also receives positionals that matched no child. + // Those are typos, not arguments, so they are named before the default runs. + if (command.commands.length > 0 && command.args.length > 0) { + const names = command.commands.map((child) => child.name()).join(", "); + throw new InputValidationError( + `unknown command '${command.args[0]}' for '${command.name()}'. Available commands: ${names}`, + ); + } + // Inherited group/global flags -> context (typed, read via ctx.value(key)). let leafCtx = ctx.withValue(CommandKey, command); leafCtx = applyGlobalFlags(globals, merged, leafCtx); @@ -214,6 +225,7 @@ export function compile( // has no own flags/arguments (globals-only). const fallback = isDefaultHandlerProvider(node) ? node.defaultHandler() : undefined; if (fallback) { + c.allowExcessArguments(); attachAction( c, withEffectiveTuiSupport(fallback, effectiveTuiSupport && fallback.doesSupportTui()), diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index d8aa079ca..dafede8c6 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -1,6 +1,6 @@ import { render, cleanup } from "ink-testing-library"; import { QueryClient } from "@tanstack/react-query"; -import { ValueContext, compile, CommandKey, type Context } from "../router"; +import { ValueContext, compile, CommandKey, PlatformKey, type Context } from "../router"; import { RegionKey, JsonKey, DebugKey, EndpointKey } from "../handlers/keys"; import { JsonRendererKey } from "../tui"; import { createRootHandler } from "../handlers"; @@ -27,7 +27,11 @@ import { TestGlobalConfigAccessor } from "./globalConfig"; // RouterScreen walks it to resolve each menu's subcommands), the global flags // (region/json/debug), and a no-op JsonRenderer. Compiling the real handler tree // keeps the command menus faithful to the production command structure. -function baseContext(core: TestCoreClient, endpointUrl?: string): Context { +function baseContext( + core: TestCoreClient, + endpointUrl?: string, + platform: NodeJS.Platform = process.platform, +): Context { const rootCommand = compile( createRootHandler(core, { io: testIO().io, @@ -40,6 +44,7 @@ function baseContext(core: TestCoreClient, endpointUrl?: string): Context { return ValueContext.EmptyContext() .withValue(CommandKey, rootCommand) .withValue(RegionKey, "us-east-1") + .withValue(PlatformKey, platform) .withValue(EndpointKey, endpointUrl) .withValue(JsonKey, false) .withValue(DebugKey, false) @@ -67,6 +72,8 @@ export interface RenderScreenOptions { // exercise cache behavior. queryClient?: QueryClient; endpointUrl?: string; + // platform pins PlatformKey, so a screen's Windows-only branch can run on any host. + platform?: NodeJS.Platform; } export interface RenderScreenResult { @@ -124,7 +131,7 @@ export function cleanupScreens(): void { // and returns handles to read frames and send input. export function renderScreen(path: string, options: RenderScreenOptions = {}): RenderScreenResult { const core = options.core ?? new TestCoreClient(); - const base = options.ctx ?? baseContext(core, options.endpointUrl); + const base = options.ctx ?? baseContext(core, options.endpointUrl, options.platform); const ctx = options.withContext?.(base) ?? base; const queryClient = options.queryClient ?? testQueryClient(); diff --git a/src/testing/setup.ts b/src/testing/setup.ts index 372320f48..3aec3b873 100644 --- a/src/testing/setup.ts +++ b/src/testing/setup.ts @@ -12,3 +12,8 @@ // is registered as a Bun test preload in bunfig.toml, which runs before test // modules load (supports-color reads FORCE_COLOR at import time). process.env.FORCE_COLOR = "0"; + +// The glyph table (components/ui/_core.ts) picks Unicode or ASCII from the +// terminal env at import time. Pin the Windows Terminal marker so frame +// assertions on ❯ ✗ ↵ hold on a plain conhost dev box as well. +process.env.WT_SESSION ??= "bun-test";