Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions src/handlers/project/status/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
Expand All @@ -12,6 +12,7 @@ import {
waitFor,
} from "../../../testing";
import type { ProjectBackend } from "../../../core/project";
import { ProjectStateError } from "../../../errors";
import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets";
import type { ResolvedProjectResource } from "../types";

Expand Down Expand Up @@ -74,6 +75,18 @@ afterEach(async () => {
);
});

// The report is refused when the ambient region is not the target's, so pin
// the ambient region to the default target's rather than leave it to the
// developer's shell (see withRegion for the fallback chain).
const SAVED_AWS_REGION = process.env.AWS_REGION;
beforeEach(() => {
process.env.AWS_REGION = DEFAULT_TARGET.region;
});
afterEach(() => {
if (SAVED_AWS_REGION === undefined) delete process.env.AWS_REGION;
else process.env.AWS_REGION = SAVED_AWS_REGION;
});

async function inProject(
subject: ReturnType<typeof testStatusCommand>,
spec: Record<string, unknown> = {},
Expand Down Expand Up @@ -223,7 +236,7 @@ describe("project status handler", () => {
const subject = testStatusCommand([]);
await inProject(subject);

await subject.run(["--target", "staging"]);
await subject.run(["--region", STAGING_TARGET.region, "--target", "staging"]);

expect(subject.targets).toEqual([STAGING_TARGET]);
expect(subject.json()).toMatchObject({ target: "staging", region: "eu-west-1" });
Expand All @@ -232,6 +245,21 @@ describe("project status handler", () => {
/has no deployment target named 'typo'/,
);
});

test("refuses a target deployed outside the ambient region", async () => {
const subject = testStatusCommand([HARNESS_ROW]);
await inProject(subject);

// The ambient region is the default target's (pinned above); staging's is not.
const outcome = subject.run(["--target", "staging"]);
await expect(outcome).rejects.toBeInstanceOf(ProjectStateError);
await expect(outcome).rejects.toThrow("This project is deployed to eu-west-1, not us-east-1");
// An explicit --region takes part in the same comparison.
await expect(subject.run(["--region", "us-west-2"])).rejects.toThrow(
"This project is deployed to us-east-1, not us-west-2",
);
expect(subject.io.stdout()).toBe("");
});
});

describe("project status dispatch", () => {
Expand Down
11 changes: 11 additions & 0 deletions src/handlers/project/status/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets";
import { createHandler, flag, ProjectKey } from "../../../router";
import { JsonRendererKey } from "../../../tui";
import type { ProjectManager, ResolvedProjectResource } from "../types";
import { RegionKey } from "../../keys";
import { ProjectStateError } from "../../../errors";

type StatusProjectHandlerConfig = {
projectManager: ProjectManager;
Expand Down Expand Up @@ -38,6 +40,15 @@ export const createStatusProjectHandler = (config: StatusProjectHandlerConfig) =
region: resolved.target.region,
resources: resolved.resources,
};

// Every follow-up command runs in the ambient region, so a report for a
// project deployed elsewhere would print ids the user cannot act on as-is.
// Refuse it and name the region to rerun with.
const region = ctx.require(RegionKey);
if (status.region !== region) {
throw new ProjectStateError(`This project is deployed to ${status.region}, not ${region}`);
}

ctx.require(JsonRendererKey).renderJson(status);
},
});
42 changes: 30 additions & 12 deletions src/handlers/project/status/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ProjectKey } from "../../../router";
import type { ScreenProps } from "../../types";
import type { DeployableResource, Project, ResolvedProjectResource } from "../types";
import { LoadingFrame, ProjectGate } from "../ProjectGate";
import { RegionKey } from "../../keys";

const theme = darkTheme;

Expand Down Expand Up @@ -168,15 +169,16 @@ export function ProjectStatusScreen({ ctx, core }: ScreenProps) {
seed={ctx.value(ProjectKey)}
onBack={() => navigate(PROJECT_MENU)}
>
{(project) => <ProjectStatusView core={core} project={project} />}
{(project) => <ProjectStatusView core={core} ctx={ctx} project={project} />}
</ProjectGate>
);
}

