diff --git a/src/cli/commands/deploy/__tests__/handleEnvDeploy.flags.test.ts b/src/cli/commands/deploy/__tests__/handleEnvDeploy.flags.test.ts new file mode 100644 index 000000000..7ff8002b3 --- /dev/null +++ b/src/cli/commands/deploy/__tests__/handleEnvDeploy.flags.test.ts @@ -0,0 +1,72 @@ +import type { DeployToTargetsOptions } from '../../../operations/deploy/multi-target'; +import { handleEnvDeploy } from '../actions'; +import { describe, expect, it, vi } from 'vitest'; + +// Capture every call to deployToTargets so we can assert the orchestrator +// observes the parallel / continueOnError flags forwarded by handleEnvDeploy. +const deployToTargetsMock = vi.fn((_targets: unknown, _options: DeployToTargetsOptions, _fn: unknown) => + Promise.resolve({ successes: [], failures: [] }) +); + +vi.mock('../../../operations/deploy/multi-target', () => ({ + deployToTargets: (targets: unknown, options: DeployToTargetsOptions, fn: unknown) => + deployToTargetsMock(targets, options, fn), +})); + +// Stub heavy ConfigIO + project operations so we never touch a real project. +vi.mock('../../../../lib', () => ({ + ConfigIO: class { + readAwsTargetsFull() { + return Promise.resolve({ + targets: [{ name: 'dev-a', account: '111111111111', region: 'us-west-2' }], + environments: { dev: { targets: ['dev-a'] } }, + }); + } + resolveAWSDeploymentTargets() { + return Promise.resolve([{ name: 'dev-a', account: '111111111111', region: 'us-west-2' }]); + } + }, + SecureCredentials: class {}, +})); + +vi.mock('../../../operations/deploy', () => ({ + bootstrapEnvironment: vi.fn(), + buildCdkProject: vi.fn(), + checkBootstrapNeeded: vi.fn(), + checkStackDeployability: vi.fn(), + getAllCredentials: () => [], + hasIdentityApiProviders: () => false, + hasIdentityOAuthProviders: () => false, + performStackTeardown: vi.fn(), + setupApiKeyProviders: vi.fn(), + setupOAuth2Providers: vi.fn(), + setupTransactionSearch: vi.fn(), + synthesizeCdk: vi.fn(), + validateProject: vi.fn(), +})); + +describe('handleEnvDeploy flag forwarding', () => { + it('forwards parallel + continueOnError to deployToTargets', async () => { + deployToTargetsMock.mockClear(); + await handleEnvDeploy({ + env: 'dev', + parallel: true, + continueOnError: true, + onLog: () => undefined, + }); + expect(deployToTargetsMock).toHaveBeenCalledTimes(1); + const opts = deployToTargetsMock.mock.calls[0]![1]; + expect(opts.environmentName).toBe('dev'); + expect(opts.parallel).toBe(true); + expect(opts.continueOnError).toBe(true); + }); + + it('defaults parallel + continueOnError to undefined when not provided', async () => { + deployToTargetsMock.mockClear(); + await handleEnvDeploy({ env: 'dev', onLog: () => undefined }); + expect(deployToTargetsMock).toHaveBeenCalledTimes(1); + const opts = deployToTargetsMock.mock.calls[0]![1]; + expect(opts.parallel).toBeUndefined(); + expect(opts.continueOnError).toBeUndefined(); + }); +}); diff --git a/src/cli/commands/deploy/__tests__/validate.test.ts b/src/cli/commands/deploy/__tests__/validate.test.ts index be490ce21..bb795af18 100644 --- a/src/cli/commands/deploy/__tests__/validate.test.ts +++ b/src/cli/commands/deploy/__tests__/validate.test.ts @@ -2,15 +2,45 @@ import { validateDeployOptions } from '../validate.js'; import { describe, expect, it } from 'vitest'; describe('validateDeployOptions', () => { - it('always returns valid', () => { + it('returns valid with no options', () => { expect(validateDeployOptions({})).toEqual({ valid: true }); }); - it('returns valid with all options set', () => { + it('returns valid with all non-conflicting options set', () => { expect(validateDeployOptions({ target: 'prod', yes: true, verbose: true, json: true })).toEqual({ valid: true }); }); it('returns valid with target only', () => { expect(validateDeployOptions({ target: 'default' })).toEqual({ valid: true }); }); + + it('returns valid with --env only', () => { + expect(validateDeployOptions({ env: 'dev' })).toEqual({ valid: true }); + }); + + it('rejects --env and --target used together with a clear error', () => { + const result = validateDeployOptions({ env: 'dev', target: 'prod' }); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/--env.*--target/); + }); + + it('rejects --parallel without --env', () => { + const result = validateDeployOptions({ parallel: true }); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/--parallel.*--env/); + }); + + it('rejects --continue-on-error without --env', () => { + const result = validateDeployOptions({ continueOnError: true }); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/--continue-on-error.*--env/); + }); + + it('accepts --parallel together with --env', () => { + expect(validateDeployOptions({ env: 'dev', parallel: true })).toEqual({ valid: true }); + }); + + it('accepts --continue-on-error together with --env', () => { + expect(validateDeployOptions({ env: 'dev', continueOnError: true })).toEqual({ valid: true }); + }); }); diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 0669dff44..d0ecdc31a 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,5 +1,11 @@ import { ConfigIO, SecureCredentials } from '../../../lib'; -import type { AgentCoreMcpSpec, DeployedState } from '../../../schema'; +import type { + AgentCoreMcpSpec, + AwsDeploymentTarget, + DeployedState, + EnvironmentOverrides, + Environments, +} from '../../../schema'; import { applyTargetRegionToEnv } from '../../aws'; import { validateAwsCredentials } from '../../aws/account'; import { CdkToolkitWrapper, createSwitchableIoHost } from '../../cdk/toolkit-lib'; @@ -33,7 +39,9 @@ import { synthesizeCdk, validateProject, } from '../../operations/deploy'; +import { mergeOverrides, resolveEnvironment } from '../../operations/deploy/environment'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; +import { deployToTargets } from '../../operations/deploy/multi-target'; import { deleteOrphanedABTests, setupABTests } from '../../operations/deploy/post-deploy-ab-tests'; import { resolveConfigBundleComponentKeys, @@ -51,6 +59,12 @@ export interface ValidatedDeployOptions { verbose?: boolean; plan?: boolean; diff?: boolean; + /** + * Optional env-var overrides applied in-memory to every runtime in the + * loaded project spec just before CDK synth. Set by --env deploys; never + * persisted to disk. + */ + envVarOverrides?: EnvironmentOverrides; onProgress?: (step: string, status: 'start' | 'success' | 'error') => void; onResourceEvent?: (message: string) => void; } @@ -142,6 +156,13 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise mergeOverrides(rt, options.envVarOverrides)), + }; + } endStep('success'); // Teardown confirmation: if this is a teardown deploy, require --yes @@ -705,3 +726,106 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise void; + onResourceEvent?: (message: string) => void; + /** Sink for orchestrator progress + summary lines. Defaults to console.log. */ + onLog?: (line: string) => void; +} + +export interface EnvDeployResult { + success: boolean; + envName: string; + results: DeployResult[]; + /** Set when the env couldn't be resolved (no per-target results available). */ + error?: string; +} + +/** + * Read aws-targets.json supporting both the legacy array shape and the new + * `{ targets, environments }` object shape. Returns environments only when + * the file uses the object shape; otherwise environments is undefined. + */ +/** + * Read aws-targets.json with environments if present. Delegates to + * `ConfigIO.readAwsTargetsFull` and then applies region fallback so the same + * environment/profile precedence used by the rest of the CLI is honored. + */ +async function readAwsTargetsWithEnvironments( + configIO: ConfigIO +): Promise<{ targets: AwsDeploymentTarget[]; environments?: Environments }> { + const full = await configIO.readAwsTargetsFull(); + // Apply the standard region fallback (env vars / profile config) by going + // through resolveAWSDeploymentTargets — its result preserves saved regions + // and fills in only blanks, so we can safely substitute it for the targets + // we just read while keeping the environments map from the object shape. + const resolved = await configIO.resolveAWSDeploymentTargets(); + return full.environments ? { targets: resolved, environments: full.environments } : { targets: resolved }; +} + +/** + * Deploy a named environment: resolve the env to its targets, apply any + * env-level overrides per target, and run them through the multi-target + * orchestrator. Sequential / fail-fast for v1 (T9 adds parallel + continue-on-error). + */ +export async function handleEnvDeploy(options: EnvDeployOptions): Promise { + const configIO = new ConfigIO(); + + let resolved; + try { + const awsTargets = await readAwsTargetsWithEnvironments(configIO); + resolved = resolveEnvironment(options.env, awsTargets); + } catch (err: unknown) { + return { + success: false, + envName: options.env, + results: [], + error: getErrorMessage(err), + }; + } + + const results: DeployResult[] = []; + const aggregate = await deployToTargets( + resolved.targets, + { + environmentName: options.env, + parallel: options.parallel, + continueOnError: options.continueOnError, + log: options.onLog, + }, + async target => { + const result = await handleDeploy({ + target: target.name, + autoConfirm: options.autoConfirm, + verbose: options.verbose, + plan: options.plan, + diff: options.diff, + envVarOverrides: resolved.overrides, + onProgress: options.onProgress, + onResourceEvent: options.onResourceEvent, + }); + results.push(result); + if (!result.success) { + // Throw so the orchestrator records this as a failure (fail-fast). + throw new Error(result.error ?? `Deploy failed for target ${target.name}`); + } + return result; + } + ); + + return { + success: aggregate.failures.length === 0 && results.every(r => r.success), + envName: options.env, + results, + }; +} diff --git a/src/cli/commands/deploy/command.tsx b/src/cli/commands/deploy/command.tsx index 5bc6e96d1..381ca9e92 100644 --- a/src/cli/commands/deploy/command.tsx +++ b/src/cli/commands/deploy/command.tsx @@ -2,7 +2,7 @@ import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; import { DeployScreen } from '../../tui/screens/deploy/DeployScreen'; -import { handleDeploy } from './actions'; +import { handleDeploy, handleEnvDeploy } from './actions'; import type { DeployOptions } from './types'; import { validateDeployOptions } from './validate'; import type { Command } from '@commander-js/extra-typings'; @@ -69,6 +69,34 @@ async function handleDeployCLI(options: DeployOptions): Promise { } : undefined; + if (options.env) { + const envResult = await handleEnvDeploy({ + env: options.env, + autoConfirm: options.yes, + verbose: options.verbose ?? options.diff, + plan: options.plan, + diff: options.diff, + parallel: options.parallel, + continueOnError: options.continueOnError, + onProgress, + onResourceEvent, + onLog: line => console.log(line), + }); + + if (spinner) { + clearInterval(spinner); + process.stdout.write('\r\x1b[K'); + } + + if (options.json) { + console.log(JSON.stringify(envResult)); + } else if (!envResult.success) { + if (envResult.error) console.error(envResult.error); + } + + process.exit(envResult.success ? 0 : 1); + } + const result = await handleDeploy({ target: options.target!, autoConfirm: options.yes, @@ -141,6 +169,12 @@ export const registerDeploy = (program: Command) => { .alias('dp') .description(COMMAND_DESCRIPTIONS.deploy) .option('--target ', 'Deployment target name (default: "default") [non-interactive]') + .option('--env ', 'Environment name to deploy (deploys all targets in the environment) [non-interactive]') + .option('--parallel', 'Deploy environment targets concurrently (requires --env) [non-interactive]') + .option( + '--continue-on-error', + 'Continue deploying remaining targets after a failure (requires --env) [non-interactive]' + ) .option('-y, --yes', 'Auto-confirm prompts, read credentials from env [non-interactive]') .option('-v, --verbose', 'Show resource-level deployment events [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') @@ -149,6 +183,9 @@ export const registerDeploy = (program: Command) => { .action( async (cliOptions: { target?: string; + env?: string; + parallel?: boolean; + continueOnError?: boolean; yes?: boolean; verbose?: boolean; json?: boolean; @@ -157,12 +194,21 @@ export const registerDeploy = (program: Command) => { }) => { try { requireProject(); - if (cliOptions.json || cliOptions.target || cliOptions.dryRun || cliOptions.yes || cliOptions.verbose) { + if ( + cliOptions.json || + cliOptions.target || + cliOptions.env || + cliOptions.parallel || + cliOptions.continueOnError || + cliOptions.dryRun || + cliOptions.yes || + cliOptions.verbose + ) { // CLI mode - any flag triggers non-interactive mode const options = { ...cliOptions, plan: cliOptions.dryRun, - target: cliOptions.target ?? 'default', + target: cliOptions.env ? cliOptions.target : (cliOptions.target ?? 'default'), progress: !cliOptions.json, }; await handleDeployCLI(options as DeployOptions); diff --git a/src/cli/commands/deploy/types.ts b/src/cli/commands/deploy/types.ts index 44cdc7847..dc6217016 100644 --- a/src/cli/commands/deploy/types.ts +++ b/src/cli/commands/deploy/types.ts @@ -1,5 +1,8 @@ export interface DeployOptions { target?: string; + env?: string; + parallel?: boolean; + continueOnError?: boolean; yes?: boolean; progress?: boolean; verbose?: boolean; diff --git a/src/cli/commands/deploy/validate.ts b/src/cli/commands/deploy/validate.ts index db15b6091..7f70c05fd 100644 --- a/src/cli/commands/deploy/validate.ts +++ b/src/cli/commands/deploy/validate.ts @@ -5,7 +5,24 @@ export interface ValidationResult { error?: string; } -export function validateDeployOptions(_options: DeployOptions): ValidationResult { - // Target should always be set (defaulted to 'default' by command handler) +export function validateDeployOptions(options: DeployOptions): ValidationResult { + if (options.env && options.target) { + return { + valid: false, + error: 'Cannot use --env and --target together. Pick one.', + }; + } + if (options.parallel && !options.env) { + return { + valid: false, + error: '--parallel requires --env. Specify an environment to deploy in parallel.', + }; + } + if (options.continueOnError && !options.env) { + return { + valid: false, + error: '--continue-on-error requires --env. Specify an environment to deploy.', + }; + } return { valid: true }; } diff --git a/src/cli/commands/status/__tests__/status-env.test.ts b/src/cli/commands/status/__tests__/status-env.test.ts new file mode 100644 index 000000000..b05b92011 --- /dev/null +++ b/src/cli/commands/status/__tests__/status-env.test.ts @@ -0,0 +1,173 @@ +import type { AwsDeploymentTarget, Environments } from '../../../../schema'; +import { AwsTargetsSchema } from '../../../../schema'; +import { type StackInfoFetcher, handleEnvStatus } from '../action'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const targetA: AwsDeploymentTarget = { + name: 'dev-a', + account: '111111111111', + region: 'us-west-2', +}; +const targetB: AwsDeploymentTarget = { + name: 'dev-b', + account: '222222222222', + region: 'us-east-1', +}; + +function makeFakeConfigIO(awsTargetsPath: string): never { + return { + getPathResolver: () => ({ getAWSTargetsConfigPath: () => awsTargetsPath }), + // Mirror ConfigIO.readAwsTargetsFull's contract just enough for the + // status helper: dual-shape read with no region fallback. + readAwsTargetsFull: async (): Promise<{ targets: AwsDeploymentTarget[]; environments?: Environments }> => { + const raw = await readFile(awsTargetsPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) return { targets: parsed as AwsDeploymentTarget[] }; + const validated = AwsTargetsSchema.parse(parsed); + return validated.environments + ? { targets: validated.targets, environments: validated.environments } + : { targets: validated.targets }; + }, + } as never; +} + +const baseContext = { + project: { name: 'proj' } as never, + awsTargets: [targetA, targetB], + deployedState: { + targets: { + 'dev-a': { resources: { stackName: 'stack-dev-a' } }, + 'dev-b': { resources: { stackName: 'stack-dev-b' } }, + }, + }, +}; + +describe('handleEnvStatus', () => { + let tmpDir: string; + let awsTargetsPath: string; + + beforeEach(async () => { + tmpDir = path.join(os.tmpdir(), `status-env-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(tmpDir, { recursive: true }); + awsTargetsPath = path.join(tmpDir, 'aws-targets.json'); + }); + + afterEach(async () => { + if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); + }); + + it('resolves env and returns one row per target with status + last deployed', async () => { + await writeFile( + awsTargetsPath, + JSON.stringify({ + targets: [targetA, targetB], + environments: { dev: { targets: ['dev-a', 'dev-b'] } }, + }) + ); + + const fetchStackInfo: StackInfoFetcher = vi.fn(async (_region, stackName) => { + await Promise.resolve(); + if (stackName === 'stack-dev-a') { + return { status: 'CREATE_COMPLETE', lastUpdated: new Date('2025-01-01T00:00:00Z') }; + } + return { status: 'UPDATE_COMPLETE', lastUpdated: new Date('2025-02-02T00:00:00Z') }; + }); + + const result = await handleEnvStatus('dev', { + configIO: makeFakeConfigIO(awsTargetsPath), + loadConfig: () => Promise.resolve(baseContext as never), + fetchStackInfo, + }); + + expect(result.success).toBe(true); + expect(result.envName).toBe('dev'); + expect(result.rows).toEqual([ + { + target: 'dev-a', + region: 'us-west-2', + status: 'CREATE_COMPLETE', + lastDeployed: '2025-01-01T00:00:00.000Z', + }, + { + target: 'dev-b', + region: 'us-east-1', + status: 'UPDATE_COMPLETE', + lastDeployed: '2025-02-02T00:00:00.000Z', + }, + ]); + expect(fetchStackInfo).toHaveBeenCalledTimes(2); + expect(fetchStackInfo).toHaveBeenCalledWith('us-west-2', 'stack-dev-a'); + expect(fetchStackInfo).toHaveBeenCalledWith('us-east-1', 'stack-dev-b'); + }); + + it('returns NOT_DEPLOYED row when no stack name is recorded for a target', async () => { + await writeFile( + awsTargetsPath, + JSON.stringify({ + targets: [targetA, targetB], + environments: { dev: { targets: ['dev-a', 'dev-b'] } }, + }) + ); + + const ctxOnlyA = { + ...baseContext, + deployedState: { targets: { 'dev-a': { resources: { stackName: 'stack-dev-a' } } } }, + }; + + const fetchStackInfo: StackInfoFetcher = vi.fn(async () => { + await Promise.resolve(); + return { status: 'CREATE_COMPLETE', lastUpdated: new Date('2025-01-01T00:00:00Z') }; + }); + + const result = await handleEnvStatus('dev', { + configIO: makeFakeConfigIO(awsTargetsPath), + loadConfig: () => Promise.resolve(ctxOnlyA as never), + fetchStackInfo, + }); + + expect(result.success).toBe(true); + expect(result.rows).toHaveLength(2); + const devB = result.rows.find(r => r.target === 'dev-b'); + expect(devB?.status).toBe('NOT_DEPLOYED'); + expect(devB?.lastDeployed).toBe('\u2014'); + // Stack info fetcher only called for dev-a (the only target with a stackName). + expect(fetchStackInfo).toHaveBeenCalledTimes(1); + }); + + it('returns a failure result with the unknown-env error when env does not exist', async () => { + await writeFile( + awsTargetsPath, + JSON.stringify({ + targets: [targetA, targetB], + environments: { dev: { targets: ['dev-a'] } }, + }) + ); + + const result = await handleEnvStatus('staging', { + configIO: makeFakeConfigIO(awsTargetsPath), + loadConfig: () => Promise.resolve(baseContext as never), + fetchStackInfo: () => Promise.resolve({}), + }); + + expect(result.success).toBe(false); + expect(result.rows).toEqual([]); + expect(result.error).toMatch(/Unknown environment "staging"/); + }); + + it('returns a failure result when aws-targets.json has no environments', async () => { + // Legacy array shape (no environments). + await writeFile(awsTargetsPath, JSON.stringify([targetA, targetB])); + + const result = await handleEnvStatus('dev', { + configIO: makeFakeConfigIO(awsTargetsPath), + loadConfig: () => Promise.resolve(baseContext as never), + fetchStackInfo: () => Promise.resolve({}), + }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/No environments are defined/); + }); +}); diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 271b05bc9..56a0bfbe7 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -1,12 +1,21 @@ import { ConfigIO } from '../../../lib'; -import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedResourceState, DeployedState } from '../../../schema'; -import { getAgentRuntimeStatus } from '../../aws'; +import type { + AgentCoreProjectSpec, + AwsDeploymentTarget, + AwsDeploymentTargets, + DeployedResourceState, + DeployedState, + Environments, +} from '../../../schema'; +import { getAgentRuntimeStatus, getCredentialProvider } from '../../aws'; import { getEvaluator, getOnlineEvaluationConfig } from '../../aws/agentcore-control'; import { dnsSuffix } from '../../aws/partition'; import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; +import { resolveEnvironment } from '../../operations/deploy/environment'; import type { ResourceDeploymentState } from './constants'; import { buildRuntimeInvocationUrl } from './constants'; +import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; export type { ResourceDeploymentState }; @@ -553,3 +562,118 @@ export async function handleRuntimeLookup( return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() }; } } + +// ============================================================================ +// Environment-scoped status (--env) +// ============================================================================ + +export interface EnvStatusRow { + target: string; + region: string; + status: string; + lastDeployed: string; +} + +export interface EnvStatusResult { + success: boolean; + envName: string; + rows: EnvStatusRow[]; + error?: string; +} + +export type StackInfoFetcher = (region: string, stackName: string) => Promise<{ status?: string; lastUpdated?: Date }>; + +/** + * Default stack-info fetcher: DescribeStacks via SDK. Returns undefined fields + * when the stack does not exist or the call fails (caller decides how to display). + */ +export const defaultStackInfoFetcher: StackInfoFetcher = async (region, stackName) => { + const cfn = new CloudFormationClient({ region, credentials: getCredentialProvider() }); + try { + const resp = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); + const stack = resp.Stacks?.[0]; + return { + status: stack?.StackStatus, + lastUpdated: stack?.LastUpdatedTime ?? stack?.CreationTime, + }; + } catch { + return {}; + } +}; + +/** + * Read aws-targets.json with environments via ConfigIO. Falls back to the + * supplied targets list (no environments) on any read failure so callers can + * still surface a clean "no environments" error from resolveEnvironment. + */ +async function readEnvironmentsFromAwsTargets( + configIO: ConfigIO, + targets: AwsDeploymentTarget[] +): Promise<{ targets: AwsDeploymentTarget[]; environments?: Environments }> { + try { + const full = await configIO.readAwsTargetsFull(); + return full.environments ? { targets, environments: full.environments } : { targets }; + } catch { + return { targets }; + } +} + +function formatStatusRow( + target: AwsDeploymentTarget, + stackName: string | undefined, + info: { status?: string; lastUpdated?: Date } | undefined +): EnvStatusRow { + return { + target: target.name, + region: target.region, + status: info?.status ?? (stackName ? 'NOT_DEPLOYED' : 'NOT_DEPLOYED'), + lastDeployed: info?.lastUpdated ? info.lastUpdated.toISOString() : '\u2014', + }; +} + +/** + * Resolve an environment to its targets and query each target's stack status. + * Used by `agentcore status --env `. + */ +export async function handleEnvStatus( + envName: string, + options: { + configIO?: ConfigIO; + fetchStackInfo?: StackInfoFetcher; + /** Inject a loader (used by tests to bypass real ConfigIO project/state reads). */ + loadConfig?: (configIO: ConfigIO) => Promise; + } = {} +): Promise { + const configIO = options.configIO ?? new ConfigIO(); + const fetchStackInfo = options.fetchStackInfo ?? defaultStackInfoFetcher; + const loadConfig = options.loadConfig ?? loadStatusConfig; + + let context: StatusContext; + try { + context = await loadConfig(configIO); + } catch (err: unknown) { + return { success: false, envName, rows: [], error: getErrorMessage(err) }; + } + + const awsTargetsWithEnv = await readEnvironmentsFromAwsTargets(configIO, context.awsTargets); + + let resolved; + try { + resolved = resolveEnvironment(envName, awsTargetsWithEnv); + } catch (err: unknown) { + return { success: false, envName, rows: [], error: getErrorMessage(err) }; + } + + const rows: EnvStatusRow[] = await Promise.all( + resolved.targets.map(async target => { + const stackName = context.deployedState.targets?.[target.name]?.resources?.stackName; + if (!stackName) { + return formatStatusRow(target, undefined, undefined); + } + const info = await fetchStackInfo(target.region, stackName); + return formatStatusRow(target, stackName, info); + }) + ); + + return { success: true, envName, rows }; +} diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index 506ad10ec..b0f1861fa 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -2,7 +2,7 @@ import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; import type { ResourceStatusEntry } from './action'; -import { handleProjectStatus, handleRuntimeLookup, loadStatusConfig } from './action'; +import { handleEnvStatus, handleProjectStatus, handleRuntimeLookup, loadStatusConfig } from './action'; import { DEPLOYMENT_STATE_COLORS, DEPLOYMENT_STATE_LABELS } from './constants'; import type { Command } from '@commander-js/extra-typings'; import { Box, Text, render } from 'ink'; @@ -25,6 +25,7 @@ const VALID_STATES = ['deployed', 'local-only', 'pending-removal'] as const; interface StatusCliOptions { runtimeId?: string; target?: string; + env?: string; type?: string; state?: string; runtime?: string; @@ -59,6 +60,7 @@ export const registerStatus = (program: Command) => { .description(COMMAND_DESCRIPTIONS.status) .option('--runtime-id ', 'Look up a specific runtime by ID') .option('--target ', 'Select deployment target') + .option('--env ', 'Show status for all targets in an environment') .option( '--type ', 'Filter by resource type (agent, runtime-endpoint, memory, credential, gateway, evaluator, online-eval, policy-engine, policy, config-bundle, ab-test)' @@ -69,6 +71,31 @@ export const registerStatus = (program: Command) => { .action(async (cliOptions: StatusCliOptions) => { requireProject(); + // Environment-scoped status: render a single table across all targets in the env + if (cliOptions.env) { + if (cliOptions.target) { + render(Cannot use --env and --target together. Pick one.); + process.exit(1); + } + try { + const envResult = await handleEnvStatus(cliOptions.env); + if (cliOptions.json) { + console.log(JSON.stringify(envResult, null, 2)); + if (!envResult.success) process.exit(1); + return; + } + if (!envResult.success) { + render({envResult.error}); + process.exit(1); + } + render(); + return; + } catch (error) { + render(Error: {getErrorMessage(error)}); + process.exit(1); + } + } + // Validate --type if (cliOptions.type && !(VALID_RESOURCE_TYPES as readonly string[]).includes(cliOptions.type)) { render( @@ -314,3 +341,37 @@ function ResourceEntry({ entry, showRuntime }: { entry: ResourceStatusEntry; sho ); } + +function EnvStatusTable({ + envName, + rows, +}: { + envName: string; + rows: { target: string; region: string; status: string; lastDeployed: string }[]; +}) { + const columns = [ + { key: 'target', header: 'Target' }, + { key: 'region', header: 'Region' }, + { key: 'status', header: 'Status' }, + { key: 'lastDeployed', header: 'Last Deployed' }, + ] as const; + + const widths = columns.map(col => { + const headerLen = col.header.length; + const cellMax = rows.reduce((max, row) => Math.max(max, String(row[col.key]).length), 0); + return Math.max(headerLen, cellMax); + }); + + const pad = (text: string, width: number) => text + ' '.repeat(Math.max(0, width - text.length)); + + return ( + + Environment: {envName} + {columns.map((c, i) => pad(c.header, widths[i]!)).join(' ')} + {rows.length === 0 && No targets in this environment.} + {rows.map(row => ( + {columns.map((c, i) => pad(String(row[c.key]), widths[i]!)).join(' ')} + ))} + + ); +} diff --git a/src/cli/operations/deploy/__tests__/environment.test.ts b/src/cli/operations/deploy/__tests__/environment.test.ts new file mode 100644 index 000000000..f75d84e63 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/environment.test.ts @@ -0,0 +1,141 @@ +import type { AgentEnvSpec, AwsDeploymentTarget, Environments } from '../../../../schema'; +import { type AwsTargetsInput, mergeOverrides, resolveEnvironment } from '../environment'; +import { describe, expect, it } from 'vitest'; + +const targetA: AwsDeploymentTarget = { + name: 'dev-a', + account: '111111111111', + region: 'us-west-2', +}; +const targetB: AwsDeploymentTarget = { + name: 'dev-b', + account: '222222222222', + region: 'us-east-1', +}; +const targetC: AwsDeploymentTarget = { + name: 'prod-a', + account: '333333333333', + region: 'us-east-1', +}; + +const environments: Environments = { + dev: { targets: ['dev-a', 'dev-b'], overrides: { envVars: { LOG_LEVEL: 'DEBUG' } } }, + prod: { targets: ['prod-a'] }, +}; + +const awsTargets: AwsTargetsInput = { + targets: [targetA, targetB, targetC], + environments, +}; + +const agentBase: AgentEnvSpec = { + name: 'my-agent', + build: 'Container', + entrypoint: 'agent.handler' as AgentEnvSpec['entrypoint'], + codeLocation: './src' as AgentEnvSpec['codeLocation'], +}; + +describe('resolveEnvironment', () => { + it('returns targets in declared order with overrides for a valid env', () => { + const result = resolveEnvironment('dev', awsTargets); + expect(result.targets).toEqual([targetA, targetB]); + expect(result.overrides).toEqual({ envVars: { LOG_LEVEL: 'DEBUG' } }); + }); + + it('returns undefined overrides when env has none', () => { + const result = resolveEnvironment('prod', awsTargets); + expect(result.targets).toEqual([targetC]); + expect(result.overrides).toBeUndefined(); + }); + + it('throws on unknown env name and lists available envs', () => { + expect(() => resolveEnvironment('staging', awsTargets)).toThrowError(/Unknown environment "staging".*dev.*prod/); + }); + + it('throws when no environments are defined', () => { + expect(() => resolveEnvironment('dev', { targets: [targetA] })).toThrowError(/No environments are defined/); + }); + + it('throws when environments map is empty', () => { + expect(() => resolveEnvironment('dev', { targets: [targetA], environments: {} })).toThrowError( + /No environments are defined/ + ); + }); + + it('throws when env references an unknown target (defensive)', () => { + const broken: AwsTargetsInput = { + targets: [targetA], + environments: { dev: { targets: ['dev-a', 'missing'] } }, + }; + expect(() => resolveEnvironment('dev', broken)).toThrowError(/unknown target "missing"/); + }); +}); + +describe('mergeOverrides', () => { + it('returns the same agent config when overrides is undefined', () => { + expect(mergeOverrides(agentBase, undefined)).toBe(agentBase); + }); + + it('returns the same agent config when overrides has no envVars', () => { + expect(mergeOverrides(agentBase, {})).toBe(agentBase); + }); + + it('returns the same agent config when envVars override map is empty', () => { + expect(mergeOverrides(agentBase, { envVars: {} })).toBe(agentBase); + }); + + it('appends new envVars when agent has none', () => { + const result = mergeOverrides(agentBase, { envVars: { LOG_LEVEL: 'DEBUG', STAGE: 'dev' } }); + expect(result.envVars).toEqual([ + { name: 'LOG_LEVEL', value: 'DEBUG' }, + { name: 'STAGE', value: 'dev' }, + ]); + }); + + it('shallow-merges envVars by name (override replaces existing value)', () => { + const agent: AgentEnvSpec = { + ...agentBase, + envVars: [ + { name: 'LOG_LEVEL', value: 'INFO' }, + { name: 'KEEP', value: 'unchanged' }, + ], + }; + const result = mergeOverrides(agent, { envVars: { LOG_LEVEL: 'DEBUG', NEW: 'added' } }); + expect(result.envVars).toEqual([ + { name: 'LOG_LEVEL', value: 'DEBUG' }, + { name: 'KEEP', value: 'unchanged' }, + { name: 'NEW', value: 'added' }, + ]); + }); + + it('does not mutate the input agent config', () => { + const agent: AgentEnvSpec = { + ...agentBase, + envVars: [{ name: 'LOG_LEVEL', value: 'INFO' }], + }; + const snapshot = JSON.parse(JSON.stringify(agent)); + mergeOverrides(agent, { envVars: { LOG_LEVEL: 'DEBUG', EXTRA: 'x' } }); + expect(agent).toEqual(snapshot); + }); + + it('does not mutate the overrides input', () => { + const overrides = { envVars: { LOG_LEVEL: 'DEBUG' } }; + const snapshot = JSON.parse(JSON.stringify(overrides)); + mergeOverrides(agentBase, overrides); + expect(overrides).toEqual(snapshot); + }); + + it('preserves other agent config fields untouched', () => { + const agent: AgentEnvSpec = { + ...agentBase, + description: 'preserved', + runtimeVersion: 'python:3.11' as AgentEnvSpec['runtimeVersion'], + }; + const result = mergeOverrides(agent, { envVars: { A: 'b' } }); + expect(result.name).toBe(agent.name); + expect(result.description).toBe('preserved'); + expect(result.build).toBe(agent.build); + expect(result.entrypoint).toBe(agent.entrypoint); + expect(result.codeLocation).toBe(agent.codeLocation); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/multi-target.test.ts b/src/cli/operations/deploy/__tests__/multi-target.test.ts new file mode 100644 index 000000000..a0c78e542 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/multi-target.test.ts @@ -0,0 +1,238 @@ +import type { AwsDeploymentTarget } from '../../../../schema'; +import { deployToTargets } from '../multi-target'; +import { describe, expect, it, vi } from 'vitest'; + +const targetA: AwsDeploymentTarget = { + name: 'dev-a', + account: '111111111111', + region: 'us-west-2', +}; +const targetB: AwsDeploymentTarget = { + name: 'dev-b', + account: '222222222222', + region: 'us-east-1', +}; +const targetC: AwsDeploymentTarget = { + name: 'prod-a', + account: '333333333333', + region: 'us-east-1', +}; + +describe('deployToTargets (sequential)', () => { + it('calls deployFn once per target in declared order', async () => { + const calls: string[] = []; + const log = vi.fn(); + const deployFn = vi.fn(async (target: AwsDeploymentTarget) => { + await Promise.resolve(); + calls.push(target.name); + return `ok-${target.name}`; + }); + + const result = await deployToTargets([targetA, targetB, targetC], { environmentName: 'dev', log }, deployFn); + + expect(calls).toEqual(['dev-a', 'dev-b', 'prod-a']); + expect(deployFn).toHaveBeenCalledTimes(3); + expect(result.successes.map(s => s.target.name)).toEqual(['dev-a', 'dev-b', 'prod-a']); + expect(result.successes.map(s => s.value)).toEqual(['ok-dev-a', 'ok-dev-b', 'ok-prod-a']); + expect(result.failures).toEqual([]); + }); + + it('emits progress lines in the documented format', async () => { + const lines: string[] = []; + await deployToTargets([targetA, targetB], { environmentName: 'dev', log: line => lines.push(line) }, () => + Promise.resolve() + ); + + expect(lines[0]).toBe('[1/2] Deploying to dev-a (us-west-2)...'); + expect(lines[1]).toBe('[2/2] Deploying to dev-b (us-east-1)...'); + }); + + it('emits success summary when all targets succeed', async () => { + const lines: string[] = []; + await deployToTargets([targetA, targetB], { environmentName: 'dev', log: line => lines.push(line) }, () => + Promise.resolve() + ); + + expect(lines[lines.length - 1]).toBe('\u2713 Environment "dev" deployed (2/2 targets)'); + }); + + it('stops on first failure (fail-fast) and records the failure', async () => { + const calls: string[] = []; + const lines: string[] = []; + const boom = new Error('cdk failed'); + const deployFn = vi.fn(async (target: AwsDeploymentTarget) => { + await Promise.resolve(); + calls.push(target.name); + if (target.name === 'dev-b') throw boom; + return undefined; + }); + + const result = await deployToTargets( + [targetA, targetB, targetC], + { environmentName: 'dev', log: line => lines.push(line) }, + deployFn + ); + + expect(calls).toEqual(['dev-a', 'dev-b']); + expect(deployFn).toHaveBeenCalledTimes(2); + expect(result.successes.map(s => s.target.name)).toEqual(['dev-a']); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]!.target.name).toBe('dev-b'); + expect(result.failures[0]!.error).toBe(boom); + // No success summary line when we bailed out. + expect(lines.find(l => l.startsWith('\u2713 Environment'))).toBeUndefined(); + }); + + it('returns empty result and emits summary when targets array is empty', async () => { + const lines: string[] = []; + const deployFn = vi.fn(() => Promise.resolve()); + + const result = await deployToTargets([], { environmentName: 'dev', log: line => lines.push(line) }, deployFn); + + expect(deployFn).not.toHaveBeenCalled(); + expect(result.successes).toEqual([]); + expect(result.failures).toEqual([]); + expect(lines).toEqual(['\u2713 Environment "dev" deployed (0/0 targets)']); + }); + + it('passes the zero-based index to deployFn', async () => { + const indexes: number[] = []; + await deployToTargets([targetA, targetB, targetC], { environmentName: 'dev', log: () => undefined }, (_t, idx) => { + indexes.push(idx); + return Promise.resolve(); + }); + expect(indexes).toEqual([0, 1, 2]); + }); +}); + +describe('deployToTargets (continueOnError)', () => { + it('continues past per-target failures and records all', async () => { + const calls: string[] = []; + const lines: string[] = []; + const boom = new Error('cdk failed'); + const deployFn = vi.fn(async (target: AwsDeploymentTarget) => { + await Promise.resolve(); + calls.push(target.name); + if (target.name === 'dev-b') throw boom; + return `ok-${target.name}`; + }); + + const result = await deployToTargets( + [targetA, targetB, targetC], + { environmentName: 'dev', continueOnError: true, log: line => lines.push(line) }, + deployFn + ); + + expect(calls).toEqual(['dev-a', 'dev-b', 'prod-a']); + expect(deployFn).toHaveBeenCalledTimes(3); + expect(result.successes.map(s => s.target.name)).toEqual(['dev-a', 'prod-a']); + expect(result.failures.map(f => f.target.name)).toEqual(['dev-b']); + expect(result.failures[0]!.error).toBe(boom); + }); + + it('emits failure summary listing failed targets and suggests status --env', async () => { + const lines: string[] = []; + const deployFn = vi.fn(async (target: AwsDeploymentTarget) => { + await Promise.resolve(); + if (target.name !== 'dev-a') throw new Error(`failed-${target.name}`); + return undefined; + }); + + await deployToTargets( + [targetA, targetB, targetC], + { environmentName: 'dev', continueOnError: true, log: line => lines.push(line) }, + deployFn + ); + + const joined = lines.join('\n'); + expect(joined).toMatch(/Environment "dev" deploy had 2 failure\(s\) \(1\/3 succeeded\)/); + expect(joined).toMatch(/Failed targets:/); + expect(joined).toMatch(/- dev-b \(us-east-1\): failed-dev-b/); + expect(joined).toMatch(/- prod-a \(us-east-1\): failed-prod-a/); + expect(joined).toMatch(/agentcore status --env dev/); + }); +}); + +describe('deployToTargets (parallel)', () => { + it('runs targets concurrently via Promise.allSettled', async () => { + let inFlight = 0; + let maxInFlight = 0; + const deployFn = vi.fn(async (target: AwsDeploymentTarget) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, 5)); + inFlight--; + return target.name; + }); + + const result = await deployToTargets( + [targetA, targetB, targetC], + { environmentName: 'dev', parallel: true, log: () => undefined }, + deployFn + ); + + expect(maxInFlight).toBeGreaterThan(1); + expect(result.successes.map(s => s.target.name).sort()).toEqual(['dev-a', 'dev-b', 'prod-a']); + expect(result.failures).toEqual([]); + }); + + it('aggregates partial failures into a summary (does not cancel siblings)', async () => { + const deployFn = vi.fn(async (target: AwsDeploymentTarget) => { + await Promise.resolve(); + if (target.name === 'dev-b') throw new Error('boom-b'); + return `ok-${target.name}`; + }); + + const result = await deployToTargets( + [targetA, targetB, targetC], + { environmentName: 'dev', parallel: true, log: () => undefined }, + deployFn + ); + + expect(deployFn).toHaveBeenCalledTimes(3); + expect(result.successes.map(s => s.target.name).sort()).toEqual(['dev-a', 'prod-a']); + expect(result.failures.map(f => f.target.name)).toEqual(['dev-b']); + expect((result.failures[0]!.error as Error).message).toBe('boom-b'); + }); + + it('emits failure summary in parallel mode', async () => { + const lines: string[] = []; + const deployFn = vi.fn(async (target: AwsDeploymentTarget) => { + await Promise.resolve(); + if (target.name === 'dev-a') throw new Error('boom-a'); + return undefined; + }); + + await deployToTargets( + [targetA, targetB], + { environmentName: 'gamma', parallel: true, log: line => lines.push(line) }, + deployFn + ); + + const joined = lines.join('\n'); + expect(joined).toMatch(/\u2717 Environment "gamma" deploy had 1 failure\(s\) \(1\/2 succeeded\)/); + expect(joined).toMatch(/- dev-a \(us-west-2\): boom-a/); + expect(joined).toMatch(/agentcore status --env gamma/); + }); +}); + +describe('deployToTargets (exit-code semantics)', () => { + it('reports failures.length === 0 on full success (caller exits 0)', async () => { + const result = await deployToTargets([targetA, targetB], { environmentName: 'dev', log: () => undefined }, () => + Promise.resolve('ok') + ); + expect(result.failures.length).toBe(0); + }); + + it('reports failures.length > 0 when any target fails (caller exits 1)', async () => { + const result = await deployToTargets( + [targetA, targetB], + { environmentName: 'dev', parallel: true, log: () => undefined }, + (target: AwsDeploymentTarget) => { + if (target.name === 'dev-b') return Promise.reject(new Error('x')); + return Promise.resolve('ok'); + } + ); + expect(result.failures.length).toBe(1); + }); +}); diff --git a/src/cli/operations/deploy/environment.ts b/src/cli/operations/deploy/environment.ts new file mode 100644 index 000000000..49c3cce09 --- /dev/null +++ b/src/cli/operations/deploy/environment.ts @@ -0,0 +1,86 @@ +import type { + AgentEnvSpec, + AwsDeploymentTarget, + Environment, + EnvironmentOverrides, + Environments, +} from '../../../schema'; + +export interface ResolvedEnvironment { + /** Targets in the order declared by the environment, hydrated from the AWS targets list. */ + targets: AwsDeploymentTarget[]; + /** Overrides defined on the environment (undefined if none). */ + overrides: EnvironmentOverrides | undefined; +} + +export interface AwsTargetsInput { + targets: AwsDeploymentTarget[]; + environments?: Environments; +} + +/** + * Resolve a named environment into its concrete targets + overrides. + * + * Throws if the environment is unknown or if the AwsTargets value has no + * environments map at all. Unknown target refs are not expected here because + * AwsTargetsSchema's superRefine rejects them at parse time; we still guard + * defensively in case this is called with hand-built data. + */ +export function resolveEnvironment(name: string, awsTargets: AwsTargetsInput): ResolvedEnvironment { + const environments = awsTargets.environments; + if (!environments || Object.keys(environments).length === 0) { + throw new Error(`No environments are defined in aws-targets.json. Cannot resolve environment "${name}".`); + } + + const env: Environment | undefined = environments[name]; + if (!env) { + const available = Object.keys(environments).sort().join(', '); + throw new Error(`Unknown environment "${name}". Available environments: ${available}`); + } + + const targetsByName = new Map(awsTargets.targets.map(t => [t.name, t])); + const resolvedTargets: AwsDeploymentTarget[] = []; + for (const ref of env.targets) { + const target = targetsByName.get(ref); + if (!target) { + const available = awsTargets.targets.map(t => t.name).join(', '); + throw new Error( + `Environment "${name}" references unknown target "${ref}". Available targets: ${available || '(none)'}` + ); + } + resolvedTargets.push(target); + } + + return { targets: resolvedTargets, overrides: env.overrides }; +} + +/** + * Shallow-merge environment overrides into an agent config. + * + * v1 supports envVars only. The agent's envVars are an array of `{name, value}` + * pairs; overrides are a `Record`. Merge replaces any agent + * entry whose `name` matches an override key, then appends the remaining + * override entries. Inputs are never mutated. + */ +export function mergeOverrides(agentConfig: AgentEnvSpec, overrides: EnvironmentOverrides | undefined): AgentEnvSpec { + if (!overrides?.envVars || Object.keys(overrides.envVars).length === 0) { + return agentConfig; + } + + const overrideEnvVars = overrides.envVars; + const existing = agentConfig.envVars ?? []; + const overrideNames = new Set(Object.keys(overrideEnvVars)); + + const merged: { name: string; value: string }[] = existing.map(entry => + overrideNames.has(entry.name) ? { name: entry.name, value: overrideEnvVars[entry.name]! } : { ...entry } + ); + + const existingNames = new Set(existing.map(e => e.name)); + for (const [name, value] of Object.entries(overrideEnvVars)) { + if (!existingNames.has(name)) { + merged.push({ name, value }); + } + } + + return { ...agentConfig, envVars: merged }; +} diff --git a/src/cli/operations/deploy/multi-target.ts b/src/cli/operations/deploy/multi-target.ts new file mode 100644 index 000000000..494d08cea --- /dev/null +++ b/src/cli/operations/deploy/multi-target.ts @@ -0,0 +1,117 @@ +import type { AwsDeploymentTarget } from '../../../schema'; + +export interface DeployToTargetsOptions { + /** Environment name used in progress / summary output. */ + environmentName: string; + /** Run targets concurrently via Promise.allSettled. */ + parallel?: boolean; + /** In sequential mode, keep going past per-target failures. Ignored in parallel mode (allSettled already does this). */ + continueOnError?: boolean; + /** Sink for progress + summary lines. Defaults to `console.log`. */ + log?: (line: string) => void; +} + +export interface TargetDeployResult { + target: AwsDeploymentTarget; + /** Result returned by the deployFn on success. */ + value?: unknown; + /** Error caught from a failing deployFn. */ + error?: unknown; +} + +export interface DeployToTargetsResult { + successes: TargetDeployResult[]; + failures: TargetDeployResult[]; +} + +export type TargetDeployFn = (target: AwsDeploymentTarget, index: number) => Promise; + +const SUCCESS_MARK = '\u2713'; +const FAILURE_MARK = '\u2717'; + +/** + * Run `deployFn` per target with one of three execution policies: + * - default (sequential, fail-fast): stop on first error. + * - `continueOnError` (sequential): catch per-target errors and keep going. + * - `parallel`: launch all in flight via Promise.allSettled; one failure + * does not cancel the rest. + * + * The orchestrator never throws; it always resolves with a summary aggregate + * so callers can decide on exit code (0 if `failures.length === 0`, else 1). + */ +export async function deployToTargets( + targets: AwsDeploymentTarget[], + options: DeployToTargetsOptions, + deployFn: TargetDeployFn +): Promise { + const log = options.log ?? ((line: string) => console.log(line)); + const successes: TargetDeployResult[] = []; + const failures: TargetDeployResult[] = []; + + if (options.parallel) { + targets.forEach((target, i) => { + log(`[${i + 1}/${targets.length}] Deploying to ${target.name} (${target.region})...`); + }); + const settled = await Promise.allSettled(targets.map((target, i) => deployFn(target, i))); + settled.forEach((result, i) => { + const target = targets[i]!; + if (result.status === 'fulfilled') { + successes.push({ target, value: result.value }); + } else { + failures.push({ target, error: result.reason }); + } + }); + } else { + for (let i = 0; i < targets.length; i++) { + const target = targets[i]!; + log(`[${i + 1}/${targets.length}] Deploying to ${target.name} (${target.region})...`); + try { + const value = await deployFn(target, i); + successes.push({ target, value }); + } catch (error) { + failures.push({ target, error }); + if (!options.continueOnError) { + // Fail-fast: stop iterating on first error. + emitSummary(log, options.environmentName, targets.length, successes, failures); + return { successes, failures }; + } + } + } + } + + emitSummary(log, options.environmentName, targets.length, successes, failures); + return { successes, failures }; +} + +function emitSummary( + log: (line: string) => void, + environmentName: string, + totalCount: number, + successes: TargetDeployResult[], + failures: TargetDeployResult[] +): void { + if (failures.length === 0) { + log(`${SUCCESS_MARK} Environment "${environmentName}" deployed (${successes.length}/${totalCount} targets)`); + return; + } + + log( + `${FAILURE_MARK} Environment "${environmentName}" deploy had ${failures.length} failure(s) (${successes.length}/${totalCount} succeeded)` + ); + log('Failed targets:'); + for (const failure of failures) { + const reason = errorMessage(failure.error); + log(` - ${failure.target.name} (${failure.target.region}): ${reason}`); + } + log(`Run \`agentcore status --env ${environmentName}\` to inspect deployed state.`); +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + try { + return JSON.stringify(err); + } catch { + return String(err); + } +} diff --git a/src/cli/tui/hooks/useAwsTargetConfig.ts b/src/cli/tui/hooks/useAwsTargetConfig.ts index 3a0e723de..6374843e0 100644 --- a/src/cli/tui/hooks/useAwsTargetConfig.ts +++ b/src/cli/tui/hooks/useAwsTargetConfig.ts @@ -1,5 +1,5 @@ import { ConfigIO, NoProjectError, findConfigRoot } from '../../../lib'; -import type { AgentCoreRegion, AwsDeploymentTarget } from '../../../schema'; +import type { AgentCoreRegion, AwsDeploymentTarget, Environments } from '../../../schema'; import { detectAwsContext } from '../../aws'; import { getErrorMessage } from '../../errors'; import { useCallback, useEffect, useState } from 'react'; @@ -26,6 +26,8 @@ export interface AwsTargetConfigState { detectedRegion: AgentCoreRegion; /** Available targets for selection (when phase === 'select-target') */ availableTargets: AwsDeploymentTarget[]; + /** Environments map parsed from aws-targets.json (undefined when not defined or on legacy array shape). */ + environments?: Environments; /** Selected target indices (empty means all targets) */ selectedTargetIndices: number[]; /** Pending target indices for multi-select (before confirmation) */ @@ -77,6 +79,7 @@ export function useAwsTargetConfig(): AwsTargetConfigState { const [detectedRegion, setDetectedRegion] = useState('us-east-1'); const [manualAccountId, setManualAccountId] = useState(''); const [availableTargets, setAvailableTargets] = useState([]); + const [environments, setEnvironments] = useState(undefined); const [selectedTargetIndices, setSelectedTargetIndices] = useState([]); const [pendingTargetIndices, setPendingTargetIndices] = useState([]); @@ -111,6 +114,14 @@ export function useAwsTargetConfig(): AwsTargetConfigState { const configIO = new ConfigIO({ baseDir: configRoot }); const targets = await configIO.resolveAWSDeploymentTargets(); + // Best-effort read of the new `{ targets, environments }` object shape. + // Falls back to undefined for legacy array configs or any read failure. + try { + const full = await configIO.readAwsTargetsFull(); + if (full.environments) setEnvironments(full.environments); + } catch { + // Legacy array shape (no environments) or unreadable — leave undefined. + } if (targets.length > 1) { // Multiple targets - show selection @@ -246,6 +257,7 @@ export function useAwsTargetConfig(): AwsTargetConfigState { error, detectedRegion, availableTargets, + environments, selectedTargetIndices, pendingTargetIndices, startConfig, diff --git a/src/cli/tui/screens/create/AssignTargetsPanel.tsx b/src/cli/tui/screens/create/AssignTargetsPanel.tsx new file mode 100644 index 000000000..759dea705 --- /dev/null +++ b/src/cli/tui/screens/create/AssignTargetsPanel.tsx @@ -0,0 +1,170 @@ +import type { AwsDeploymentTarget, Environments } from '../../../../schema'; +import { AwsTargetsSchema } from '../../../../schema'; +import { Box, Text, useInput } from 'ink'; +import React, { useState } from 'react'; + +/** Assignment matrix: environment name → set of selected target names. */ +export type EnvironmentAssignments = Record>; + +export interface AssignTargetsPanelProps { + targets: AwsDeploymentTarget[]; + envNames: string[]; + /** Optional initial assignments (defaults to empty for every env). */ + initial?: EnvironmentAssignments; + /** Called with the final assignments when the user confirms. */ + onConfirm: (assignments: EnvironmentAssignments) => void; + /** Called when the user backs out of this step. */ + onCancel: () => void; + isActive?: boolean; +} + +/** + * Interactive panel: cursor moves through an env-major / target-minor grid. + * Space toggles the cell. Enter confirms. Esc cancels. The panel never writes + * to disk; the parent wires the resulting matrix into aws-targets.json. + */ +export function AssignTargetsPanel({ + targets, + envNames, + initial, + onConfirm, + onCancel, + isActive = true, +}: AssignTargetsPanelProps) { + const [assignments, setAssignments] = useState(() => { + const seed: EnvironmentAssignments = {}; + for (const env of envNames) { + seed[env] = new Set(initial?.[env] ?? []); + } + return seed; + }); + const [envCursor, setEnvCursor] = useState(0); + const [targetCursor, setTargetCursor] = useState(0); + + const noEnvs = envNames.length === 0; + const noTargets = targets.length === 0; + + useInput( + (input, key) => { + if (noEnvs || noTargets) { + if (key.return || key.escape) onCancel(); + return; + } + if (key.upArrow) { + setTargetCursor(c => (c - 1 + targets.length) % targets.length); + } else if (key.downArrow) { + setTargetCursor(c => (c + 1) % targets.length); + } else if (key.leftArrow) { + setEnvCursor(c => (c - 1 + envNames.length) % envNames.length); + } else if (key.rightArrow) { + setEnvCursor(c => (c + 1) % envNames.length); + } else if (input === ' ' || input === 'x' || input === 'X') { + const env = envNames[envCursor]!; + const target = targets[targetCursor]!; + setAssignments(prev => { + const next = { ...prev }; + const current = new Set(next[env] ?? []); + if (current.has(target.name)) current.delete(target.name); + else current.add(target.name); + next[env] = current; + return next; + }); + } else if (key.return) { + onConfirm(assignments); + } else if (key.escape) { + onCancel(); + } + }, + { isActive } + ); + + if (noEnvs) { + return ( + + (No environments to assign — skipping target assignment.) + Press Enter to continue. + + ); + } + if (noTargets) { + return ( + + (No targets defined yet — environments will be created without target assignments.) + Add targets later via aws-targets.json. Press Enter to continue. + + ); + } + + return ( + + Assign targets to environments: + + + Target \\ Env + {targets.map((t, idx) => ( + + {idx === targetCursor ? '> ' : ' '} + {t.name} + + ))} + + {envNames.map((env, envIdx) => { + const assigned = assignments[env] ?? new Set(); + return ( + + + {env} + + {targets.map((t, tIdx) => { + const isCursor = envIdx === envCursor && tIdx === targetCursor; + const checked = assigned.has(t.name); + return ( + + {isCursor ? '> ' : ' '} + {checked ? '[x]' : '[ ]'} + + ); + })} + + ); + })} + + + ↑/↓ row · ←/→ env · Space toggle · Enter confirm · Esc cancel + + + ); +} + +/** + * Build the `environments` section of aws-targets.json from an assignment + * matrix. Drops environments with zero targets so the resulting object always + * passes EnvironmentSchema's `min(1)` rule. Returns `undefined` when no + * environment ends up with any targets — that signals the caller should omit + * the `environments` field entirely. + */ +export function buildEnvironmentsSection(assignments: EnvironmentAssignments): Environments | undefined { + const result: Environments = {}; + for (const [name, members] of Object.entries(assignments)) { + const targets = Array.from(members).filter(Boolean); + if (targets.length === 0) continue; + result[name] = { targets }; + } + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Build a complete AwsTargets payload (object form) from a target list and an + * assignment matrix, validated against AwsTargetsSchema (incl. cross-validation + * that every environment target ref exists in `targets[]`). Throws ZodError + * when invalid. + */ +export function buildAwsTargetsConfig( + targets: AwsDeploymentTarget[], + assignments: EnvironmentAssignments +): { targets: AwsDeploymentTarget[]; environments?: Environments } { + const environments = buildEnvironmentsSection(assignments); + const candidate = { targets, ...(environments ? { environments } : {}) }; + // Run the schema (incl. superRefine) so callers get a guaranteed-valid object. + return AwsTargetsSchema.parse(candidate); +} diff --git a/src/cli/tui/screens/create/CreateScreen.tsx b/src/cli/tui/screens/create/CreateScreen.tsx index 801dfc8d8..da32c6af1 100644 --- a/src/cli/tui/screens/create/CreateScreen.tsx +++ b/src/cli/tui/screens/create/CreateScreen.tsx @@ -1,3 +1,5 @@ +import { ConfigIO, findConfigRoot } from '../../../../lib'; +import type { AwsDeploymentTarget } from '../../../../schema'; import { DEFAULT_MODEL_IDS, ProjectNameSchema } from '../../../../schema'; import { validateFolderNotExists } from '../../../commands/create/validate'; import { VPC_ENDPOINT_WARNING } from '../../../commands/shared/vpc-utils'; @@ -19,10 +21,12 @@ import { STATUS_COLORS } from '../../theme'; import { AddAgentScreen } from '../agent/AddAgentScreen'; import type { AddAgentConfig } from '../agent/types'; import { FRAMEWORK_OPTIONS } from '../agent/types'; +import { AssignTargetsPanel, type EnvironmentAssignments, buildAwsTargetsConfig } from './AssignTargetsPanel'; +import { EnvironmentStep } from './EnvironmentStep'; import { useCreateFlow } from './useCreateFlow'; import { Box, Text, useApp } from 'ink'; import { join } from 'path'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; /** Build a text representation of the completion screen for terminal output */ function buildExitMessage(projectName: string, steps: Step[], agentConfig: AddAgentConfig | null): string { @@ -209,6 +213,65 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS // Completion state for next steps const allSuccess = !flow.hasError && flow.isComplete; + // Optional post-scaffold environment wizard (T13/T14). Runs once after the + // project is created; auto-skipped when there are 0 or 1 targets in + // aws-targets.json. Persists via writeAwsTargetsFull when the user assigns + // targets to environments. + const [envWizardStage, setEnvWizardStage] = useState< + 'idle' | 'env-step' | 'assign' | 'persisting' | 'done' | 'skipped' + >('idle'); + const [existingTargets, setExistingTargets] = useState([]); + const [envNamesChosen, setEnvNamesChosen] = useState([]); + const [envWizardError, setEnvWizardError] = useState(null); + + // Kick the wizard off once scaffolding succeeds (interactive only). + useEffect(() => { + if (!allSuccess || envWizardStage !== 'idle' || !isInteractive) return; + let cancelled = false; + void (async () => { + try { + const configRoot = findConfigRoot(projectRoot); + if (!configRoot) { + if (!cancelled) setEnvWizardStage('skipped'); + return; + } + const configIO = new ConfigIO({ baseDir: configRoot }); + const full = await configIO.readAwsTargetsFull(); + if (cancelled) return; + setExistingTargets(full.targets); + setEnvWizardStage('env-step'); + } catch { + if (!cancelled) setEnvWizardStage('skipped'); + } + })(); + return () => { + cancelled = true; + }; + }, [allSuccess, envWizardStage, isInteractive, projectRoot]); + + const writeEnvironmentsAndExit = useCallback( + (assignments: EnvironmentAssignments) => { + setEnvWizardStage('persisting'); + void (async () => { + try { + const configRoot = findConfigRoot(projectRoot); + if (!configRoot) { + setEnvWizardStage('done'); + return; + } + const configIO = new ConfigIO({ baseDir: configRoot }); + const config = buildAwsTargetsConfig(existingTargets, assignments); + await configIO.writeAwsTargetsFull(config); + setEnvWizardStage('done'); + } catch (err: unknown) { + setEnvWizardError(err instanceof Error ? err.message : String(err)); + setEnvWizardStage('done'); + } + })(); + }, + [existingTargets, projectRoot] + ); + // Handle exit - if successful, exit app completely and print completion screen const handleExit = useCallback(() => { if (allSuccess && isInteractive) { @@ -220,12 +283,13 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS } }, [allSuccess, isInteractive, flow.projectName, flow.steps, flow.addAgentConfig, exit, onExit]); - // Auto-exit when project creation completes successfully + // Auto-exit when project creation completes successfully (after env wizard finishes / is skipped) + const wizardFinished = envWizardStage === 'done' || envWizardStage === 'skipped' || !isInteractive; useEffect(() => { - if (allSuccess) { + if (allSuccess && wizardFinished) { handleExit(); } - }, [allSuccess, handleExit]); + }, [allSuccess, wizardFinished, handleExit]); // Create prompt navigation const { selectedIndex: createPromptIndex } = useListNavigation({ @@ -328,7 +392,58 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS {phase === 'running' && } - {allSuccess && flow.outputDir && ( + {allSuccess && envWizardStage === 'env-step' && ( + + + + setEnvWizardStage('skipped')} + onComplete={names => { + setEnvNamesChosen(names); + if (existingTargets.length === 0) { + // No targets to assign yet — environments without members + // can't be persisted (EnvironmentSchema requires min 1). + setEnvWizardStage('skipped'); + } else { + setEnvWizardStage('assign'); + } + }} + /> + + + )} + + {allSuccess && envWizardStage === 'assign' && ( + + + + setEnvWizardStage('skipped')} + onConfirm={writeEnvironmentsAndExit} + /> + + + )} + + {allSuccess && envWizardStage === 'persisting' && ( + + + Writing environments to aws-targets.json… + + )} + + {allSuccess && envWizardError && ( + + Environment write failed: {envWizardError} + + )} + + {allSuccess && flow.outputDir && wizardFinished && ( diff --git a/src/cli/tui/screens/create/EnvironmentStep.tsx b/src/cli/tui/screens/create/EnvironmentStep.tsx new file mode 100644 index 000000000..bb4f8607c --- /dev/null +++ b/src/cli/tui/screens/create/EnvironmentStep.tsx @@ -0,0 +1,192 @@ +import { Box, Text, useInput } from 'ink'; +import React, { useState } from 'react'; + +const ENV_NAME_REGEX = /^[a-z][a-z0-9-]*$/; + +export const ENVIRONMENT_PRESETS = ['dev', 'gamma', 'prod'] as const; +export type EnvironmentPreset = (typeof ENVIRONMENT_PRESETS)[number]; + +export interface EnvironmentStepProps { + /** Number of targets the user has defined. The step is a no-op when <= 1. */ + targetCount: number; + /** Called with the chosen environment names (preset values or custom name). */ + onComplete: (envNames: string[]) => void; + /** Called when the user skips this step (default). */ + onSkip: () => void; + isActive?: boolean; +} + +type Mode = 'prompt' | 'preset-select' | 'custom-name'; + +interface SelectionState { + presetsSelected: Record; + customName: string; + customError: string | null; +} + +const initialSelection: SelectionState = { + presetsSelected: { dev: false, gamma: false, prod: false }, + customName: '', + customError: null, +}; + +/** + * Optional create-wizard step: lets the user define one or more deployment + * environments (dev / gamma / prod or a custom name) when more than one + * target has been configured. The step defaults to "No" so users with a + * single-target setup or who don't need environments can skip with one keypress. + * + * T13: this component implements the UI only. T14 wires the chosen env names + * into the write path (`environments` section of aws-targets.json) and adds + * the per-target assignment panel. + */ +export function EnvironmentStep({ targetCount, onComplete, onSkip, isActive = true }: EnvironmentStepProps) { + const eligible = targetCount > 1; + const [mode, setMode] = useState('prompt'); + const [cursor, setCursor] = useState(0); + const [selection, setSelection] = useState(initialSelection); + + // Auto-skip when the step is not eligible. Surfaces as a no-op for the parent. + React.useEffect(() => { + if (!eligible && isActive) onSkip(); + }, [eligible, isActive, onSkip]); + + const presetItems: { id: EnvironmentPreset | '__custom__' | '__done__'; label: string }[] = [ + ...ENVIRONMENT_PRESETS.map(name => ({ + id: name, + label: `${selection.presetsSelected[name] ? '[x]' : '[ ]'} ${name}`, + })), + { id: '__custom__', label: 'Add custom environment name…' }, + { id: '__done__', label: 'Done' }, + ]; + + useInput( + (input, key) => { + if (!eligible) return; + + if (mode === 'prompt') { + if (input === 'y' || input === 'Y') { + setMode('preset-select'); + setCursor(0); + } else if (input === 'n' || input === 'N' || key.escape || key.return) { + onSkip(); + } + return; + } + + if (mode === 'preset-select') { + if (key.upArrow) { + setCursor(c => (c - 1 + presetItems.length) % presetItems.length); + } else if (key.downArrow) { + setCursor(c => (c + 1) % presetItems.length); + } else if (key.escape) { + setMode('prompt'); + } else if (key.return) { + const choice = presetItems[cursor]!; + if (choice.id === '__custom__') { + setMode('custom-name'); + setSelection(s => ({ ...s, customError: null })); + } else if (choice.id === '__done__') { + const chosen = ENVIRONMENT_PRESETS.filter(name => selection.presetsSelected[name]); + if (chosen.length === 0) { + onSkip(); + } else { + onComplete(chosen); + } + } else { + // Toggle preset + const presetId = choice.id; + setSelection(s => ({ + ...s, + presetsSelected: { ...s.presetsSelected, [presetId]: !s.presetsSelected[presetId] }, + })); + } + } + return; + } + + if (mode === 'custom-name') { + if (key.escape) { + setMode('preset-select'); + setSelection(s => ({ ...s, customName: '', customError: null })); + } else if (key.return) { + const trimmed = selection.customName.trim(); + if (!ENV_NAME_REGEX.test(trimmed)) { + setSelection(s => ({ + ...s, + customError: + 'Environment name must start with a lowercase letter and contain only lowercase alphanumeric characters and hyphens.', + })); + return; + } + const chosen = ENVIRONMENT_PRESETS.filter(name => selection.presetsSelected[name]); + onComplete([...chosen, trimmed]); + } else if (key.backspace || key.delete) { + setSelection(s => ({ ...s, customName: s.customName.slice(0, -1), customError: null })); + } else if (input && !key.ctrl && !key.meta) { + setSelection(s => ({ ...s, customName: s.customName + input, customError: null })); + } + } + }, + { isActive } + ); + + if (!eligible) { + return ( + + (Environment setup skipped: only one target defined.) + + ); + } + + if (mode === 'prompt') { + return ( + + Define deployment environments? + + Group your {targetCount} targets into environments (e.g. dev / gamma / prod) so you can run{' '} + agentcore deploy --env <name> to deploy them as a set. + + + (y) Yes · (n) No [default] + + + ); + } + + if (mode === 'preset-select') { + return ( + + Select environments: + {presetItems.map((item, idx) => { + const isCursor = idx === cursor; + return ( + + {isCursor ? '> ' : ' '} + {item.label} + + ); + })} + + ↑/↓ to navigate · Enter to toggle/select · Esc to cancel + + + ); + } + + // custom-name + return ( + + Custom environment name: + + {'> '} + {selection.customName} + + + {selection.customError && {selection.customError}} + + Enter to confirm · Esc to go back · format: lowercase, hyphens, digits + + + ); +} diff --git a/src/cli/tui/screens/create/__tests__/AssignTargetsPanel.test.tsx b/src/cli/tui/screens/create/__tests__/AssignTargetsPanel.test.tsx new file mode 100644 index 000000000..20a6b799e --- /dev/null +++ b/src/cli/tui/screens/create/__tests__/AssignTargetsPanel.test.tsx @@ -0,0 +1,187 @@ +import type { AwsDeploymentTarget } from '../../../../../schema'; +import { AwsTargetsSchema } from '../../../../../schema'; +import { + AssignTargetsPanel, + type EnvironmentAssignments, + buildAwsTargetsConfig, + buildEnvironmentsSection, +} from '../AssignTargetsPanel'; +import { render } from 'ink-testing-library'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const flush = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + +const targetA: AwsDeploymentTarget = { name: 'dev-a', account: '111111111111', region: 'us-west-2' }; +const targetB: AwsDeploymentTarget = { name: 'dev-b', account: '222222222222', region: 'us-east-1' }; +const targetC: AwsDeploymentTarget = { name: 'prod-a', account: '333333333333', region: 'us-east-1' }; + +describe('AssignTargetsPanel (UI)', () => { + it('renders header columns for each environment and rows for each target', () => { + const { lastFrame } = render( + undefined} + onCancel={() => undefined} + /> + ); + const frame = lastFrame() ?? ''; + expect(frame).toMatch(/Assign targets to environments:/); + expect(frame).toMatch(/dev/); + expect(frame).toMatch(/prod/); + expect(frame).toMatch(/dev-a/); + expect(frame).toMatch(/dev-b/); + expect(frame).toMatch(/prod-a/); + }); + + it('toggles the cell at the cursor with Space and surfaces it to onConfirm', async () => { + const onConfirm = vi.fn(); + const { stdin } = render( + undefined} + /> + ); + // Cursor starts at (env=dev, target=dev-a). Toggle on. + stdin.write(' '); + await flush(); + // ↓ to dev-b, toggle on. + stdin.write('\u001B[B'); + await flush(); + stdin.write(' '); + await flush(); + // → to prod, ↑ back to dev-a... easier: from dev-b, → to prod (still at dev-b row), toggle on. + stdin.write('\u001B[C'); + await flush(); + stdin.write(' '); + await flush(); + stdin.write('\r'); + await flush(); + expect(onConfirm).toHaveBeenCalledTimes(1); + const result = onConfirm.mock.calls[0]![0] as EnvironmentAssignments; + expect(Array.from(result.dev ?? [])).toEqual(['dev-a', 'dev-b']); + expect(Array.from(result.prod ?? [])).toEqual(['dev-b']); + }); + + it('cancels via Esc', async () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn(); + const { stdin } = render( + + ); + stdin.write('\u001B'); + await flush(); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('renders an empty-targets fallback message when no targets are defined', () => { + const { lastFrame } = render( + undefined} + onCancel={() => undefined} + /> + ); + expect(lastFrame() ?? '').toMatch(/No targets defined yet/); + }); + + it('renders a no-environments fallback when envNames is empty', () => { + const { lastFrame } = render( + undefined} onCancel={() => undefined} /> + ); + expect(lastFrame() ?? '').toMatch(/No environments to assign/); + }); +}); + +describe('buildEnvironmentsSection (serialization)', () => { + it('serializes assignments into the schema-shaped environments map', () => { + const assignments: EnvironmentAssignments = { + dev: new Set(['dev-a', 'dev-b']), + prod: new Set(['prod-a']), + }; + expect(buildEnvironmentsSection(assignments)).toEqual({ + dev: { targets: ['dev-a', 'dev-b'] }, + prod: { targets: ['prod-a'] }, + }); + }); + + it('drops environments with zero assigned targets', () => { + const assignments: EnvironmentAssignments = { + dev: new Set(['dev-a']), + empty: new Set(), + }; + expect(buildEnvironmentsSection(assignments)).toEqual({ + dev: { targets: ['dev-a'] }, + }); + }); + + it('returns undefined when every environment is empty', () => { + const assignments: EnvironmentAssignments = { dev: new Set(), prod: new Set() }; + expect(buildEnvironmentsSection(assignments)).toBeUndefined(); + }); +}); + +describe('buildAwsTargetsConfig (schema-validated)', () => { + it('produces an object that AwsTargetsSchema accepts (incl. cross-validation)', () => { + const config = buildAwsTargetsConfig([targetA, targetB, targetC], { + dev: new Set(['dev-a', 'dev-b']), + prod: new Set(['prod-a']), + }); + // buildAwsTargetsConfig internally calls AwsTargetsSchema.parse, so we + // re-validate here as a sanity check that the returned object is stable. + const reparsed = AwsTargetsSchema.parse(config); + expect(reparsed.targets).toHaveLength(3); + expect(reparsed.environments?.dev?.targets).toEqual(['dev-a', 'dev-b']); + expect(reparsed.environments?.prod?.targets).toEqual(['prod-a']); + }); + + it('omits the environments field when no env has any targets', () => { + const config = buildAwsTargetsConfig([targetA], { dev: new Set() }); + expect(config.environments).toBeUndefined(); + expect(() => AwsTargetsSchema.parse(config)).not.toThrow(); + }); + + it('throws on cross-validation when an environment references an unknown target', () => { + expect(() => + buildAwsTargetsConfig([targetA], { + dev: new Set(['dev-a', 'missing-target']), + }) + ).toThrowError(/unknown target.*missing-target/); + }); + + it('round-trips through aws-targets.json on disk and re-validates with AwsTargetsSchema', async () => { + const tmpDir = path.join(os.tmpdir(), `assign-targets-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(tmpDir, { recursive: true }); + try { + const filePath = path.join(tmpDir, 'aws-targets.json'); + const config = buildAwsTargetsConfig([targetA, targetB, targetC], { + dev: new Set(['dev-a', 'dev-b']), + prod: new Set(['prod-a']), + }); + await writeFile(filePath, JSON.stringify(config, null, 2)); + + const raw = await readFile(filePath, 'utf8'); + const parsed = AwsTargetsSchema.parse(JSON.parse(raw)); + expect(parsed.targets.map(t => t.name)).toEqual(['dev-a', 'dev-b', 'prod-a']); + expect(Object.keys(parsed.environments ?? {})).toEqual(['dev', 'prod']); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +beforeEach(() => { + // No-op; placeholder for symmetry with other test files. +}); diff --git a/src/cli/tui/screens/create/__tests__/EnvironmentStep.test.tsx b/src/cli/tui/screens/create/__tests__/EnvironmentStep.test.tsx new file mode 100644 index 000000000..5f26622b4 --- /dev/null +++ b/src/cli/tui/screens/create/__tests__/EnvironmentStep.test.tsx @@ -0,0 +1,168 @@ +import { ENVIRONMENT_PRESETS, EnvironmentStep } from '../EnvironmentStep'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +const flush = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + +describe('EnvironmentStep', () => { + it('exposes the dev / gamma / prod presets', () => { + expect(ENVIRONMENT_PRESETS).toEqual(['dev', 'gamma', 'prod']); + }); + + it('auto-skips when targetCount <= 1 (single-target setup)', async () => { + const onSkip = vi.fn(); + const onComplete = vi.fn(); + const { lastFrame } = render(); + await flush(); + expect(onSkip).toHaveBeenCalledTimes(1); + expect(onComplete).not.toHaveBeenCalled(); + expect(lastFrame() ?? '').toMatch(/Environment setup skipped/); + }); + + it('renders the y/n prompt with No as the default when targetCount > 1', () => { + const { lastFrame } = render( + undefined} onComplete={() => undefined} /> + ); + const frame = lastFrame() ?? ''; + expect(frame).toMatch(/Define deployment environments\?/); + expect(frame).toMatch(/3 targets/); + expect(frame).toMatch(/\(y\) Yes/); + expect(frame).toMatch(/\(n\) No \[default\]/); + }); + + it('skips with onSkip when the user presses "n"', async () => { + const onSkip = vi.fn(); + const onComplete = vi.fn(); + const { stdin } = render(); + stdin.write('n'); + await flush(); + expect(onSkip).toHaveBeenCalledTimes(1); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('skips with onSkip when the user presses Enter at the prompt (default)', async () => { + const onSkip = vi.fn(); + const onComplete = vi.fn(); + const { stdin } = render(); + stdin.write('\r'); + await flush(); + expect(onSkip).toHaveBeenCalledTimes(1); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('opens the preset selector on "y" and lists dev/gamma/prod plus custom + done', async () => { + const { stdin, lastFrame } = render( + undefined} onComplete={() => undefined} /> + ); + stdin.write('y'); + await flush(); + const frame = lastFrame() ?? ''; + expect(frame).toMatch(/Select environments:/); + expect(frame).toMatch(/\[ \] dev/); + expect(frame).toMatch(/\[ \] gamma/); + expect(frame).toMatch(/\[ \] prod/); + expect(frame).toMatch(/Add custom environment name…/); + expect(frame).toMatch(/Done/); + }); + + it('toggles a preset on Enter and shows the checkbox state', async () => { + const { stdin, lastFrame } = render( + undefined} onComplete={() => undefined} /> + ); + stdin.write('y'); + await flush(); + // Cursor starts at "dev" — toggle it on. + stdin.write('\r'); + await flush(); + expect(lastFrame() ?? '').toMatch(/\[x\] dev/); + }); + + it('completes with selected presets when the user picks Done', async () => { + const onComplete = vi.fn(); + const { stdin } = render( undefined} onComplete={onComplete} />); + stdin.write('y'); + await flush(); + // Toggle dev (cursor 0). + stdin.write('\r'); + await flush(); + // Down to gamma, toggle. + stdin.write('\u001B[B'); + await flush(); + stdin.write('\r'); + await flush(); + // Down to prod, skip toggle. Down to custom, skip. Down to Done. + stdin.write('\u001B[B'); + await flush(); + stdin.write('\u001B[B'); + await flush(); + stdin.write('\u001B[B'); + await flush(); + stdin.write('\r'); + await flush(); + expect(onComplete).toHaveBeenCalledWith(['dev', 'gamma']); + }); + + it('falls back to onSkip when the user picks Done with no presets toggled', async () => { + const onSkip = vi.fn(); + const onComplete = vi.fn(); + const { stdin } = render(); + stdin.write('y'); + await flush(); + // Move to "Done" (4 down: dev->gamma->prod->custom->done). + for (let i = 0; i < 4; i++) { + stdin.write('\u001B[B'); + await flush(); + } + stdin.write('\r'); + await flush(); + expect(onSkip).toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('lets the user enter a custom environment name and includes it in onComplete', async () => { + const onComplete = vi.fn(); + const { stdin } = render( undefined} onComplete={onComplete} />); + stdin.write('y'); + await flush(); + // Move to "Add custom environment name…" (down 3 times: dev->gamma->prod->custom). + for (let i = 0; i < 3; i++) { + stdin.write('\u001B[B'); + await flush(); + } + stdin.write('\r'); + await flush(); + // Type a valid custom name and confirm. + for (const ch of 'staging') { + stdin.write(ch); + await flush(10); + } + stdin.write('\r'); + await flush(); + expect(onComplete).toHaveBeenCalledWith(['staging']); + }); + + it('rejects an invalid custom name and surfaces an error', async () => { + const onComplete = vi.fn(); + const { stdin, lastFrame } = render( + undefined} onComplete={onComplete} /> + ); + stdin.write('y'); + await flush(); + for (let i = 0; i < 3; i++) { + stdin.write('\u001B[B'); + await flush(); + } + stdin.write('\r'); + await flush(); + // Type an invalid name (capital letter) and try to confirm. + for (const ch of 'Prod') { + stdin.write(ch); + await flush(10); + } + stdin.write('\r'); + await flush(); + expect(onComplete).not.toHaveBeenCalled(); + expect(lastFrame() ?? '').toMatch(/lowercase letter/); + }); +}); diff --git a/src/cli/tui/screens/create/index.ts b/src/cli/tui/screens/create/index.ts index 642b76744..fd5abd59a 100644 --- a/src/cli/tui/screens/create/index.ts +++ b/src/cli/tui/screens/create/index.ts @@ -1,2 +1,15 @@ export { CreateScreen } from './CreateScreen'; export { useCreateFlow } from './useCreateFlow'; +export { + ENVIRONMENT_PRESETS, + EnvironmentStep, + type EnvironmentPreset, + type EnvironmentStepProps, +} from './EnvironmentStep'; +export { + AssignTargetsPanel, + buildAwsTargetsConfig, + buildEnvironmentsSection, + type AssignTargetsPanelProps, + type EnvironmentAssignments, +} from './AssignTargetsPanel'; diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 319f970ec..531cbe3ca 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -1,5 +1,6 @@ import { ConfigIO } from '../../../../lib'; import type { AgentCoreMcpSpec, AgentCoreProjectSpec } from '../../../../schema'; +import { type EnvDeployResult, handleEnvDeploy } from '../../../commands/deploy/actions'; import { formatTargetStatus } from '../../../operations/deploy/gateway-status'; import { AwsTargetConfigUI, @@ -18,6 +19,7 @@ import { import { BOOTSTRAP, HELP_TEXT } from '../../constants'; import { useAwsTargetConfig } from '../../hooks'; import { InvokeScreen } from '../invoke'; +import { EnvironmentPicker } from './EnvironmentPicker'; import { type PreSynthesized, useDeployFlow } from './useDeployFlow'; import { Box, Text, useInput, useStdout } from 'ink'; import React, { useEffect, useMemo, useState } from 'react'; @@ -63,6 +65,10 @@ export function DeployScreen({ const [showResourceGraph, setShowResourceGraph] = useState(false); const [showDiff, setShowDiff] = useState(diffMode ?? false); const [mcpSpec, setMcpSpec] = useState(); + const [envChoice, setEnvChoice] = useState<'pending' | 'skipped' | 'running' | 'done'>('pending'); + const [envDeployResult, setEnvDeployResult] = useState(null); + const [selectedEnvName, setSelectedEnvName] = useState(null); + const [envDeployLog, setEnvDeployLog] = useState([]); // Load MCP spec for ResourceGraph const configIO = useMemo(() => new ConfigIO(), []); @@ -143,12 +149,20 @@ export function DeployScreen({ { isActive: isInteractive && !diffMode && !!context } ); + // Determine whether to show the env picker. Only relevant in interactive mode + // when the user has not yet made an env / single-target choice and the + // project has at least one environment defined. + const hasEnvironments = !!awsConfig.environments && Object.keys(awsConfig.environments).length > 0; + const showEnvPicker = isInteractive && !skipPreflight && !diffMode && hasEnvironments && envChoice === 'pending'; + // Auto-start deploy when AWS target is configured (or immediately when preSynthesized) useEffect(() => { + if (showEnvPicker) return; + if (envChoice === 'running' || envChoice === 'done') return; if (phase === 'idle' && (skipPreflight || awsConfig.isConfigured)) { startDeploy(); } - }, [phase, awsConfig.isConfigured, startDeploy, skipPreflight]); + }, [phase, awsConfig.isConfigured, startDeploy, skipPreflight, showEnvPicker, envChoice]); // Auto-confirm teardown when autoConfirm is enabled useEffect(() => { @@ -192,6 +206,39 @@ export function DeployScreen({ } }, [isInteractive, allSuccess, onExit]); + // Run multi-target deploy after the user picks an env in the TUI picker. + useEffect(() => { + if (envChoice !== 'running' || !selectedEnvName) return; + let cancelled = false; + void handleEnvDeploy({ + env: selectedEnvName, + // Honor the outer DeployScreen's autoConfirm prop so teardown deploys + // are NOT silently approved when the user opens the TUI without -y. + // Inside handleDeploy this gates the teardown-confirmation prompt. + autoConfirm: autoConfirm, + onLog: line => { + if (cancelled) return; + // Keep the last 20 lines so long deploys don't grow state unbounded. + setEnvDeployLog(prev => (prev.length >= 20 ? [...prev.slice(-19), line] : [...prev, line])); + }, + onProgress: (step, status) => { + if (cancelled) return; + const mark = status === 'success' ? '\u2713' : status === 'error' ? '\u2717' : '\u2026'; + setEnvDeployLog(prev => { + const next = [...prev, ` ${mark} ${step}`]; + return next.length > 20 ? next.slice(-20) : next; + }); + }, + }).then(result => { + if (cancelled) return; + setEnvDeployResult(result); + setEnvChoice('done'); + }); + return () => { + cancelled = true; + }; + }, [envChoice, selectedEnvName, autoConfirm]); + // Show invoke screen (only in interactive mode when selected from next steps) if (showInvoke && isInteractive) { return ; @@ -239,6 +286,59 @@ export function DeployScreen({ return null; } + // Env picker: only when environments are defined and user hasn't yet chosen. + if (showEnvPicker && awsConfig.environments) { + return ( + + { + setSelectedEnvName(name); + setEnvChoice('running'); + }} + onSkip={() => setEnvChoice('skipped')} + /> + + ); + } + + // Env deploy in flight or completed. + if (envChoice === 'running' || envChoice === 'done') { + return ( + + + Environment: {selectedEnvName} + {envChoice === 'running' && Deploying targets…} + {envDeployLog.length > 0 && ( + + {envDeployLog.map((line, idx) => ( + + {line} + + ))} + + )} + {envChoice === 'done' && envDeployResult && ( + + + {envDeployResult.success ? '\u2713' : '\u2717'} Environment "{envDeployResult.envName}"{' '} + {envDeployResult.success ? 'deployed' : 'deploy failed'} + + {envDeployResult.error && {envDeployResult.error}} + {envDeployResult.results.map((r, idx) => ( + + {r.success ? '\u2713' : '\u2717'} {r.targetName ?? '(unknown target)'} + {r.error ? ` — ${r.error}` : ''} + + ))} + + )} + + + ); + } + // Credentials prompt phase if (phase === 'credentials-prompt') { return ( diff --git a/src/cli/tui/screens/deploy/EnvironmentPicker.tsx b/src/cli/tui/screens/deploy/EnvironmentPicker.tsx new file mode 100644 index 000000000..5fbb1680e --- /dev/null +++ b/src/cli/tui/screens/deploy/EnvironmentPicker.tsx @@ -0,0 +1,77 @@ +import type { Environments } from '../../../../schema'; +import { Box, Text, useInput } from 'ink'; +import React, { useState } from 'react'; + +export interface EnvironmentPickerProps { + environments: Environments; + /** Called with the chosen environment name. */ + onSelect: (envName: string) => void; + /** Skip the picker and proceed to the standard single-target deploy. */ + onSkip: () => void; + isActive?: boolean; +} + +const SKIP_OPTION = '__skip__'; + +/** + * Pre-deploy environment picker. Lists the environments parsed from + * aws-targets.json plus a "Deploy single target" escape hatch. Arrow keys + * navigate, Enter selects, `s` skips. + */ +export function EnvironmentPicker({ environments, onSelect, onSkip, isActive = true }: EnvironmentPickerProps) { + const envNames = Object.keys(environments).sort(); + const items: { id: string; label: string; detail?: string }[] = envNames.map(name => { + const targets = environments[name]?.targets ?? []; + return { + id: name, + label: name, + detail: `${targets.length} target${targets.length === 1 ? '' : 's'}: ${targets.join(', ')}`, + }; + }); + items.push({ id: SKIP_OPTION, label: 'Deploy single target (skip environment)', detail: undefined }); + + const [cursor, setCursor] = useState(0); + + useInput( + (input, key) => { + if (key.upArrow) { + setCursor(c => (c - 1 + items.length) % items.length); + } else if (key.downArrow) { + setCursor(c => (c + 1) % items.length); + } else if (key.return) { + const choice = items[cursor]!; + if (choice.id === SKIP_OPTION) { + onSkip(); + } else { + onSelect(choice.id); + } + } else if (input === 's' || input === 'S') { + onSkip(); + } + }, + { isActive } + ); + + return ( + + Select an environment to deploy: + {items.map((item, idx) => { + const isCursor = idx === cursor; + return ( + + {isCursor ? '> ' : ' '} + + + {item.label} + + {item.detail && {` ${item.detail}`}} + + + ); + })} + + ↑/↓ to navigate · Enter to select · s to skip + + + ); +} diff --git a/src/cli/tui/screens/deploy/__tests__/DeployScreen.env.test.tsx b/src/cli/tui/screens/deploy/__tests__/DeployScreen.env.test.tsx new file mode 100644 index 000000000..6c7bf6de6 --- /dev/null +++ b/src/cli/tui/screens/deploy/__tests__/DeployScreen.env.test.tsx @@ -0,0 +1,82 @@ +import type { Environments } from '../../../../../schema'; +import { EnvironmentPicker } from '../EnvironmentPicker'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +const environments: Environments = { + dev: { targets: ['dev-a', 'dev-b'] }, + prod: { targets: ['prod-a'], overrides: { envVars: { LOG_LEVEL: 'INFO' } } }, +}; + +describe('EnvironmentPicker', () => { + it('renders one entry per environment plus a skip option', () => { + const { lastFrame } = render( + undefined} onSkip={() => undefined} /> + ); + const frame = lastFrame() ?? ''; + expect(frame).toMatch(/Select an environment to deploy:/); + expect(frame).toMatch(/dev/); + expect(frame).toMatch(/prod/); + expect(frame).toMatch(/2 targets: dev-a, dev-b/); + expect(frame).toMatch(/1 target: prod-a/); + expect(frame).toMatch(/Deploy single target \(skip environment\)/); + }); + + it('calls onSelect with the chosen environment when Enter is pressed', () => { + const onSelect = vi.fn(); + const onSkip = vi.fn(); + const { stdin } = render(); + // First entry is "dev" (alphabetical). Press Enter immediately. + stdin.write('\r'); + expect(onSelect).toHaveBeenCalledWith('dev'); + expect(onSkip).not.toHaveBeenCalled(); + }); + + it('navigates with arrow keys and selects the highlighted env', async () => { + const onSelect = vi.fn(); + const { stdin } = render( + undefined} /> + ); + // Down once -> "prod" (alphabetical: dev, prod, skip). + stdin.write('\u001B[B'); + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('\r'); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(onSelect).toHaveBeenCalledWith('prod'); + }); + + it('calls onSkip when the skip option is selected with Enter', async () => { + const onSkip = vi.fn(); + const { stdin } = render( + undefined} onSkip={onSkip} /> + ); + // Down twice from "dev" -> "prod" -> skip. + stdin.write('\u001B[B'); + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('\u001B[B'); + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('\r'); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(onSkip).toHaveBeenCalled(); + }); + + it('calls onSkip when the user presses "s"', () => { + const onSkip = vi.fn(); + const onSelect = vi.fn(); + const { stdin } = render(); + stdin.write('s'); + expect(onSkip).toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('renders no env entries when the environments map is empty (escape hatch only)', () => { + const { lastFrame } = render( + undefined} onSkip={() => undefined} /> + ); + const frame = lastFrame() ?? ''; + expect(frame).toMatch(/Deploy single target \(skip environment\)/); + expect(frame).not.toMatch(/dev/); + expect(frame).not.toMatch(/prod/); + }); +}); diff --git a/src/lib/schemas/io/config-io.ts b/src/lib/schemas/io/config-io.ts index a62b48e75..920afe016 100644 --- a/src/lib/schemas/io/config-io.ts +++ b/src/lib/schemas/io/config-io.ts @@ -1,9 +1,16 @@ -import type { AgentCoreCliMcpDefs, AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState } from '../../../schema'; +import type { + AgentCoreCliMcpDefs, + AgentCoreProjectSpec, + AwsDeploymentTarget, + DeployedState, + Environments, +} from '../../../schema'; import { AgentCoreCliMcpDefsSchema, AgentCoreProjectSpecSchema, AgentCoreRegionSchema, AwsDeploymentTargetsSchema, + AwsTargetsSchema, createValidatedDeployedStateSchema, } from '../../../schema'; import { @@ -120,12 +127,44 @@ export class ConfigIO { /** * Read and validate the AWS configuration file. + * Accepts both the legacy array shape (`AwsDeploymentTarget[]`) and the new + * object shape (`{ targets, environments? }`); returns the targets array + * either way. Use `readAwsTargetsFull()` to also retrieve the environments map. * Region is preserved as saved. Use resolveAWSDeploymentTargets() for environment/profile overrides. * TODO: Account is still overridden via AWS_PROFILE — consider moving to resolveAWSDeploymentTargets() for consistency. */ async readAWSDeploymentTargets(): Promise { + const { targets } = await this.readAwsTargetsFull(); + return targets; + } + + /** + * Read and validate the AWS configuration file as the full object form. + * Returns both the targets array and the optional `environments` map. + * Tolerant to either on-disk shape: legacy array files yield + * `environments: undefined`; new object files are validated through + * `AwsTargetsSchema` (incl. cross-validation that env target refs exist). + */ + async readAwsTargetsFull(): Promise<{ + targets: AwsDeploymentTarget[]; + environments?: Environments; + }> { const filePath = this.pathResolver.getAWSTargetsConfigPath(); - let targets = await this.readAndValidate(filePath, 'AWS Targets', AwsDeploymentTargetsSchema); + let targets: AwsDeploymentTarget[]; + let environments: Environments | undefined; + + // Peek at the on-disk shape so we can route to the correct schema. We + // intentionally use a single readAndValidate call per branch so existing + // ConfigNotFoundError / ConfigParseError / ConfigValidationError paths + // stay consistent for both shapes. + const peeked = await this.peekAwsTargetsShape(filePath); + if (peeked === 'object') { + const full = await this.readAndValidate(filePath, 'AWS Targets', AwsTargetsSchema); + targets = full.targets; + environments = full.environments; + } else { + targets = await this.readAndValidate(filePath, 'AWS Targets', AwsDeploymentTargetsSchema); + } // Override account from credentials if AWS_PROFILE is set if (process.env.AWS_PROFILE) { @@ -135,7 +174,34 @@ export class ConfigIO { } } - return targets; + return environments ? { targets, environments } : { targets }; + } + + /** + * Resolve the on-disk shape of aws-targets.json. Returns 'array' when the + * top-level JSON value is an array (legacy), 'object' otherwise. Throws + * the same errors as readAndValidate would for missing / unreadable / + * unparseable files so callers see a single failure surface. + */ + private async peekAwsTargetsShape(filePath: string): Promise<'array' | 'object'> { + if (!existsSync(filePath)) { + throw new ConfigNotFoundError(filePath, 'AWS Targets'); + } + let raw: string; + try { + raw = await readFile(filePath, 'utf-8'); + } catch (err: unknown) { + const normalizedError = err instanceof Error ? err : new Error('Unknown error'); + throw new ConfigReadError(filePath, normalizedError); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err: unknown) { + const normalizedError = err instanceof Error ? err : new Error('Invalid JSON'); + throw new ConfigParseError(filePath, normalizedError); + } + return Array.isArray(parsed) ? 'array' : 'object'; } /** @@ -181,13 +247,28 @@ export class ConfigIO { } /** - * Write and validate the AWS configuration file + * Write and validate the AWS configuration file (legacy array shape). */ async writeAWSDeploymentTargets(data: AwsDeploymentTarget[]): Promise { const filePath = this.pathResolver.getAWSTargetsConfigPath(); await this.validateAndWrite(filePath, 'AWS Targets', AwsDeploymentTargetsSchema, data); } + /** + * Write the full `{ targets, environments? }` object shape, validated against + * AwsTargetsSchema (incl. cross-validation that env target refs exist). + * When `environments` is undefined or empty the legacy array shape is written + * to preserve compatibility with older tooling that may still consume this file. + */ + async writeAwsTargetsFull(data: { targets: AwsDeploymentTarget[]; environments?: Environments }): Promise { + const filePath = this.pathResolver.getAWSTargetsConfigPath(); + if (!data.environments || Object.keys(data.environments).length === 0) { + await this.validateAndWrite(filePath, 'AWS Targets', AwsDeploymentTargetsSchema, data.targets); + return; + } + await this.validateAndWrite(filePath, 'AWS Targets', AwsTargetsSchema, data); + } + /** * Read and validate the deployed state file. * Validates that all target keys exist in aws-targets. diff --git a/src/schema/schemas/__tests__/aws-targets-environments.test.ts b/src/schema/schemas/__tests__/aws-targets-environments.test.ts new file mode 100644 index 000000000..c3990f29a --- /dev/null +++ b/src/schema/schemas/__tests__/aws-targets-environments.test.ts @@ -0,0 +1,193 @@ +import { + AwsTargetsSchema, + EnvironmentNameSchema, + EnvironmentOverridesSchema, + EnvironmentSchema, + EnvironmentsSchema, +} from '../aws-targets.js'; +import { describe, expect, it } from 'vitest'; + +const targetA = { name: 'dev-a', account: '111111111111', region: 'us-west-2' as const }; +const targetB = { name: 'dev-b', account: '222222222222', region: 'us-east-1' as const }; +const targetC = { name: 'prod-a', account: '333333333333', region: 'us-east-1' as const }; + +describe('EnvironmentNameSchema', () => { + it('accepts lowercase names with digits and hyphens', () => { + expect(EnvironmentNameSchema.safeParse('dev').success).toBe(true); + expect(EnvironmentNameSchema.safeParse('gamma').success).toBe(true); + expect(EnvironmentNameSchema.safeParse('prod').success).toBe(true); + expect(EnvironmentNameSchema.safeParse('us-west-2').success).toBe(true); + expect(EnvironmentNameSchema.safeParse('env1').success).toBe(true); + }); + + it('rejects names that do not match ^[a-z][a-z0-9-]*$', () => { + expect(EnvironmentNameSchema.safeParse('Prod').success).toBe(false); + expect(EnvironmentNameSchema.safeParse('1dev').success).toBe(false); + expect(EnvironmentNameSchema.safeParse('-dev').success).toBe(false); + expect(EnvironmentNameSchema.safeParse('dev_test').success).toBe(false); + expect(EnvironmentNameSchema.safeParse('').success).toBe(false); + expect(EnvironmentNameSchema.safeParse('DEV').success).toBe(false); + }); +}); + +describe('EnvironmentOverridesSchema', () => { + it('accepts envVars-only overrides', () => { + const result = EnvironmentOverridesSchema.safeParse({ + envVars: { LOG_LEVEL: 'DEBUG', STAGE: 'dev' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts an empty overrides object', () => { + expect(EnvironmentOverridesSchema.safeParse({}).success).toBe(true); + }); + + it('rejects unknown override fields', () => { + const result = EnvironmentOverridesSchema.safeParse({ + envVars: { A: 'b' }, + iamRoleArn: 'arn-something', + }); + expect(result.success).toBe(false); + }); + + it('rejects non-string envVar values', () => { + const result = EnvironmentOverridesSchema.safeParse({ + envVars: { LOG_LEVEL: 1 }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('EnvironmentSchema', () => { + it('accepts a minimal environment with one target', () => { + expect(EnvironmentSchema.safeParse({ targets: ['dev-a'] }).success).toBe(true); + }); + + it('accepts an environment with overrides', () => { + const result = EnvironmentSchema.safeParse({ + targets: ['dev-a', 'dev-b'], + overrides: { envVars: { LOG_LEVEL: 'DEBUG' } }, + }); + expect(result.success).toBe(true); + }); + + it('rejects an empty targets array', () => { + const result = EnvironmentSchema.safeParse({ targets: [] }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => /at least one target/i.test(i.message))).toBe(true); + } + }); + + it('rejects unknown top-level fields', () => { + const result = EnvironmentSchema.safeParse({ + targets: ['dev-a'], + description: 'extra', + }); + expect(result.success).toBe(false); + }); +}); + +describe('EnvironmentsSchema', () => { + it('parses multiple environments', () => { + const result = EnvironmentsSchema.safeParse({ + dev: { targets: ['dev-a'] }, + gamma: { targets: ['dev-a', 'dev-b'] }, + prod: { targets: ['prod-a'], overrides: { envVars: { LOG_LEVEL: 'INFO' } } }, + }); + expect(result.success).toBe(true); + }); + + it('rejects environments keyed by an invalid name', () => { + const result = EnvironmentsSchema.safeParse({ + Prod: { targets: ['prod-a'] }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('AwsTargetsSchema', () => { + it('parses a config without environments (backward compatible)', () => { + const result = AwsTargetsSchema.safeParse({ + targets: [targetA, targetB], + }); + expect(result.success).toBe(true); + }); + + it('parses a config with valid environments', () => { + const result = AwsTargetsSchema.safeParse({ + targets: [targetA, targetB, targetC], + environments: { + dev: { targets: ['dev-a', 'dev-b'] }, + prod: { + targets: ['prod-a'], + overrides: { envVars: { LOG_LEVEL: 'INFO' } }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects an environment that references an unknown target and lists available targets', () => { + const result = AwsTargetsSchema.safeParse({ + targets: [targetA, targetB], + environments: { + dev: { targets: ['dev-a', 'missing'] }, + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = result.error.issues.map(i => i.message).join('\n'); + expect(messages).toMatch(/unknown target "missing"/); + expect(messages).toMatch(/dev-a/); + expect(messages).toMatch(/dev-b/); + } + }); + + it('points the issue at the precise targets-array index for a bad ref', () => { + const result = AwsTargetsSchema.safeParse({ + targets: [targetA], + environments: { + dev: { targets: ['dev-a', 'missing'] }, + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find(i => i.path.includes('environments')); + expect(issue?.path).toEqual(['environments', 'dev', 'targets', 1]); + } + }); + + it('rejects an environment whose targets array is empty', () => { + const result = AwsTargetsSchema.safeParse({ + targets: [targetA], + environments: { + dev: { targets: [] }, + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid environment name at the AwsTargets level', () => { + const result = AwsTargetsSchema.safeParse({ + targets: [targetA], + environments: { + Prod: { targets: ['dev-a'] }, + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects overrides with unknown fields at the AwsTargets level', () => { + const result = AwsTargetsSchema.safeParse({ + targets: [targetA], + environments: { + dev: { + targets: ['dev-a'], + overrides: { envVars: { A: 'b' }, iamRoleArn: 'arn-x' }, + }, + }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/schema/schemas/aws-targets.ts b/src/schema/schemas/aws-targets.ts index 711e208dc..26cdc125a 100644 --- a/src/schema/schemas/aws-targets.ts +++ b/src/schema/schemas/aws-targets.ts @@ -74,3 +74,96 @@ export const AwsDeploymentTargetsSchema = z.array(AwsDeploymentTargetSchema).sup ); export type AwsDeploymentTargets = z.infer; + +// ============================================================================ +// Environment Name +// Format mirrors deployment target names but is lower-case only so that env +// names map cleanly to URL-safe / config-key contexts (e.g. `--env dev`). +// ============================================================================ + +export const EnvironmentNameSchema = z + .string() + .min(1) + .max(64) + .regex( + /^[a-z][a-z0-9-]*$/, + 'Environment name must start with a lowercase letter and contain only lowercase alphanumeric characters and hyphens' + ) + .describe('Unique identifier for a deployment environment'); + +export type EnvironmentName = z.infer; + +// ============================================================================ +// Environment Overrides +// In v1, only `envVars` may be overridden per environment. The schema is +// `strict` so unknown override fields are rejected up-front (forward-compat +// fields require an explicit schema bump). +// ============================================================================ + +export const EnvironmentOverridesSchema = z + .object({ + envVars: z.record(z.string(), z.string()).optional(), + }) + .strict(); + +export type EnvironmentOverrides = z.infer; + +// ============================================================================ +// Environment +// Maps an environment name to an ordered, non-empty list of target references +// (target names) plus optional in-memory overrides applied at deploy time. +// ============================================================================ + +export const EnvironmentSchema = z + .object({ + targets: z.array(DeploymentTargetNameSchema).min(1, 'Environment must reference at least one target'), + overrides: EnvironmentOverridesSchema.optional(), + }) + .strict(); + +export type Environment = z.infer; + +// ============================================================================ +// Environments +// Record keyed by environment name. Cross-validation that each `targets[]` +// entry refers to an existing target in the deployment targets array is added +// in the wrapping schema (see T2). +// ============================================================================ + +export const EnvironmentsSchema = z.record(EnvironmentNameSchema, EnvironmentSchema); + +export type Environments = z.infer; + +// ============================================================================ +// AWS Targets (object form) +// Backward-compatible object wrapper that pairs the existing targets array +// with an optional `environments` map. The plain-array `AwsDeploymentTargetsSchema` +// remains the authoritative on-disk shape for now; this object form is the +// foundation for T2's cross-validation and future migrations. +// ============================================================================ + +export const AwsTargetsSchema = z + .object({ + targets: AwsDeploymentTargetsSchema, + environments: EnvironmentsSchema.optional(), + }) + .superRefine((data, ctx) => { + if (!data.environments) return; + const availableTargets = data.targets.map(t => t.name); + const availableSet = new Set(availableTargets); + for (const [envName, env] of Object.entries(data.environments)) { + env.targets.forEach((targetRef, idx) => { + if (!availableSet.has(targetRef)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['environments', envName, 'targets', idx], + message: + `Environment "${envName}" references unknown target "${targetRef}". ` + + `Available targets: ${availableTargets.length > 0 ? availableTargets.join(', ') : '(none defined)'}`, + }); + } + }); + } + }); + +export type AwsTargets = z.infer;