From c28ea6718bb354de102630d886e0a4f6f6691c19 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 25 Jun 2026 06:13:49 +0000 Subject: [PATCH 1/4] fix(unit-only): scope TUI deploy to picker-selected targets (#1267) The TUI multi-select target picker records the user's choice in selectedTargetIndices, but nothing downstream consumed it: useDeployFlow called cdkToolkitWrapper.deploy() with no stacks selector, so toolkit-lib defaulted to ALL_STACKS and deployed every synthesized stack (one per configured target). A user who picked a single target silently deployed infrastructure to every configured account/region. Thread selectedTargets from DeployScreen through useDeployFlow and scope the deploy with StackSelectionStrategy.PATTERN_MUST_MATCH over toStackName(project, target), mirroring CLI mode. Also fix the header to show the picked target(s) rather than the full configured list. Refs aws/agentcore-cli#1267 --- src/cli/tui/screens/deploy/DeployScreen.tsx | 13 +- .../__tests__/useDeployFlow.targets.test.tsx | 217 ++++++++++++++++++ src/cli/tui/screens/deploy/useDeployFlow.ts | 34 ++- 3 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 1c7a9c65e..e033bb9fa 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -59,6 +59,12 @@ export function DeployScreen({ }: DeployScreenProps) { const { stdout } = useStdout(); const awsConfig = useAwsTargetConfig(); + // Targets the user picked in the multi-select. Drives both the header and the deploy scope so a + // single-target selection no longer deploys to every configured account/region (issue #1267). + const selectedTargets = useMemo( + () => awsConfig.selectedTargetIndices.map(i => awsConfig.availableTargets[i]).filter(t => t !== undefined), + [awsConfig.selectedTargetIndices, awsConfig.availableTargets] + ); const [showInvoke, setShowInvoke] = useState(false); const [showResourceGraph, setShowResourceGraph] = useState(false); const [showDiff, setShowDiff] = useState(diffMode ?? false); @@ -98,7 +104,7 @@ export function DeployScreen({ useEnvLocalCredentials, useManualCredentials, skipCredentials, - } = useDeployFlow({ preSynthesized, isInteractive, diffMode }); + } = useDeployFlow({ preSynthesized, isInteractive, diffMode, selectedTargets }); const allSuccess = !hasError && isComplete; const skipPreflight = !!preSynthesized; @@ -276,7 +282,10 @@ export function DeployScreen({ ); } - const targetDisplay = context?.awsTargets.map(t => `${t.region}:${t.account}`).join(', '); + // Show the target(s) the user actually picked, not the full configured list. Fall back to the + // resolved context targets for the plan path (no picker) or when nothing was selected. + const displayTargets = selectedTargets.length > 0 ? selectedTargets : (context?.awsTargets ?? []); + const targetDisplay = context && displayTargets.map(t => `${t.region}:${t.account}`).join(', '); // Show deploy status box once CloudFormation has started (after asset publishing) const showDeployStatus = !diffMode && (hasStartedCfn || isComplete); diff --git a/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx b/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx new file mode 100644 index 000000000..3be14e49b --- /dev/null +++ b/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx @@ -0,0 +1,217 @@ +/** + * Regression test for issue #1267. + * + * In the TUI deploy flow a multi-target project synthesizes one stack per configured target. + * The deploy was calling `cdkToolkitWrapper.deploy()` with no stacks selector, which the wrapper + * forwards as `{ stacks: undefined }` — toolkit-lib defaults that to ALL_STACKS and provisions + * infrastructure to EVERY configured account/region, ignoring the picker selection. + * + * These tests render `useDeployFlow` with a mocked preflight + CDK wrapper and assert the deploy + * is scoped to ONLY the selected target's stack name via PATTERN_MUST_MATCH. + */ +import { toStackName } from '../../../../commands/import/import-utils'; +import { useDeployFlow } from '../useDeployFlow'; +import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ---- Module mocks ----------------------------------------------------------- + +const deploySpy = vi.fn().mockResolvedValue(undefined); +const diffSpy = vi.fn().mockResolvedValue(undefined); +const disposeSpy = vi.fn().mockResolvedValue(undefined); + +const fakeWrapper = { + deploy: deploySpy, + diff: diffSpy, + dispose: disposeSpy, +}; + +// switchableIoHost: noop setters so the deploy effect can wire callbacks without crashing. +const fakeIoHost = { + setOnRawMessage: vi.fn(), + setOnMessage: vi.fn(), + setVerbose: vi.fn(), +}; + +// preflightState is mutated per-test before render so the same mock can vary phase/context. +let preflightState: any; + +vi.mock('../../../hooks', async () => { + const actual = await vi.importActual('../../../hooks'); + return { + ...actual, + useCdkPreflight: () => preflightState, + }; +}); + +// Telemetry wrapper: just run the deploy closure so deploy() actually fires. +vi.mock('../../../../telemetry/cli-command-run.js', () => ({ + withCommandRunTelemetry: async (_cmd: string, _attrs: unknown, fn: () => Promise) => fn(), +})); + +// persistDeployedState polls getStackOutputs; make it throw fast so the (caught) post-deploy path +// resolves immediately instead of polling for 15s. The deploy() call has already happened by then. +vi.mock('../../../../cloudformation', async () => { + const actual = await vi.importActual('../../../../cloudformation'); + return { + ...actual, + getStackOutputs: vi.fn().mockRejectedValue(new Error('test: skip persist')), + }; +}); + +// Managed-memory notice does a config read; short-circuit it. +vi.mock('../../../../operations/deploy', async () => { + const actual = await vi.importActual('../../../../operations/deploy'); + return { + ...actual, + hasManagedMemoryHarness: vi.fn().mockResolvedValue(false), + setupTransactionSearch: vi.fn().mockResolvedValue({ success: true }), + }; +}); + +// ---- Fixtures --------------------------------------------------------------- + +const TARGET_A = { name: 'prod-east', account: '111111111111', region: 'us-east-1' as const }; +const TARGET_B = { name: 'prod-west', account: '222222222222', region: 'us-west-2' as const }; +const PROJECT_NAME = 'myproj'; + +function makePreflight(opts: { awsTargets: any[]; projectName?: string }) { + const stackNames = opts.awsTargets.map(t => toStackName(opts.projectName ?? PROJECT_NAME, t.name)); + return { + phase: 'complete', + steps: [], + context: { + projectSpec: { name: opts.projectName ?? PROJECT_NAME, runtimes: [] }, + awsTargets: opts.awsTargets, + isTeardownDeploy: false, + isFirstDeploy: true, // skip the pre-deploy diff branch + }, + cdkToolkitWrapper: fakeWrapper, + stackNames, + switchableIoHost: fakeIoHost, + hasTokenExpiredError: false, + hasCredentialsError: false, + missingCredentials: [], + allCredentials: {}, + identityKmsKeyArn: undefined, + startPreflight: vi.fn().mockResolvedValue(undefined), + confirmTeardown: vi.fn(), + cancelTeardown: vi.fn(), + confirmBootstrap: vi.fn(), + skipBootstrap: vi.fn(), + clearTokenExpiredError: vi.fn(), + clearCredentialsError: vi.fn(), + useEnvLocalCredentials: vi.fn(), + useManualCredentials: vi.fn(), + skipCredentials: vi.fn(), + }; +} + +// Harness component: mounts the hook so its effects fire under ink-testing-library. +function Harness({ selectedTargets }: { selectedTargets?: any[] }) { + useDeployFlow({ isInteractive: true, selectedTargets }); + return null as unknown as React.ReactElement; +} + +async function flush() { + // Let queued microtasks + effect timers settle. + for (let i = 0; i < 10; i += 1) { + await new Promise(resolve => setTimeout(resolve, 5)); + } +} + +// ---- Tests ------------------------------------------------------------------ + +describe('useDeployFlow target scoping (issue #1267)', () => { + beforeEach(() => { + deploySpy.mockClear(); + diffSpy.mockClear(); + disposeSpy.mockClear(); + fakeIoHost.setOnRawMessage.mockClear(); + fakeIoHost.setOnMessage.mockClear(); + fakeIoHost.setVerbose.mockClear(); + }); + afterEach(() => { + vi.clearAllTimers(); + }); + + it('scopes deploy to ONLY the single selected target stack (not ALL_STACKS)', async () => { + preflightState = makePreflight({ awsTargets: [TARGET_A, TARGET_B] }); + + const { unmount } = render(); + await flush(); + unmount(); + + expect(deploySpy).toHaveBeenCalledTimes(1); + const arg = deploySpy.mock.calls[0][0]; + + // Must NOT be called with stacks undefined (the pre-fix behavior → ALL_STACKS). + expect(arg).toBeDefined(); + expect(arg.stacks).toBeDefined(); + expect(arg.stacks.strategy).toBe(StackSelectionStrategy.PATTERN_MUST_MATCH); + expect(arg.stacks.patterns).toEqual([toStackName(PROJECT_NAME, TARGET_B.name)]); + + // Regression: selecting B must never produce a pattern for A. + expect(arg.stacks.patterns).not.toContain(toStackName(PROJECT_NAME, TARGET_A.name)); + }); + + it('selecting target A produces only A’s stack (no cross-leak to B)', async () => { + preflightState = makePreflight({ awsTargets: [TARGET_A, TARGET_B] }); + + const { unmount } = render(); + await flush(); + unmount(); + + expect(deploySpy).toHaveBeenCalledTimes(1); + const arg = deploySpy.mock.calls[0][0]; + expect(arg.stacks.patterns).toEqual([toStackName(PROJECT_NAME, TARGET_A.name)]); + expect(arg.stacks.patterns).not.toContain(toStackName(PROJECT_NAME, TARGET_B.name)); + }); + + it('selecting ALL targets yields a selector covering every target stack', async () => { + preflightState = makePreflight({ awsTargets: [TARGET_A, TARGET_B] }); + + const { unmount } = render(); + await flush(); + unmount(); + + expect(deploySpy).toHaveBeenCalledTimes(1); + const arg = deploySpy.mock.calls[0][0]; + expect(arg.stacks.strategy).toBe(StackSelectionStrategy.PATTERN_MUST_MATCH); + expect(arg.stacks.patterns).toEqual([ + toStackName(PROJECT_NAME, TARGET_A.name), + toStackName(PROJECT_NAME, TARGET_B.name), + ]); + }); + + it('single-target project still deploys (unscoped → the lone synthesized stack)', async () => { + preflightState = makePreflight({ awsTargets: [TARGET_A] }); + + // Single-target picker selects the one target. + const { unmount } = render(); + await flush(); + unmount(); + + expect(deploySpy).toHaveBeenCalledTimes(1); + const arg = deploySpy.mock.calls[0][0]; + // Either a pattern selector for the single stack, or undefined (assembly's lone stack) — both + // deploy exactly one stack. Must never resolve to a broader set. + if (arg.stacks) { + expect(arg.stacks.patterns).toEqual([toStackName(PROJECT_NAME, TARGET_A.name)]); + } + }); + + it('no picker selection falls back to the unscoped assembly (undefined selector)', async () => { + preflightState = makePreflight({ awsTargets: [TARGET_A, TARGET_B] }); + + const { unmount } = render(); + await flush(); + unmount(); + + expect(deploySpy).toHaveBeenCalledTimes(1); + const arg = deploySpy.mock.calls[0][0]; + expect(arg.stacks).toBeUndefined(); + }); +}); diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 9b2ebd719..6527b40b3 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -1,5 +1,7 @@ import { ConfigIO } from '../../../../lib'; +import type { AwsDeploymentTarget } from '../../../../schema'; import type { CdkToolkitWrapper, DeployMessage, SwitchableIoHost } from '../../../cdk/toolkit-lib'; +import { toStackName } from '../../../commands/import/import-utils'; import { buildDeployedState, getStackOutputs, @@ -43,6 +45,7 @@ import { parseStackDiff, } from '../../components'; import { type MissingCredential, type PreflightContext, useCdkPreflight } from '../../hooks'; +import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; type DeployPhase = @@ -75,6 +78,13 @@ interface DeployFlowOptions { isInteractive?: boolean; /** Run CDK diff instead of deploy */ diffMode?: boolean; + /** + * Targets the user chose in the multi-select picker. The vended CDK app synthesizes one stack + * per configured target, so without scoping the deploy runs against ALL_STACKS (every target). + * When set, the deploy is restricted to these targets' stacks. Empty/undefined falls back to the + * full assembly (single-target projects, and the pre-synthesized plan path). + */ + selectedTargets?: AwsDeploymentTarget[]; } interface DeployFlowState { @@ -130,7 +140,7 @@ interface DeployFlowState { } export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState { - const { preSynthesized, isInteractive = false, diffMode = false } = options; + const { preSynthesized, isInteractive = false, diffMode = false, selectedTargets } = options; const skipPreflight = !!preSynthesized; // Create logger once for the entire deploy flow @@ -147,6 +157,21 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const identityKmsKeyArn = preSynthesized?.identityKmsKeyArn ?? preflight.identityKmsKeyArn; const allCredentials = preSynthesized?.allCredentials ?? preflight.allCredentials; + // Scope the deploy to the picker's selected targets. The vended CDK app synthesizes one stack + // per target, so an unscoped deploy resolves to ALL_STACKS and provisions every configured + // account/region — even the ones the user did not pick (see issue #1267). Mirrors CLI mode + // (commands/deploy/actions.ts), which patterns deploy() by toStackName(project, target). + // Skipped on the pre-synthesized plan path, which already targets a single stack. + const deployStacks = useMemo(() => { + if (skipPreflight) return undefined; + const projectName = context?.projectSpec.name; + if (!projectName || !selectedTargets || selectedTargets.length === 0) return undefined; + return { + strategy: StackSelectionStrategy.PATTERN_MUST_MATCH, + patterns: selectedTargets.map(t => toStackName(projectName, t.name)), + }; + }, [skipPreflight, context?.projectSpec.name, selectedTargets]); + const [preDeployDiffStep, setPreDeployDiffStep] = useState({ label: 'Computing diff changes...', status: 'pending', @@ -779,8 +804,10 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState try { // Run deploy - toolkit-lib handles CloudFormation orchestration - // Output goes to stdout via the switchable ioHost - await cdkToolkitWrapper.deploy(); + // Output goes to stdout via the switchable ioHost. + // deployStacks restricts the deploy to the picker's selected targets; undefined (single + // target or plan path) lets the assembly's lone stack deploy as before. + await cdkToolkitWrapper.deploy({ stacks: deployStacks }); // CDK deploy itself is done. Mark "Deploy to AWS" success and let post-deploy // phases (persist, hydrate KBs, auto-ingest, dataset sync, online evals, @@ -934,6 +961,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState context?.awsTargets, context?.projectSpec.runtimes, diffMode, + deployStacks, ]); // Start diff when preflight completes (diff mode only) From 6aedcc7d1be9e9dd4b35cda0d1e57df1caf0cdd0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 29 Jun 2026 14:36:22 +0000 Subject: [PATCH 2/4] fix(deploy): scope TUI deploy to the picker-selected targets (#1267) From 52db94f3bf4934e3628b67e123a9211ce7057ff4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 29 Jun 2026 14:43:39 +0000 Subject: [PATCH 3/4] fix(deploy): resolve format and typecheck failures in target-scoping test --- .../deploy/__tests__/useDeployFlow.targets.test.tsx | 10 +++++----- src/cli/tui/screens/deploy/useDeployFlow.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx b/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx index 3be14e49b..d83d195e5 100644 --- a/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx +++ b/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx @@ -145,7 +145,7 @@ describe('useDeployFlow target scoping (issue #1267)', () => { unmount(); expect(deploySpy).toHaveBeenCalledTimes(1); - const arg = deploySpy.mock.calls[0][0]; + const arg = deploySpy.mock.calls[0]![0]; // Must NOT be called with stacks undefined (the pre-fix behavior → ALL_STACKS). expect(arg).toBeDefined(); @@ -165,7 +165,7 @@ describe('useDeployFlow target scoping (issue #1267)', () => { unmount(); expect(deploySpy).toHaveBeenCalledTimes(1); - const arg = deploySpy.mock.calls[0][0]; + const arg = deploySpy.mock.calls[0]![0]; expect(arg.stacks.patterns).toEqual([toStackName(PROJECT_NAME, TARGET_A.name)]); expect(arg.stacks.patterns).not.toContain(toStackName(PROJECT_NAME, TARGET_B.name)); }); @@ -178,7 +178,7 @@ describe('useDeployFlow target scoping (issue #1267)', () => { unmount(); expect(deploySpy).toHaveBeenCalledTimes(1); - const arg = deploySpy.mock.calls[0][0]; + const arg = deploySpy.mock.calls[0]![0]; expect(arg.stacks.strategy).toBe(StackSelectionStrategy.PATTERN_MUST_MATCH); expect(arg.stacks.patterns).toEqual([ toStackName(PROJECT_NAME, TARGET_A.name), @@ -195,7 +195,7 @@ describe('useDeployFlow target scoping (issue #1267)', () => { unmount(); expect(deploySpy).toHaveBeenCalledTimes(1); - const arg = deploySpy.mock.calls[0][0]; + const arg = deploySpy.mock.calls[0]![0]; // Either a pattern selector for the single stack, or undefined (assembly's lone stack) — both // deploy exactly one stack. Must never resolve to a broader set. if (arg.stacks) { @@ -211,7 +211,7 @@ describe('useDeployFlow target scoping (issue #1267)', () => { unmount(); expect(deploySpy).toHaveBeenCalledTimes(1); - const arg = deploySpy.mock.calls[0][0]; + const arg = deploySpy.mock.calls[0]![0]; expect(arg.stacks).toBeUndefined(); }); }); diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 6527b40b3..0ee5d1e1f 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -1,7 +1,6 @@ import { ConfigIO } from '../../../../lib'; import type { AwsDeploymentTarget } from '../../../../schema'; import type { CdkToolkitWrapper, DeployMessage, SwitchableIoHost } from '../../../cdk/toolkit-lib'; -import { toStackName } from '../../../commands/import/import-utils'; import { buildDeployedState, getStackOutputs, @@ -20,6 +19,7 @@ import { parseRuntimeEndpointOutputs, } from '../../../cloudformation'; import { DEFAULT_DEPLOY_ATTRS, computeDeployAttrs } from '../../../commands/deploy/utils.js'; +import { toStackName } from '../../../commands/import/import-utils'; import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from '../../../errors'; import { ExecLogger } from '../../../logging'; import { From a37a9b517b0f6c2473b31beaeed017316c3d9a5c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 29 Jun 2026 14:58:54 +0000 Subject: [PATCH 4/4] fix(deploy): scope post-deploy bookkeeping and diff to the picked target --- .../__tests__/useDeployFlow.targets.test.tsx | 51 +++++++++++++++++-- src/cli/tui/screens/deploy/useDeployFlow.ts | 51 ++++++++++++++----- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx b/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx index d83d195e5..f05ff6ceb 100644 --- a/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx +++ b/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx @@ -35,6 +35,12 @@ const fakeIoHost = { setVerbose: vi.fn(), }; +// Hoisted so the vi.mock factory below (hoisted above module init) can reference it, while the +// test bodies keep a handle to assert the persist path polls the SELECTED target's stack/region. +const { getStackOutputsSpy } = vi.hoisted(() => ({ + getStackOutputsSpy: vi.fn().mockRejectedValue(new Error('test: skip persist')), +})); + // preflightState is mutated per-test before render so the same mock can vary phase/context. let preflightState: any; @@ -57,7 +63,7 @@ vi.mock('../../../../cloudformation', async () => { const actual = await vi.importActual('../../../../cloudformation'); return { ...actual, - getStackOutputs: vi.fn().mockRejectedValue(new Error('test: skip persist')), + getStackOutputs: getStackOutputsSpy, }; }); @@ -77,7 +83,7 @@ const TARGET_A = { name: 'prod-east', account: '111111111111', region: 'us-east- const TARGET_B = { name: 'prod-west', account: '222222222222', region: 'us-west-2' as const }; const PROJECT_NAME = 'myproj'; -function makePreflight(opts: { awsTargets: any[]; projectName?: string }) { +function makePreflight(opts: { awsTargets: any[]; projectName?: string; isFirstDeploy?: boolean }) { const stackNames = opts.awsTargets.map(t => toStackName(opts.projectName ?? PROJECT_NAME, t.name)); return { phase: 'complete', @@ -86,7 +92,8 @@ function makePreflight(opts: { awsTargets: any[]; projectName?: string }) { projectSpec: { name: opts.projectName ?? PROJECT_NAME, runtimes: [] }, awsTargets: opts.awsTargets, isTeardownDeploy: false, - isFirstDeploy: true, // skip the pre-deploy diff branch + // First-deploy skips the pre-deploy diff branch; flip it off to exercise diff scoping. + isFirstDeploy: opts.isFirstDeploy ?? true, }, cdkToolkitWrapper: fakeWrapper, stackNames, @@ -132,6 +139,8 @@ describe('useDeployFlow target scoping (issue #1267)', () => { fakeIoHost.setOnRawMessage.mockClear(); fakeIoHost.setOnMessage.mockClear(); fakeIoHost.setVerbose.mockClear(); + getStackOutputsSpy.mockClear(); + getStackOutputsSpy.mockRejectedValue(new Error('test: skip persist')); }); afterEach(() => { vi.clearAllTimers(); @@ -214,4 +223,40 @@ describe('useDeployFlow target scoping (issue #1267)', () => { const arg = deploySpy.mock.calls[0]![0]; expect(arg.stacks).toBeUndefined(); }); + + it('persist polls the SELECTED target stack/region, not awsTargets[0]/stackNames[0]', async () => { + // [A, B] project, user picks B (the non-first target). The persist step must resolve outputs + // from B's stack in B's region — pre-fix it polled A's stack in A's region (never deployed), + // failing the poll and writing deployed-state under the wrong target name. + preflightState = makePreflight({ awsTargets: [TARGET_A, TARGET_B] }); + + const { unmount } = render(); + await flush(); + unmount(); + + expect(getStackOutputsSpy).toHaveBeenCalled(); + const [region, stackName] = getStackOutputsSpy.mock.calls[0]!; + expect(region).toBe(TARGET_B.region); + expect(stackName).toBe(toStackName(PROJECT_NAME, TARGET_B.name)); + // Regression: must never poll A's (the first configured target's) stack/region. + expect(region).not.toBe(TARGET_A.region); + expect(stackName).not.toBe(toStackName(PROJECT_NAME, TARGET_A.name)); + }); + + it('scopes the pre-deploy diff to the selected target stack (not ALL_STACKS)', async () => { + // isFirstDeploy=false enables the pre-deploy diff branch. Picking B must diff only B's stack. + preflightState = makePreflight({ awsTargets: [TARGET_A, TARGET_B], isFirstDeploy: false }); + + const { unmount } = render(); + await flush(); + unmount(); + + expect(diffSpy).toHaveBeenCalled(); + const diffArg = diffSpy.mock.calls[0]![0]; + expect(diffArg).toBeDefined(); + expect(diffArg.stacks).toBeDefined(); + expect(diffArg.stacks.strategy).toBe(StackSelectionStrategy.PATTERN_MUST_MATCH); + expect(diffArg.stacks.patterns).toEqual([toStackName(PROJECT_NAME, TARGET_B.name)]); + expect(diffArg.stacks.patterns).not.toContain(toStackName(PROJECT_NAME, TARGET_A.name)); + }); }); diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 0ee5d1e1f..173445d90 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -172,6 +172,19 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState }; }, [skipPreflight, context?.projectSpec.name, selectedTargets]); + // The target whose stack the post-deploy bookkeeping (persist state, teardown, transaction + // search) operates on. The deploy is now scoped to the picker selection, so this MUST track the + // deployed target rather than blindly using `awsTargets[0]`: when a multi-target project's user + // picks a non-first target, `awsTargets[0]`/`stackNames[0]` point at a stack that was never + // deployed, so persist would poll the wrong stack and write deployed-state under the wrong target + // (see issue #1267). Falls back to the resolved context target for the plan path (no picker) and + // when nothing was selected. The TUI persists a single target; if the user picked several, we + // bookkeep the first selected one (the same target whose outputs we poll below). + const activeTarget = useMemo( + () => selectedTargets?.[0] ?? context?.awsTargets[0], + [selectedTargets, context?.awsTargets] + ); + const [preDeployDiffStep, setPreDeployDiffStep] = useState({ label: 'Computing diff changes...', status: 'pending', @@ -267,7 +280,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState switchableIoHost?.setVerbose(true); try { - await cdkToolkitWrapper.diff(); + // Scope the diff to the picker selection so it mirrors what will deploy (issue #1267). + await cdkToolkitWrapper.diff({ stacks: deployStacks }); } catch { setDiffSummaries([{ stackName: 'Error', sections: [], hasSecurityChanges: false, totalChanges: 0 }]); } finally { @@ -279,7 +293,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState }; void run(); - }, [cdkToolkitWrapper, diffSummaries.length, switchableIoHost, logger]); + }, [cdkToolkitWrapper, diffSummaries.length, switchableIoHost, logger, deployStacks]); /** * Persist deployed state after successful deployment. @@ -287,8 +301,13 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState */ const persistDeployedState = useCallback(async () => { const ctx = context; - const currentStackName = stackNames[0]; - const target = ctx?.awsTargets[0]; + const target = activeTarget; + // Persist against the deployed target's stack, not stackNames[0]. For a multi-target project + // where the user picked a non-first target, stackNames[0] is a sibling stack that was never + // deployed — polling it would fail and we'd write deployed-state under the wrong target name. + // The plan path (no project name / no picker) still falls back to the lone synthesized stack. + const projectName = ctx?.projectSpec.name; + const currentStackName = projectName && target ? toStackName(projectName, target.name) : stackNames[0]; if (!ctx || !currentStackName || !target) return; @@ -700,7 +719,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (allStatuses.length > 0) { setTargetStatuses(allStatuses); } - }, [context, stackNames, logger, identityKmsKeyArn, allCredentials]); + }, [context, stackNames, activeTarget, logger, identityKmsKeyArn, allCredentials]); // Start deploy when preflight completes OR when shouldStartDeploy is set useEffect(() => { @@ -744,7 +763,9 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState }); switchableIoHost?.setVerbose(true); try { - await cdkToolkitWrapper.diff(); + // Scope the pre-deploy diff to the same stacks the deploy will touch (issue #1267), + // so a single-target pick doesn't diff every configured target's stack. + await cdkToolkitWrapper.diff({ stacks: deployStacks }); } catch { // Diff failure is non-fatal — deploy will proceed } finally { @@ -828,15 +849,15 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // After deploying the empty spec, destroy the stack entirely. // Harnesses are part of the CloudFormation stack, so stack destroy handles them. // Clean up imperative payment credential providers before stack teardown. - const targetName = context.awsTargets[0]?.name; + // Tear down the deployed target (the picker selection), not blindly awsTargets[0]. + const targetName = activeTarget?.name; if (targetName) { try { const configIO = new ConfigIO(); const deployedState = await configIO.readDeployedState(); const existingPayments = deployedState?.targets?.[targetName]?.resources?.payments; - if (existingPayments && Object.keys(existingPayments).length > 0) { - const target = context.awsTargets[0]!; - await cleanupPaymentCredentialProviders({ region: target.region, payments: existingPayments }); + if (existingPayments && Object.keys(existingPayments).length > 0 && activeTarget) { + await cleanupPaymentCredentialProviders({ region: activeTarget.region, payments: existingPayments }); } } catch { // Best-effort: continue with teardown even if credential cleanup fails @@ -866,9 +887,10 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } // Post-deploy: Enable CloudWatch Transaction Search (non-blocking, silent) + // Wire it in the deployed target's account/region (the picker selection), not awsTargets[0]. const agentNames = context?.projectSpec.runtimes?.map((a: { name: string }) => a.name) ?? []; - const targetRegion = context?.awsTargets[0]?.region; - const targetAccount = context?.awsTargets[0]?.account; + const targetRegion = activeTarget?.region; + const targetAccount = activeTarget?.account; const hasGateways = (context?.projectSpec.agentCoreGateways?.length ?? 0) > 0; const hasPythonAgent = context?.projectSpec.runtimes?.some( @@ -960,6 +982,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState context?.isTeardownDeploy, context?.awsTargets, context?.projectSpec.runtimes, + activeTarget, diffMode, deployStacks, ]); @@ -1007,7 +1030,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState switchableIoHost?.setVerbose(true); try { - await cdkToolkitWrapper.diff(); + // Scope the standalone diff to the picker selection (issue #1267). + await cdkToolkitWrapper.diff({ stacks: deployStacks }); logger.endStep('success'); logger.finalize(true); setDiffStep(prev => ({ ...prev, status: 'success' })); @@ -1045,6 +1069,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState skipPreflight, shouldStartDeploy, switchableIoHost, + deployStacks, ]); // Finalize logger and dispose toolkit when preflight fails