function ProjectStatusView({
core,
ctx,
project,
}: Pick<ScreenProps, "core"> & {
}: ScreenProps & {
project: Project;
}) {
const navigate = useNavigate();
Expand Down Expand Up @@ -224,6 +226,12 @@ function ProjectStatusView({
setHint(node.data?.hint);
};

// A linked detail page fetches in the target's region, but everything it
// opens in turn runs in the ambient one, so a project deployed elsewhere is
// reported rather than listed: the user reopens with --region.
const region = ctx.require(RegionKey);
const isWrongRegion = status.data.target.region !== region;

return (
<Layout
breadcrumb={BREADCRUMB}
Expand All @@ -237,16 +245,26 @@ function ProjectStatusView({
]}
>
<Box flexDirection="column" paddingX={1}>
<Text bold>resources</Text>
<Box flexDirection="column">
{nodes.length === 0 ? (
<Text color={theme.colors.muted}>
No resources are declared in this project. Run `agentcore project add` to declare one.
</Text>
) : (
<TreeView nodes={nodes} onSelect={select} showIcons={false} focusMarker />
)}
</Box>
{isWrongRegion && (
<Text color="red">
This project is deployed to {status.data.target.region}, not {region}
</Text>
)}
{!isWrongRegion && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we also throw ProjectStateError here

<>
<Text bold>resources</Text>
<Box flexDirection="column">
{nodes.length === 0 ? (
<Text color={theme.colors.muted}>
No resources are declared in this project. Run `agentcore project add` to declare
one.
</Text>
) : (
<TreeView nodes={nodes} onSelect={select} showIcons={false} focusMarker />
)}
</Box>
</>
)}
{hint !== undefined && (
<Box marginTop={1}>
<Text color={theme.colors.muted}>{hint}</Text>
Expand Down
33 changes: 28 additions & 5 deletions src/handlers/project/status/status.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { ProjectKey } from "../../../router";
import { RegionKey } from "../../keys";
import {
cleanupScreens,
flatFrame,
Expand All @@ -30,9 +31,10 @@ afterEach(async () => {
);
});

// The target region differs from the base context's us-east-1 on purpose: the
// detail screens must fetch where the project deployed, not in the ambient
// region.
// The target region differs from the base context's us-east-1 on purpose, so
// the detail screens are linked with the region the project deployed in.
// renderStatus pins the ambient region to the target's, since the screen only
// lists a project deployed there; the mismatch case has a test of its own.
const TARGET = { name: "default", account: "111122223333", region: "eu-west-1" } as const;
const ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}`;
const RUNTIME_ID = "checkout-AbCdEf1234";
Expand Down Expand Up @@ -100,10 +102,14 @@ function core(resources: ResolvedProjectResource[] = RUNTIME_RESOURCES): TestCor
return value;
}

function renderStatus(value: TestCoreClient, seed: Project = RUNTIME_PROJECT) {
function renderStatus(
value: TestCoreClient,
seed: Project = RUNTIME_PROJECT,
region: string = TARGET.region,
) {
return renderScreen("/agentcore/project/status", {
core: value,
withContext: (ctx) => ctx.withValue(ProjectKey, seed),
withContext: (ctx) => ctx.withValue(ProjectKey, seed).withValue(RegionKey, region),
});
}

Expand Down Expand Up @@ -277,6 +283,23 @@ describe("project status screen", () => {
await waitForText(screen.lastFrame, "No resources are declared in this project.");
});

test("reports a project deployed outside the ambient region instead of listing it", async () => {
const screen = renderStatus(core(), RUNTIME_PROJECT, "us-east-1");

await waitForFlatText(
screen.lastFrame,
`This project is deployed to ${TARGET.region}, not us-east-1`,
);
const frame = flatFrame(screen.lastFrame);
expect(frame).not.toContain("checkout agent");
expect(frame).not.toMatch(/runtime\s+checkout/);
// Nothing is focusable, so enter goes nowhere and escape still leaves.
await screen.press("return");
expect(screen.lastFrame()).toContain("agentcore → project → status");
await screen.press("escape");
await waitForText(screen.lastFrame, "manage an AgentCore project");
});

test("reports the CLI's own guidance outside a project", async () => {
const directory = await mkdtemp(join(tmpdir(), "agentcore-status-no-project-"));
tempDirectories.push(directory);
Expand Down
Loading