Skip to content

Commit fdaa0fa

Browse files
author
agent
committed
feat(multi-environment-deploy): Target-to-environment assignment + write environments on create
1 parent 482d15c commit fdaa0fa

3 files changed

Lines changed: 364 additions & 0 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import type { AwsDeploymentTarget, Environments } from '../../../../schema';
2+
import { AwsTargetsSchema } from '../../../../schema';
3+
import { Box, Text, useInput } from 'ink';
4+
import React, { useState } from 'react';
5+
6+
/** Assignment matrix: environment name → set of selected target names. */
7+
export type EnvironmentAssignments = Record<string, Set<string>>;
8+
9+
export interface AssignTargetsPanelProps {
10+
targets: AwsDeploymentTarget[];
11+
envNames: string[];
12+
/** Optional initial assignments (defaults to empty for every env). */
13+
initial?: EnvironmentAssignments;
14+
/** Called with the final assignments when the user confirms. */
15+
onConfirm: (assignments: EnvironmentAssignments) => void;
16+
/** Called when the user backs out of this step. */
17+
onCancel: () => void;
18+
isActive?: boolean;
19+
}
20+
21+
/**
22+
* Interactive panel: cursor moves through an env-major / target-minor grid.
23+
* Space toggles the cell. Enter confirms. Esc cancels. The panel never writes
24+
* to disk; the parent wires the resulting matrix into aws-targets.json.
25+
*/
26+
export function AssignTargetsPanel({
27+
targets,
28+
envNames,
29+
initial,
30+
onConfirm,
31+
onCancel,
32+
isActive = true,
33+
}: AssignTargetsPanelProps) {
34+
const [assignments, setAssignments] = useState<EnvironmentAssignments>(() => {
35+
const seed: EnvironmentAssignments = {};
36+
for (const env of envNames) {
37+
seed[env] = new Set(initial?.[env] ?? []);
38+
}
39+
return seed;
40+
});
41+
const [envCursor, setEnvCursor] = useState(0);
42+
const [targetCursor, setTargetCursor] = useState(0);
43+
44+
const noEnvs = envNames.length === 0;
45+
const noTargets = targets.length === 0;
46+
47+
useInput(
48+
(input, key) => {
49+
if (noEnvs || noTargets) {
50+
if (key.return || key.escape) onCancel();
51+
return;
52+
}
53+
if (key.upArrow) {
54+
setTargetCursor(c => (c - 1 + targets.length) % targets.length);
55+
} else if (key.downArrow) {
56+
setTargetCursor(c => (c + 1) % targets.length);
57+
} else if (key.leftArrow) {
58+
setEnvCursor(c => (c - 1 + envNames.length) % envNames.length);
59+
} else if (key.rightArrow) {
60+
setEnvCursor(c => (c + 1) % envNames.length);
61+
} else if (input === ' ' || input === 'x' || input === 'X') {
62+
const env = envNames[envCursor]!;
63+
const target = targets[targetCursor]!;
64+
setAssignments(prev => {
65+
const next = { ...prev };
66+
const current = new Set(next[env] ?? []);
67+
if (current.has(target.name)) current.delete(target.name);
68+
else current.add(target.name);
69+
next[env] = current;
70+
return next;
71+
});
72+
} else if (key.return) {
73+
onConfirm(assignments);
74+
} else if (key.escape) {
75+
onCancel();
76+
}
77+
},
78+
{ isActive }
79+
);
80+
81+
if (noEnvs) {
82+
return (
83+
<Box flexDirection="column">
84+
<Text dimColor>(No environments to assign — skipping target assignment.)</Text>
85+
<Text dimColor>Press Enter to continue.</Text>
86+
</Box>
87+
);
88+
}
89+
if (noTargets) {
90+
return (
91+
<Box flexDirection="column">
92+
<Text dimColor>(No targets defined yet — environments will be created without target assignments.)</Text>
93+
<Text dimColor>Add targets later via aws-targets.json. Press Enter to continue.</Text>
94+
</Box>
95+
);
96+
}
97+
98+
return (
99+
<Box flexDirection="column">
100+
<Text bold>Assign targets to environments:</Text>
101+
<Box flexDirection="row" marginTop={1}>
102+
<Box width={20} flexDirection="column">
103+
<Text bold>Target \\ Env</Text>
104+
{targets.map((t, idx) => (
105+
<Text key={t.name} color={idx === targetCursor ? 'cyan' : undefined}>
106+
{idx === targetCursor ? '> ' : ' '}
107+
{t.name}
108+
</Text>
109+
))}
110+
</Box>
111+
{envNames.map((env, envIdx) => {
112+
const assigned = assignments[env] ?? new Set<string>();
113+
return (
114+
<Box key={env} width={12} flexDirection="column">
115+
<Text bold color={envIdx === envCursor ? 'cyan' : undefined}>
116+
{env}
117+
</Text>
118+
{targets.map((t, tIdx) => {
119+
const isCursor = envIdx === envCursor && tIdx === targetCursor;
120+
const checked = assigned.has(t.name);
121+
return (
122+
<Text key={t.name} color={isCursor ? 'cyan' : undefined}>
123+
{isCursor ? '> ' : ' '}
124+
{checked ? '[x]' : '[ ]'}
125+
</Text>
126+
);
127+
})}
128+
</Box>
129+
);
130+
})}
131+
</Box>
132+
<Box marginTop={1}>
133+
<Text dimColor>↑/↓ row · ←/→ env · Space toggle · Enter confirm · Esc cancel</Text>
134+
</Box>
135+
</Box>
136+
);
137+
}
138+
139+
/**
140+
* Build the `environments` section of aws-targets.json from an assignment
141+
* matrix. Drops environments with zero targets so the resulting object always
142+
* passes EnvironmentSchema's `min(1)` rule. Returns `undefined` when no
143+
* environment ends up with any targets — that signals the caller should omit
144+
* the `environments` field entirely.
145+
*/
146+
export function buildEnvironmentsSection(assignments: EnvironmentAssignments): Environments | undefined {
147+
const result: Environments = {};
148+
for (const [name, members] of Object.entries(assignments)) {
149+
const targets = Array.from(members).filter(Boolean);
150+
if (targets.length === 0) continue;
151+
result[name] = { targets };
152+
}
153+
return Object.keys(result).length > 0 ? result : undefined;
154+
}
155+
156+
/**
157+
* Build a complete AwsTargets payload (object form) from a target list and an
158+
* assignment matrix, validated against AwsTargetsSchema (incl. cross-validation
159+
* that every environment target ref exists in `targets[]`). Throws ZodError
160+
* when invalid.
161+
*/
162+
export function buildAwsTargetsConfig(
163+
targets: AwsDeploymentTarget[],
164+
assignments: EnvironmentAssignments
165+
): { targets: AwsDeploymentTarget[]; environments?: Environments } {
166+
const environments = buildEnvironmentsSection(assignments);
167+
const candidate = { targets, ...(environments ? { environments } : {}) };
168+
// Run the schema (incl. superRefine) so callers get a guaranteed-valid object.
169+
return AwsTargetsSchema.parse(candidate);
170+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import type { AwsDeploymentTarget } from '../../../../../schema';
2+
import { AwsTargetsSchema } from '../../../../../schema';
3+
import {
4+
AssignTargetsPanel,
5+
type EnvironmentAssignments,
6+
buildAwsTargetsConfig,
7+
buildEnvironmentsSection,
8+
} from '../AssignTargetsPanel';
9+
import { render } from 'ink-testing-library';
10+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
11+
import * as os from 'node:os';
12+
import * as path from 'node:path';
13+
import React from 'react';
14+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
15+
16+
const flush = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms));
17+
18+
const targetA: AwsDeploymentTarget = { name: 'dev-a', account: '111111111111', region: 'us-west-2' };
19+
const targetB: AwsDeploymentTarget = { name: 'dev-b', account: '222222222222', region: 'us-east-1' };
20+
const targetC: AwsDeploymentTarget = { name: 'prod-a', account: '333333333333', region: 'us-east-1' };
21+
22+
describe('AssignTargetsPanel (UI)', () => {
23+
it('renders header columns for each environment and rows for each target', () => {
24+
const { lastFrame } = render(
25+
<AssignTargetsPanel
26+
targets={[targetA, targetB, targetC]}
27+
envNames={['dev', 'prod']}
28+
onConfirm={() => undefined}
29+
onCancel={() => undefined}
30+
/>
31+
);
32+
const frame = lastFrame() ?? '';
33+
expect(frame).toMatch(/Assign targets to environments:/);
34+
expect(frame).toMatch(/dev/);
35+
expect(frame).toMatch(/prod/);
36+
expect(frame).toMatch(/dev-a/);
37+
expect(frame).toMatch(/dev-b/);
38+
expect(frame).toMatch(/prod-a/);
39+
});
40+
41+
it('toggles the cell at the cursor with Space and surfaces it to onConfirm', async () => {
42+
const onConfirm = vi.fn();
43+
const { stdin } = render(
44+
<AssignTargetsPanel
45+
targets={[targetA, targetB]}
46+
envNames={['dev', 'prod']}
47+
onConfirm={onConfirm}
48+
onCancel={() => undefined}
49+
/>
50+
);
51+
// Cursor starts at (env=dev, target=dev-a). Toggle on.
52+
stdin.write(' ');
53+
await flush();
54+
// ↓ to dev-b, toggle on.
55+
stdin.write('\u001B[B');
56+
await flush();
57+
stdin.write(' ');
58+
await flush();
59+
// → to prod, ↑ back to dev-a... easier: from dev-b, → to prod (still at dev-b row), toggle on.
60+
stdin.write('\u001B[C');
61+
await flush();
62+
stdin.write(' ');
63+
await flush();
64+
stdin.write('\r');
65+
await flush();
66+
expect(onConfirm).toHaveBeenCalledTimes(1);
67+
const result = onConfirm.mock.calls[0]![0] as EnvironmentAssignments;
68+
expect(Array.from(result.dev ?? [])).toEqual(['dev-a', 'dev-b']);
69+
expect(Array.from(result.prod ?? [])).toEqual(['dev-b']);
70+
});
71+
72+
it('cancels via Esc', async () => {
73+
const onCancel = vi.fn();
74+
const onConfirm = vi.fn();
75+
const { stdin } = render(
76+
<AssignTargetsPanel targets={[targetA]} envNames={['dev']} onConfirm={onConfirm} onCancel={onCancel} />
77+
);
78+
stdin.write('\u001B');
79+
await flush();
80+
expect(onCancel).toHaveBeenCalledTimes(1);
81+
expect(onConfirm).not.toHaveBeenCalled();
82+
});
83+
84+
it('renders an empty-targets fallback message when no targets are defined', () => {
85+
const { lastFrame } = render(
86+
<AssignTargetsPanel
87+
targets={[]}
88+
envNames={['dev', 'prod']}
89+
onConfirm={() => undefined}
90+
onCancel={() => undefined}
91+
/>
92+
);
93+
expect(lastFrame() ?? '').toMatch(/No targets defined yet/);
94+
});
95+
96+
it('renders a no-environments fallback when envNames is empty', () => {
97+
const { lastFrame } = render(
98+
<AssignTargetsPanel targets={[targetA]} envNames={[]} onConfirm={() => undefined} onCancel={() => undefined} />
99+
);
100+
expect(lastFrame() ?? '').toMatch(/No environments to assign/);
101+
});
102+
});
103+
104+
describe('buildEnvironmentsSection (serialization)', () => {
105+
it('serializes assignments into the schema-shaped environments map', () => {
106+
const assignments: EnvironmentAssignments = {
107+
dev: new Set(['dev-a', 'dev-b']),
108+
prod: new Set(['prod-a']),
109+
};
110+
expect(buildEnvironmentsSection(assignments)).toEqual({
111+
dev: { targets: ['dev-a', 'dev-b'] },
112+
prod: { targets: ['prod-a'] },
113+
});
114+
});
115+
116+
it('drops environments with zero assigned targets', () => {
117+
const assignments: EnvironmentAssignments = {
118+
dev: new Set(['dev-a']),
119+
empty: new Set(),
120+
};
121+
expect(buildEnvironmentsSection(assignments)).toEqual({
122+
dev: { targets: ['dev-a'] },
123+
});
124+
});
125+
126+
it('returns undefined when every environment is empty', () => {
127+
const assignments: EnvironmentAssignments = { dev: new Set(), prod: new Set() };
128+
expect(buildEnvironmentsSection(assignments)).toBeUndefined();
129+
});
130+
});
131+
132+
describe('buildAwsTargetsConfig (schema-validated)', () => {
133+
it('produces an object that AwsTargetsSchema accepts (incl. cross-validation)', () => {
134+
const config = buildAwsTargetsConfig([targetA, targetB, targetC], {
135+
dev: new Set(['dev-a', 'dev-b']),
136+
prod: new Set(['prod-a']),
137+
});
138+
// buildAwsTargetsConfig internally calls AwsTargetsSchema.parse, so we
139+
// re-validate here as a sanity check that the returned object is stable.
140+
const reparsed = AwsTargetsSchema.parse(config);
141+
expect(reparsed.targets).toHaveLength(3);
142+
expect(reparsed.environments?.dev?.targets).toEqual(['dev-a', 'dev-b']);
143+
expect(reparsed.environments?.prod?.targets).toEqual(['prod-a']);
144+
});
145+
146+
it('omits the environments field when no env has any targets', () => {
147+
const config = buildAwsTargetsConfig([targetA], { dev: new Set() });
148+
expect(config.environments).toBeUndefined();
149+
expect(() => AwsTargetsSchema.parse(config)).not.toThrow();
150+
});
151+
152+
it('throws on cross-validation when an environment references an unknown target', () => {
153+
expect(() =>
154+
buildAwsTargetsConfig([targetA], {
155+
dev: new Set(['dev-a', 'missing-target']),
156+
})
157+
).toThrowError(/unknown target "missing-target"/);
158+
});
159+
160+
it('round-trips through aws-targets.json on disk and re-validates with AwsTargetsSchema', async () => {
161+
const tmpDir = path.join(os.tmpdir(), `assign-targets-${Date.now()}-${Math.random().toString(36).slice(2)}`);
162+
await mkdir(tmpDir, { recursive: true });
163+
try {
164+
const filePath = path.join(tmpDir, 'aws-targets.json');
165+
const config = buildAwsTargetsConfig([targetA, targetB, targetC], {
166+
dev: new Set(['dev-a', 'dev-b']),
167+
prod: new Set(['prod-a']),
168+
});
169+
await writeFile(filePath, JSON.stringify(config, null, 2));
170+
171+
const raw = await readFile(filePath, 'utf8');
172+
const parsed = AwsTargetsSchema.parse(JSON.parse(raw));
173+
expect(parsed.targets.map(t => t.name)).toEqual(['dev-a', 'dev-b', 'prod-a']);
174+
expect(Object.keys(parsed.environments ?? {})).toEqual(['dev', 'prod']);
175+
} finally {
176+
await rm(tmpDir, { recursive: true, force: true });
177+
}
178+
});
179+
});
180+
181+
afterEach(() => {
182+
vi.restoreAllMocks();
183+
});
184+
185+
beforeEach(() => {
186+
// No-op; placeholder for symmetry with other test files.
187+
});

src/cli/tui/screens/create/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@ export {
66
type EnvironmentPreset,
77
type EnvironmentStepProps,
88
} from './EnvironmentStep';
9+
export {
10+
AssignTargetsPanel,
11+
buildAwsTargetsConfig,
12+
buildEnvironmentsSection,
13+
type AssignTargetsPanelProps,
14+
type EnvironmentAssignments,
15+
} from './AssignTargetsPanel';

0 commit comments

Comments
 (0)