diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 65dbcddb1..9856b64b4 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -25,6 +25,7 @@ import { buildCdkProject, checkBootstrapNeeded, checkStackDeployability, + ensureDefaultDeploymentTarget, getAllCredentials, hasIdentityApiProviders, hasIdentityOAuthProviders, @@ -117,8 +118,13 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise t.name === options.target); if (!target) { diff --git a/src/cli/commands/deploy/progress.ts b/src/cli/commands/deploy/progress.ts index e222de52f..da7eb7b3c 100644 --- a/src/cli/commands/deploy/progress.ts +++ b/src/cli/commands/deploy/progress.ts @@ -1,7 +1,7 @@ import { ConfigIO } from '../../../lib'; -import { detectAwsContext } from '../../aws/aws-context'; import { ANSI } from '../../constants'; import { getErrorMessage } from '../../errors'; +import { ensureDefaultDeploymentTarget } from '../../operations/deploy'; import { canSkipDeploy } from '../../operations/deploy/change-detection'; import { handleDeploy } from './actions'; @@ -48,27 +48,10 @@ export async function runCliDeploy(): Promise { const { onProgress, cleanup } = createSpinnerProgress(); try { - // Auto-populate aws-targets.json if empty + // Auto-populate aws-targets.json if empty (best-effort). handleDeploy also + // does this, but we run it here first so canSkipDeploy sees a populated target. const configIO = new ConfigIO(); - try { - const targets = await configIO.readAWSDeploymentTargets(); - if (targets.length === 0) { - const ctx = await detectAwsContext(); - if (ctx.accountId) { - await configIO.writeAWSDeploymentTargets([{ name: 'default', account: ctx.accountId, region: ctx.region }]); - } - } - } catch { - // aws-targets.json doesn't exist — try to create it - try { - const ctx = await detectAwsContext(); - if (ctx.accountId) { - await configIO.writeAWSDeploymentTargets([{ name: 'default', account: ctx.accountId, region: ctx.region }]); - } - } catch { - // Can't detect — let handleDeploy fail with a clear error - } - } + await ensureDefaultDeploymentTarget(configIO); const noChanges = await canSkipDeploy(configIO); if (noChanges) { diff --git a/src/cli/operations/deploy/__tests__/ensure-target.test.ts b/src/cli/operations/deploy/__tests__/ensure-target.test.ts new file mode 100644 index 000000000..6910698e1 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/ensure-target.test.ts @@ -0,0 +1,91 @@ +import { type ConfigIO, ConfigNotFoundError } from '../../../../lib'; +import { ensureDefaultDeploymentTarget } from '../ensure-target.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockDetectAwsContext } = vi.hoisted(() => ({ + mockDetectAwsContext: vi.fn(), +})); + +vi.mock('../../../aws', () => ({ + detectAwsContext: mockDetectAwsContext, +})); + +/** Build a fake ConfigIO with stubbed read/write target methods. */ +function makeConfigIO(opts: { read?: () => Promise }): { configIO: ConfigIO; writes: unknown[] } { + const writes: unknown[] = []; + const configIO = { + readAWSDeploymentTargets: opts.read ?? (() => Promise.resolve([])), + writeAWSDeploymentTargets: (data: unknown) => { + writes.push(data); + return Promise.resolve(); + }, + } as unknown as ConfigIO; + return { configIO, writes }; +} + +describe('ensureDefaultDeploymentTarget', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDetectAwsContext.mockResolvedValue({ accountId: '123456789012', region: 'us-east-1', regionSource: 'env' }); + }); + + it('writes a default target when aws-targets.json is empty', async () => { + const { configIO, writes } = makeConfigIO({ read: () => Promise.resolve([]) }); + + const wrote = await ensureDefaultDeploymentTarget(configIO); + + expect(wrote).toBe(true); + expect(writes).toHaveLength(1); + expect(writes[0]).toEqual([{ name: 'default', account: '123456789012', region: 'us-east-1' }]); + }); + + it('does not overwrite when a target already exists', async () => { + const existing = [{ name: 'prod', account: '999888777666', region: 'us-west-2' }]; + const { configIO, writes } = makeConfigIO({ read: () => Promise.resolve(existing) }); + + const wrote = await ensureDefaultDeploymentTarget(configIO); + + expect(wrote).toBe(false); + expect(writes).toHaveLength(0); + expect(mockDetectAwsContext).not.toHaveBeenCalled(); + }); + + it('treats a missing targets file (ConfigNotFoundError) as empty and populates it', async () => { + const { configIO, writes } = makeConfigIO({ + read: () => Promise.reject(new ConfigNotFoundError('aws-targets.json', 'AWS Targets')), + }); + + const wrote = await ensureDefaultDeploymentTarget(configIO); + + expect(wrote).toBe(true); + expect(writes[0]).toEqual([{ name: 'default', account: '123456789012', region: 'us-east-1' }]); + }); + + it('surfaces a non-not-found read error instead of overwriting the file', async () => { + const { configIO, writes } = makeConfigIO({ + read: () => Promise.reject(new Error('Unexpected end of JSON input')), + }); + + await expect(ensureDefaultDeploymentTarget(configIO)).rejects.toThrow('Unexpected end of JSON input'); + expect(writes).toHaveLength(0); + }); + + it('does not write when the AWS account cannot be detected', async () => { + mockDetectAwsContext.mockResolvedValue({ accountId: null, region: 'us-east-1', regionSource: 'default' }); + const { configIO, writes } = makeConfigIO({ read: () => Promise.resolve([]) }); + + const wrote = await ensureDefaultDeploymentTarget(configIO); + + expect(wrote).toBe(false); + expect(writes).toHaveLength(0); + }); + + it('uses the detected region for the default target', async () => { + mockDetectAwsContext.mockResolvedValue({ accountId: '123456789012', region: 'eu-west-1', regionSource: 'config' }); + const { configIO, writes } = makeConfigIO({ read: () => Promise.resolve([]) }); + + await ensureDefaultDeploymentTarget(configIO); + + expect(writes[0]).toEqual([{ name: 'default', account: '123456789012', region: 'eu-west-1' }]); + }); +}); diff --git a/src/cli/operations/deploy/ensure-target.ts b/src/cli/operations/deploy/ensure-target.ts new file mode 100644 index 000000000..b07fe9c78 --- /dev/null +++ b/src/cli/operations/deploy/ensure-target.ts @@ -0,0 +1,47 @@ +import { type ConfigIO, ConfigNotFoundError } from '../../../lib'; +import { detectAwsContext } from '../../aws'; + +/** + * Ensure `aws-targets.json` has at least one deployment target. + * + * Freshly-created projects (via `agentcore create`, interactive or not) write an + * empty `aws-targets.json` by design — the target is expected to be populated at + * deploy time. The interactive deploy flow prompts the user for it, but the + * non-interactive deploy path (`deploy --yes` / `--json` / `--target`) has no + * prompt, so it would otherwise fail with `Target "default" not found`. + * + * This mirrors the auto-populate behavior already used by `agentcore dev`: if no + * targets exist, detect the account/region from the environment and write a + * single `default` target. Best-effort — if the account can't be detected, the + * file is left as-is and the caller surfaces a clear "target not found" error. + * + * A missing `aws-targets.json` is treated as empty. Any other read failure + * (corrupt JSON, validation error, permissions) is surfaced rather than silently + * overwriting a file that exists but couldn't be parsed. + * + * @returns true if a default target was written, false otherwise. + */ +export async function ensureDefaultDeploymentTarget(configIO: ConfigIO): Promise { + let targets; + try { + targets = await configIO.readAWSDeploymentTargets(); + } catch (err) { + // Only treat a genuinely-missing file as empty; surface real read errors. + if (!(err instanceof ConfigNotFoundError)) { + throw err; + } + targets = []; + } + + if (targets.length > 0) { + return false; + } + + const ctx = await detectAwsContext(); + if (!ctx.accountId) { + return false; + } + + await configIO.writeAWSDeploymentTargets([{ name: 'default', account: ctx.accountId, region: ctx.region }]); + return true; +} diff --git a/src/cli/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index 93cdb6353..db6d841eb 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -60,6 +60,8 @@ export { type OnlineEvalEnableResult, } from './post-deploy-online-evals'; +export { ensureDefaultDeploymentTarget } from './ensure-target'; + // Post-deploy config bundles export { setupConfigBundles, diff --git a/src/cli/tui/hooks/useDevDeploy.ts b/src/cli/tui/hooks/useDevDeploy.ts index 893706e3f..a570676f4 100644 --- a/src/cli/tui/hooks/useDevDeploy.ts +++ b/src/cli/tui/hooks/useDevDeploy.ts @@ -1,8 +1,8 @@ import { ConfigIO } from '../../../lib'; -import { detectAwsContext } from '../../aws/aws-context'; import type { DeployMessage } from '../../cdk/toolkit-lib'; import { handleDeploy } from '../../commands/deploy/actions'; import { getErrorMessage } from '../../errors'; +import { ensureDefaultDeploymentTarget } from '../../operations/deploy'; import { canSkipDeploy } from '../../operations/deploy/change-detection'; import type { Step } from '../components/StepProgress'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -62,29 +62,9 @@ export function useDevDeploy({ skip, ready = true }: UseDevDeployOptions = {}): // If we can't read project spec, proceed with deploy as a safe default } - // Auto-populate aws-targets.json if empty - try { - const targets = await configIO.readAWSDeploymentTargets(); - if (targets.length === 0) { - const ctx = await detectAwsContext(); - if (ctx.accountId) { - await configIO.writeAWSDeploymentTargets([ - { name: 'default', account: ctx.accountId, region: ctx.region }, - ]); - } - } - } catch { - try { - const ctx = await detectAwsContext(); - if (ctx.accountId) { - await configIO.writeAWSDeploymentTargets([ - { name: 'default', account: ctx.accountId, region: ctx.region }, - ]); - } - } catch { - // Can't detect — let handleDeploy fail with a clear error - } - } + // Auto-populate aws-targets.json if empty (best-effort). handleDeploy also + // does this, but we run it here first so canSkipDeploy sees a populated target. + await ensureDefaultDeploymentTarget(configIO); const noChanges = await canSkipDeploy(configIO); if (noChanges) {