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
8 changes: 7 additions & 1 deletion src/cli/commands/deploy/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
buildCdkProject,
checkBootstrapNeeded,
checkStackDeployability,
ensureDefaultDeploymentTarget,
getAllCredentials,
hasIdentityApiProviders,
hasIdentityOAuthProviders,
Expand Down Expand Up @@ -117,8 +118,13 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
try {
const configIO = new ConfigIO();

// Load targets and find the specified one
// Load targets and find the specified one.
// Freshly-created projects have an empty aws-targets.json (populated at deploy
// time). The interactive flow prompts for the target; for non-interactive
// deploys (`--yes`/`--json`/`--target`) auto-populate a default from the
// detected AWS context so deploy doesn't fail with "target not found".
startStep('Load deployment target');
await ensureDefaultDeploymentTarget(configIO);
const targets = await configIO.resolveAWSDeploymentTargets();
const target = targets.find(t => t.name === options.target);
if (!target) {
Expand Down
25 changes: 4 additions & 21 deletions src/cli/commands/deploy/progress.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -48,27 +48,10 @@ export async function runCliDeploy(): Promise<void> {
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) {
Expand Down
91 changes: 91 additions & 0 deletions src/cli/operations/deploy/__tests__/ensure-target.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> }): { 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' }]);
});
});
47 changes: 47 additions & 0 deletions src/cli/operations/deploy/ensure-target.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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 = [];

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.

nit: can we just return false here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 0a64ed8 — the catch now rethrows non-ConfigNotFoundError errors and only falls through to targets = [] for the missing-file case, so the structure is cleaner.

}

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;
}
2 changes: 2 additions & 0 deletions src/cli/operations/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export {
type OnlineEvalEnableResult,
} from './post-deploy-online-evals';

export { ensureDefaultDeploymentTarget } from './ensure-target';

// Post-deploy config bundles
export {
setupConfigBundles,
Expand Down
28 changes: 4 additions & 24 deletions src/cli/tui/hooks/useDevDeploy.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
Loading