@ankhorage/infra 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +43 -2
- package/dist/ankh.provider.d.ts +10 -0
- package/dist/ankh.provider.d.ts.map +1 -0
- package/dist/ankh.provider.js +27 -0
- package/dist/ankh.provider.js.map +1 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +39 -0
- package/dist/cli.js.map +1 -0
- package/dist/commandContext.d.ts +12 -0
- package/dist/commandContext.d.ts.map +1 -0
- package/dist/commandContext.js +15 -0
- package/dist/commandContext.js.map +1 -0
- package/dist/commands.d.ts +95 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +256 -0
- package/dist/commands.js.map +1 -0
- package/dist/infraValidation.d.ts +3 -0
- package/dist/infraValidation.d.ts.map +1 -0
- package/dist/infraValidation.js +58 -0
- package/dist/infraValidation.js.map +1 -0
- package/dist/metadata/index.d.ts +2 -0
- package/dist/metadata/index.d.ts.map +1 -0
- package/dist/metadata/index.js +2 -0
- package/dist/metadata/index.js.map +1 -0
- package/dist/packageMetadata.d.ts +10 -0
- package/dist/packageMetadata.d.ts.map +1 -0
- package/dist/packageMetadata.js +17 -0
- package/dist/packageMetadata.js.map +1 -0
- package/dist/project.d.ts +14 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +202 -0
- package/dist/project.js.map +1 -0
- package/dist/projectInfrastructure.d.ts +22 -0
- package/dist/projectInfrastructure.d.ts.map +1 -0
- package/dist/projectInfrastructure.js +120 -0
- package/dist/projectInfrastructure.js.map +1 -0
- package/dist/readme-usage.d.ts +2 -0
- package/dist/readme-usage.d.ts.map +1 -0
- package/dist/readme-usage.js +30 -0
- package/dist/readme-usage.js.map +1 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +87 -0
- package/dist/runtime.js.map +1 -0
- package/dist/testSupport.d.ts +24 -0
- package/dist/testSupport.d.ts.map +1 -0
- package/dist/testSupport.js +73 -0
- package/dist/testSupport.js.map +1 -0
- package/package.json +18 -3
- package/src/ankh.provider.test.ts +32 -0
- package/src/ankh.provider.ts +53 -0
- package/src/cli.test.ts +137 -0
- package/src/cli.ts +67 -0
- package/src/commandContext.ts +27 -0
- package/src/commands.test.ts +219 -0
- package/src/commands.ts +404 -0
- package/src/infraValidation.ts +90 -0
- package/src/metadata/index.ts +7 -0
- package/src/package.test.ts +27 -0
- package/src/packageMetadata.ts +24 -0
- package/src/project.test.ts +218 -0
- package/src/project.ts +270 -0
- package/src/projectInfrastructure.test.ts +207 -0
- package/src/projectInfrastructure.ts +177 -0
- package/src/readme-usage.ts +30 -0
- package/src/runtime.test.ts +124 -0
- package/src/runtime.ts +127 -0
- package/src/testSupport.ts +112 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createProviderCommandDescriptors,
|
|
5
|
+
findInfraCommandByStandaloneName,
|
|
6
|
+
INFRA_COMMANDS,
|
|
7
|
+
renderRootHelp,
|
|
8
|
+
runInfraCommand,
|
|
9
|
+
} from './commands.js';
|
|
10
|
+
import { createCapturedCommandContext } from './testSupport.js';
|
|
11
|
+
import { createAppManifest } from './testSupport.js';
|
|
12
|
+
|
|
13
|
+
describe('infra command table', () => {
|
|
14
|
+
test('stays aligned across provider descriptors and standalone names', () => {
|
|
15
|
+
const providerDescriptors = createProviderCommandDescriptors();
|
|
16
|
+
|
|
17
|
+
expect(INFRA_COMMANDS.map((command) => command.standaloneName)).toEqual([
|
|
18
|
+
'validate',
|
|
19
|
+
'generate',
|
|
20
|
+
'status',
|
|
21
|
+
'up',
|
|
22
|
+
'down',
|
|
23
|
+
]);
|
|
24
|
+
expect(providerDescriptors.map((command) => command.path.join(' '))).toEqual([
|
|
25
|
+
'validate',
|
|
26
|
+
'generate',
|
|
27
|
+
'status',
|
|
28
|
+
'up',
|
|
29
|
+
'down',
|
|
30
|
+
]);
|
|
31
|
+
expect(JSON.stringify(providerDescriptors)).not.toContain('port-forward');
|
|
32
|
+
expect(JSON.stringify(providerDescriptors)).not.toContain('portForward');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('root help only exposes the locked command surface', () => {
|
|
36
|
+
const help = renderRootHelp('0.2.1');
|
|
37
|
+
|
|
38
|
+
expect(help).toContain('validate');
|
|
39
|
+
expect(help).toContain('generate');
|
|
40
|
+
expect(help).toContain('status');
|
|
41
|
+
expect(help).toContain('up');
|
|
42
|
+
expect(help).toContain('down');
|
|
43
|
+
expect(help).not.toContain('port-forward');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('validate prints warnings without failing', async () => {
|
|
47
|
+
const command = findInfraCommandByStandaloneName('validate');
|
|
48
|
+
const captured = createCapturedCommandContext('/workspace');
|
|
49
|
+
|
|
50
|
+
if (command === null) {
|
|
51
|
+
throw new Error('validate command not found');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const result = await runInfraCommand(
|
|
55
|
+
{
|
|
56
|
+
argv: ['shop'],
|
|
57
|
+
command,
|
|
58
|
+
context: captured.context,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
services: {
|
|
62
|
+
resolveProject() {
|
|
63
|
+
return Promise.resolve({
|
|
64
|
+
appsRoot: '/workspace/apps',
|
|
65
|
+
workspaceRoot: '/workspace',
|
|
66
|
+
manifestPath: '/workspace/apps/shop/ankh.config.json',
|
|
67
|
+
projectId: 'shop',
|
|
68
|
+
projectPath: '/workspace/apps/shop',
|
|
69
|
+
manifest: createAppManifest('shop', { plugins: [] }),
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
validateInfraSupport() {
|
|
73
|
+
return ['warning-1'];
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(result.exitCode).toBe(0);
|
|
80
|
+
expect(captured.stdout.value).toContain('infraConfigSupport: warnings');
|
|
81
|
+
expect(captured.stderr.value).toContain('warning-1');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('generate reports skipped studio results', async () => {
|
|
85
|
+
const command = findInfraCommandByStandaloneName('generate');
|
|
86
|
+
const captured = createCapturedCommandContext('/workspace');
|
|
87
|
+
|
|
88
|
+
if (command === null) {
|
|
89
|
+
throw new Error('generate command not found');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const result = await runInfraCommand(
|
|
93
|
+
{
|
|
94
|
+
argv: ['studio'],
|
|
95
|
+
command,
|
|
96
|
+
context: captured.context,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
services: {
|
|
100
|
+
resolveProject() {
|
|
101
|
+
return Promise.resolve({
|
|
102
|
+
appsRoot: '/workspace/apps',
|
|
103
|
+
workspaceRoot: '/workspace',
|
|
104
|
+
manifestPath: '/workspace/apps/studio/ankh.config.json',
|
|
105
|
+
projectId: 'studio',
|
|
106
|
+
projectPath: '/workspace/apps/studio',
|
|
107
|
+
manifest: createAppManifest('studio', { plugins: [] }),
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
syncProjectInfrastructure() {
|
|
111
|
+
return Promise.resolve({
|
|
112
|
+
generated: 0,
|
|
113
|
+
removed: 0,
|
|
114
|
+
warnings: [],
|
|
115
|
+
skipped: {
|
|
116
|
+
reason: 'apps/studio is the dashboard and is not a generated app target.',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(result.exitCode).toBe(0);
|
|
125
|
+
expect(captured.stdout.value).toContain('Skipped: apps/studio');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('status, up, and down use the runtime script service with the expected sequencing', async () => {
|
|
129
|
+
const events: string[] = [];
|
|
130
|
+
const status = findInfraCommandByStandaloneName('status');
|
|
131
|
+
const up = findInfraCommandByStandaloneName('up');
|
|
132
|
+
const down = findInfraCommandByStandaloneName('down');
|
|
133
|
+
|
|
134
|
+
if (status === null || up === null || down === null) {
|
|
135
|
+
throw new Error('missing infra commands');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const services = {
|
|
139
|
+
resolveProject() {
|
|
140
|
+
events.push('resolve-project');
|
|
141
|
+
return Promise.resolve({
|
|
142
|
+
appsRoot: '/workspace/apps',
|
|
143
|
+
workspaceRoot: '/workspace',
|
|
144
|
+
manifestPath: '/workspace/apps/shop/ankh.config.json',
|
|
145
|
+
projectId: 'shop',
|
|
146
|
+
projectPath: '/workspace/apps/shop',
|
|
147
|
+
manifest: createAppManifest('shop', {
|
|
148
|
+
deployment: { target: 'minikube', monitoring: false },
|
|
149
|
+
plugins: [],
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
resolveProjectInfrastructureTarget() {
|
|
154
|
+
events.push('resolve-target');
|
|
155
|
+
return Promise.resolve('minikube');
|
|
156
|
+
},
|
|
157
|
+
syncProjectInfrastructure() {
|
|
158
|
+
events.push('generate');
|
|
159
|
+
return Promise.resolve({ generated: 1, removed: 0, warnings: [] });
|
|
160
|
+
},
|
|
161
|
+
runProjectInfraScript(args: { readonly script: string }) {
|
|
162
|
+
events.push(`script:${args.script}`);
|
|
163
|
+
return Promise.resolve();
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
await runInfraCommand(
|
|
168
|
+
{
|
|
169
|
+
argv: ['shop'],
|
|
170
|
+
command: status,
|
|
171
|
+
context: createCapturedCommandContext('/workspace').context,
|
|
172
|
+
},
|
|
173
|
+
{ services },
|
|
174
|
+
);
|
|
175
|
+
await runInfraCommand(
|
|
176
|
+
{ argv: ['shop'], command: up, context: createCapturedCommandContext('/workspace').context },
|
|
177
|
+
{ services },
|
|
178
|
+
);
|
|
179
|
+
await runInfraCommand(
|
|
180
|
+
{
|
|
181
|
+
argv: ['shop'],
|
|
182
|
+
command: down,
|
|
183
|
+
context: createCapturedCommandContext('/workspace').context,
|
|
184
|
+
},
|
|
185
|
+
{ services },
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(events).toEqual([
|
|
189
|
+
'resolve-project',
|
|
190
|
+
'resolve-target',
|
|
191
|
+
'script:status',
|
|
192
|
+
'resolve-project',
|
|
193
|
+
'generate',
|
|
194
|
+
'resolve-target',
|
|
195
|
+
'script:up',
|
|
196
|
+
'resolve-project',
|
|
197
|
+
'resolve-target',
|
|
198
|
+
'script:down',
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('rejects extra arguments for a command', async () => {
|
|
203
|
+
const command = findInfraCommandByStandaloneName('validate');
|
|
204
|
+
const captured = createCapturedCommandContext('/workspace');
|
|
205
|
+
|
|
206
|
+
if (command === null) {
|
|
207
|
+
throw new Error('validate command not found');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const result = await runInfraCommand({
|
|
211
|
+
argv: ['shop', 'extra'],
|
|
212
|
+
command,
|
|
213
|
+
context: captured.context,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(result.exitCode).toBe(1);
|
|
217
|
+
expect(captured.stderr.value).toContain('accepts at most one project argument');
|
|
218
|
+
});
|
|
219
|
+
});
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import type { AnkhCapabilityId, AnkhCommandDescriptor } from '@ankhorage/contracts/cli';
|
|
2
|
+
|
|
3
|
+
import type { InfraCommandContext, InfraCommandRunResult } from './commandContext.js';
|
|
4
|
+
import { validateInfraSupport } from './infraValidation.js';
|
|
5
|
+
import { INFRA_CAPABILITIES, INFRA_COMMAND_CATEGORY } from './packageMetadata.js';
|
|
6
|
+
import type { ResolvedInfraProject } from './project.js';
|
|
7
|
+
import { resolveInfraProject } from './project.js';
|
|
8
|
+
import type { InfraSyncResult } from './projectInfrastructure.js';
|
|
9
|
+
import {
|
|
10
|
+
resolveProjectInfrastructureTarget,
|
|
11
|
+
syncProjectInfrastructure,
|
|
12
|
+
} from './projectInfrastructure.js';
|
|
13
|
+
import type { InfraLifecycleScript } from './runtime.js';
|
|
14
|
+
import { runProjectInfraScript } from './runtime.js';
|
|
15
|
+
|
|
16
|
+
type InfraCommandName = 'down' | 'generate' | 'status' | 'up' | 'validate';
|
|
17
|
+
|
|
18
|
+
interface InfraCommandRunRequest {
|
|
19
|
+
readonly context: InfraCommandContext;
|
|
20
|
+
readonly projectId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type InfraCommandImplementation = (
|
|
24
|
+
request: InfraCommandRunRequest,
|
|
25
|
+
services: InfraCommandServices,
|
|
26
|
+
) => Promise<InfraCommandRunResult>;
|
|
27
|
+
|
|
28
|
+
export interface InfraCommandDefinition {
|
|
29
|
+
readonly capability: AnkhCapabilityId;
|
|
30
|
+
readonly path: readonly [InfraCommandName];
|
|
31
|
+
readonly standaloneName: InfraCommandName;
|
|
32
|
+
readonly summary: string;
|
|
33
|
+
readonly run: InfraCommandImplementation;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface InfraCommandInvocation {
|
|
37
|
+
readonly argv: readonly string[];
|
|
38
|
+
readonly command: InfraCommandDefinition;
|
|
39
|
+
readonly context: InfraCommandContext;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface InfraCommandServices {
|
|
43
|
+
readonly resolveProject: (options: {
|
|
44
|
+
readonly cwd: string;
|
|
45
|
+
readonly projectId?: string;
|
|
46
|
+
}) => Promise<ResolvedInfraProject>;
|
|
47
|
+
readonly resolveProjectInfrastructureTarget: (args: {
|
|
48
|
+
readonly manifest: ResolvedInfraProject['manifest'];
|
|
49
|
+
readonly projectPath: string;
|
|
50
|
+
}) => Promise<string | null>;
|
|
51
|
+
readonly runProjectInfraScript: (args: {
|
|
52
|
+
readonly context: InfraCommandContext;
|
|
53
|
+
readonly projectId: string;
|
|
54
|
+
readonly projectPath: string;
|
|
55
|
+
readonly script: InfraLifecycleScript;
|
|
56
|
+
readonly target: string;
|
|
57
|
+
}) => Promise<void>;
|
|
58
|
+
readonly syncProjectInfrastructure: (args: {
|
|
59
|
+
readonly manifest: ResolvedInfraProject['manifest'];
|
|
60
|
+
readonly projectId: string;
|
|
61
|
+
readonly projectPath: string;
|
|
62
|
+
}) => Promise<InfraSyncResult>;
|
|
63
|
+
readonly validateInfraSupport: typeof validateInfraSupport;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RunInfraCommandOptions {
|
|
67
|
+
readonly runCommandImpl?: RunInfraCommandImpl;
|
|
68
|
+
readonly services?: Partial<InfraCommandServices>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type RunInfraCommandImpl = (
|
|
72
|
+
request: InfraCommandInvocation,
|
|
73
|
+
options?: RunInfraCommandOptions,
|
|
74
|
+
) => Promise<InfraCommandRunResult>;
|
|
75
|
+
|
|
76
|
+
const COMMAND_CAPABILITIES = {
|
|
77
|
+
validate: INFRA_CAPABILITIES[0],
|
|
78
|
+
generate: INFRA_CAPABILITIES[1],
|
|
79
|
+
status: INFRA_CAPABILITIES[2],
|
|
80
|
+
up: INFRA_CAPABILITIES[3],
|
|
81
|
+
down: INFRA_CAPABILITIES[4],
|
|
82
|
+
} as const satisfies Record<InfraCommandName, AnkhCapabilityId>;
|
|
83
|
+
|
|
84
|
+
export const INFRA_COMMANDS = [
|
|
85
|
+
{
|
|
86
|
+
standaloneName: 'validate',
|
|
87
|
+
path: ['validate'],
|
|
88
|
+
capability: COMMAND_CAPABILITIES.validate,
|
|
89
|
+
summary: 'Validate infra support for a project manifest.',
|
|
90
|
+
run: runValidateCommand,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
standaloneName: 'generate',
|
|
94
|
+
path: ['generate'],
|
|
95
|
+
capability: COMMAND_CAPABILITIES.generate,
|
|
96
|
+
summary: 'Generate infra artifacts and update the infra ledger for a project.',
|
|
97
|
+
run: runGenerateCommand,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
standaloneName: 'status',
|
|
101
|
+
path: ['status'],
|
|
102
|
+
capability: COMMAND_CAPABILITIES.status,
|
|
103
|
+
summary: 'Run the generated runtime status script for a project.',
|
|
104
|
+
run: runStatusCommand,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
standaloneName: 'up',
|
|
108
|
+
path: ['up'],
|
|
109
|
+
capability: COMMAND_CAPABILITIES.up,
|
|
110
|
+
summary: 'Generate infra artifacts, then run the generated up script.',
|
|
111
|
+
run: runUpCommand,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
standaloneName: 'down',
|
|
115
|
+
path: ['down'],
|
|
116
|
+
capability: COMMAND_CAPABILITIES.down,
|
|
117
|
+
summary: 'Run the generated down script for a project.',
|
|
118
|
+
run: runDownCommand,
|
|
119
|
+
},
|
|
120
|
+
] as const satisfies readonly InfraCommandDefinition[];
|
|
121
|
+
|
|
122
|
+
export async function runInfraCommand(
|
|
123
|
+
request: InfraCommandInvocation,
|
|
124
|
+
options: RunInfraCommandOptions = {},
|
|
125
|
+
): Promise<InfraCommandRunResult> {
|
|
126
|
+
const services = createInfraCommandServices(options.services);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const parsedArgs = parseCommandArguments(request.command, request.argv);
|
|
130
|
+
if (parsedArgs.kind === 'help') {
|
|
131
|
+
request.context.writeStdout(renderCommandHelp(request.command));
|
|
132
|
+
return { exitCode: 0 };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return await request.command.run(
|
|
136
|
+
{
|
|
137
|
+
context: request.context,
|
|
138
|
+
projectId: parsedArgs.projectId,
|
|
139
|
+
},
|
|
140
|
+
services,
|
|
141
|
+
);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
request.context.writeStderr(renderCommandFailure(request.command.standaloneName, error));
|
|
144
|
+
return { exitCode: 1 };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createProviderCommandDescriptors(): readonly AnkhCommandDescriptor[] {
|
|
149
|
+
return INFRA_COMMANDS.map((command) => ({
|
|
150
|
+
capability: command.capability,
|
|
151
|
+
path: command.path,
|
|
152
|
+
summary: command.summary,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function findInfraCommandByStandaloneName(value: string): InfraCommandDefinition | null {
|
|
157
|
+
return INFRA_COMMANDS.find((command) => command.standaloneName === value) ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function renderRootHelp(version: string): string {
|
|
161
|
+
const commandLines = INFRA_COMMANDS.map(
|
|
162
|
+
(command) => ` ${command.standaloneName.padEnd(8, ' ')} ${command.summary}`,
|
|
163
|
+
).join('\n');
|
|
164
|
+
|
|
165
|
+
return [
|
|
166
|
+
`@ankhorage/infra v${version}`,
|
|
167
|
+
'',
|
|
168
|
+
'Usage:',
|
|
169
|
+
` ankhorage-infra <command> [project]`,
|
|
170
|
+
` ankh ${INFRA_COMMAND_CATEGORY} <command> [project]`,
|
|
171
|
+
'',
|
|
172
|
+
'Commands:',
|
|
173
|
+
commandLines,
|
|
174
|
+
'',
|
|
175
|
+
'Project resolution:',
|
|
176
|
+
' Pass [project], or omit it when cwd is inside apps/<project>.',
|
|
177
|
+
'',
|
|
178
|
+
].join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function renderUnknownCommand(value: string): string {
|
|
182
|
+
return [`Unknown infra command: ${value}`, '', 'Run ankhorage-infra --help', ''].join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function renderCommandHelp(command: InfraCommandDefinition): string {
|
|
186
|
+
return [
|
|
187
|
+
`${command.summary}`,
|
|
188
|
+
'',
|
|
189
|
+
'Usage:',
|
|
190
|
+
` ankhorage-infra ${command.standaloneName} [project]`,
|
|
191
|
+
` ankh ${INFRA_COMMAND_CATEGORY} ${command.path.join(' ')} [project]`,
|
|
192
|
+
'',
|
|
193
|
+
'Project resolution:',
|
|
194
|
+
' Pass [project], or omit it when cwd is inside apps/<project>.',
|
|
195
|
+
'',
|
|
196
|
+
].join('\n');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function createInfraCommandServices(
|
|
200
|
+
overrides: Partial<InfraCommandServices> = {},
|
|
201
|
+
): InfraCommandServices {
|
|
202
|
+
return {
|
|
203
|
+
resolveProject: overrides.resolveProject ?? resolveInfraProject,
|
|
204
|
+
resolveProjectInfrastructureTarget:
|
|
205
|
+
overrides.resolveProjectInfrastructureTarget ?? resolveProjectInfrastructureTarget,
|
|
206
|
+
runProjectInfraScript: overrides.runProjectInfraScript ?? runProjectInfraScript,
|
|
207
|
+
syncProjectInfrastructure: overrides.syncProjectInfrastructure ?? syncProjectInfrastructure,
|
|
208
|
+
validateInfraSupport: overrides.validateInfraSupport ?? validateInfraSupport,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseCommandArguments(
|
|
213
|
+
command: InfraCommandDefinition,
|
|
214
|
+
argv: readonly string[],
|
|
215
|
+
): { readonly kind: 'help' } | { readonly kind: 'run'; readonly projectId?: string } {
|
|
216
|
+
if (argv.length === 1 && isHelpToken(argv[0])) {
|
|
217
|
+
return { kind: 'help' };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (argv.length > 1) {
|
|
221
|
+
throw new Error(`Infra ${command.standaloneName} accepts at most one project argument.`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
kind: 'run',
|
|
226
|
+
projectId: argv[0],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function runValidateCommand(
|
|
231
|
+
request: InfraCommandRunRequest,
|
|
232
|
+
services: InfraCommandServices,
|
|
233
|
+
): Promise<InfraCommandRunResult> {
|
|
234
|
+
const project = await services.resolveProject({
|
|
235
|
+
cwd: request.context.cwd,
|
|
236
|
+
projectId: request.projectId,
|
|
237
|
+
});
|
|
238
|
+
const warnings = [...services.validateInfraSupport(project.manifest.infra)];
|
|
239
|
+
|
|
240
|
+
request.context.writeStdout(`projectId: ${project.projectId}\n`);
|
|
241
|
+
request.context.writeStdout(`infraConfigSupport: ${warnings.length === 0 ? 'ok' : 'warnings'}\n`);
|
|
242
|
+
writeWarnings(request.context, warnings);
|
|
243
|
+
|
|
244
|
+
return { exitCode: 0 };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function runGenerateCommand(
|
|
248
|
+
request: InfraCommandRunRequest,
|
|
249
|
+
services: InfraCommandServices,
|
|
250
|
+
): Promise<InfraCommandRunResult> {
|
|
251
|
+
const project = await services.resolveProject({
|
|
252
|
+
cwd: request.context.cwd,
|
|
253
|
+
projectId: request.projectId,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
request.context.writeStdout(`Regenerating infrastructure for project: ${project.projectId}...\n`);
|
|
257
|
+
|
|
258
|
+
const result = await services.syncProjectInfrastructure({
|
|
259
|
+
manifest: project.manifest,
|
|
260
|
+
projectId: project.projectId,
|
|
261
|
+
projectPath: project.projectPath,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (result.skipped !== undefined) {
|
|
265
|
+
request.context.writeStdout(`Skipped: ${result.skipped.reason}\n`);
|
|
266
|
+
return { exitCode: 0 };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
request.context.writeStdout(`Generated: ${result.generated}, removed stale: ${result.removed}\n`);
|
|
270
|
+
writeWarnings(request.context, result.warnings);
|
|
271
|
+
|
|
272
|
+
return { exitCode: 0 };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function runStatusCommand(
|
|
276
|
+
request: InfraCommandRunRequest,
|
|
277
|
+
services: InfraCommandServices,
|
|
278
|
+
): Promise<InfraCommandRunResult> {
|
|
279
|
+
const project = await services.resolveProject({
|
|
280
|
+
cwd: request.context.cwd,
|
|
281
|
+
projectId: request.projectId,
|
|
282
|
+
});
|
|
283
|
+
const target = await services.resolveProjectInfrastructureTarget({
|
|
284
|
+
manifest: project.manifest,
|
|
285
|
+
projectPath: project.projectPath,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (target === null) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Project '${project.projectId}' has no infrastructure target. Run infra generation first.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await services.runProjectInfraScript({
|
|
295
|
+
context: request.context,
|
|
296
|
+
projectId: project.projectId,
|
|
297
|
+
projectPath: project.projectPath,
|
|
298
|
+
script: 'status',
|
|
299
|
+
target,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
return { exitCode: 0 };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function runUpCommand(
|
|
306
|
+
request: InfraCommandRunRequest,
|
|
307
|
+
services: InfraCommandServices,
|
|
308
|
+
): Promise<InfraCommandRunResult> {
|
|
309
|
+
const project = await services.resolveProject({
|
|
310
|
+
cwd: request.context.cwd,
|
|
311
|
+
projectId: request.projectId,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
request.context.writeStdout(`Preparing infrastructure for project: ${project.projectId}...\n`);
|
|
315
|
+
|
|
316
|
+
const generateResult = await services.syncProjectInfrastructure({
|
|
317
|
+
manifest: project.manifest,
|
|
318
|
+
projectId: project.projectId,
|
|
319
|
+
projectPath: project.projectPath,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (generateResult.skipped !== undefined) {
|
|
323
|
+
request.context.writeStdout(`Skipped: ${generateResult.skipped.reason}\n`);
|
|
324
|
+
return { exitCode: 0 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
writeWarnings(request.context, generateResult.warnings);
|
|
328
|
+
|
|
329
|
+
const target = await services.resolveProjectInfrastructureTarget({
|
|
330
|
+
manifest: project.manifest,
|
|
331
|
+
projectPath: project.projectPath,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (project.manifest.infra.deployment === undefined || target === null) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`Project '${project.projectId}' has no infra.deployment target configured in ankh.config.json.`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await services.runProjectInfraScript({
|
|
341
|
+
context: request.context,
|
|
342
|
+
projectId: project.projectId,
|
|
343
|
+
projectPath: project.projectPath,
|
|
344
|
+
script: 'up',
|
|
345
|
+
target,
|
|
346
|
+
});
|
|
347
|
+
request.context.writeStdout('Infrastructure is up.\n');
|
|
348
|
+
|
|
349
|
+
return { exitCode: 0 };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function runDownCommand(
|
|
353
|
+
request: InfraCommandRunRequest,
|
|
354
|
+
services: InfraCommandServices,
|
|
355
|
+
): Promise<InfraCommandRunResult> {
|
|
356
|
+
const project = await services.resolveProject({
|
|
357
|
+
cwd: request.context.cwd,
|
|
358
|
+
projectId: request.projectId,
|
|
359
|
+
});
|
|
360
|
+
const target = await services.resolveProjectInfrastructureTarget({
|
|
361
|
+
manifest: project.manifest,
|
|
362
|
+
projectPath: project.projectPath,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (target === null) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`Project '${project.projectId}' has no infrastructure target. Run infra generation first.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
await services.runProjectInfraScript({
|
|
372
|
+
context: request.context,
|
|
373
|
+
projectId: project.projectId,
|
|
374
|
+
projectPath: project.projectPath,
|
|
375
|
+
script: 'down',
|
|
376
|
+
target,
|
|
377
|
+
});
|
|
378
|
+
request.context.writeStdout('Infrastructure is down.\n');
|
|
379
|
+
|
|
380
|
+
return { exitCode: 0 };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function writeWarnings(context: InfraCommandContext, warnings: readonly string[]): void {
|
|
384
|
+
if (warnings.length === 0) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
context.writeStderr('Warnings:\n');
|
|
389
|
+
for (const warning of warnings) {
|
|
390
|
+
context.writeStderr(`- ${warning}\n`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function renderCommandFailure(commandName: InfraCommandName, error: unknown): string {
|
|
395
|
+
return `Infra ${commandName} failed: ${getErrorMessage(error)}\n`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function getErrorMessage(error: unknown): string {
|
|
399
|
+
return error instanceof Error ? error.message : String(error);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function isHelpToken(value: string | undefined): boolean {
|
|
403
|
+
return value === '--help' || value === '-h' || value === 'help';
|
|
404
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTH_PROVIDERS,
|
|
3
|
+
AUTH_SCOPES,
|
|
4
|
+
AUTHZ_ENGINES,
|
|
5
|
+
DATABASE_PROVIDERS,
|
|
6
|
+
DEPLOYMENT_TARGETS,
|
|
7
|
+
} from '@ankhorage/contracts';
|
|
8
|
+
|
|
9
|
+
import type { InfraManifestInput } from './types.js';
|
|
10
|
+
|
|
11
|
+
export function validateInfraSupport(manifest: InfraManifestInput): readonly string[] {
|
|
12
|
+
const warnings: string[] = [];
|
|
13
|
+
|
|
14
|
+
const deploymentTarget = manifest.deployment?.target;
|
|
15
|
+
if (deploymentTarget !== undefined && !isSupported(deploymentTarget, DEPLOYMENT_TARGETS)) {
|
|
16
|
+
warnings.push(
|
|
17
|
+
warningForUnsupportedValue({
|
|
18
|
+
path: 'infra.deployment.target',
|
|
19
|
+
value: deploymentTarget,
|
|
20
|
+
supported: DEPLOYMENT_TARGETS,
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const databaseProvider = manifest.database?.provider;
|
|
26
|
+
if (databaseProvider !== undefined && !isSupported(databaseProvider, DATABASE_PROVIDERS)) {
|
|
27
|
+
warnings.push(
|
|
28
|
+
warningForUnsupportedValue({
|
|
29
|
+
path: 'infra.database.provider',
|
|
30
|
+
value: databaseProvider,
|
|
31
|
+
supported: DATABASE_PROVIDERS,
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const authProvider = manifest.auth?.provider;
|
|
37
|
+
if (authProvider !== undefined && !isSupported(authProvider, AUTH_PROVIDERS)) {
|
|
38
|
+
warnings.push(
|
|
39
|
+
warningForUnsupportedValue({
|
|
40
|
+
path: 'infra.auth.provider',
|
|
41
|
+
value: authProvider,
|
|
42
|
+
supported: AUTH_PROVIDERS,
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const authScope = manifest.auth?.scope;
|
|
48
|
+
if (authScope !== undefined && !isSupported(authScope, AUTH_SCOPES)) {
|
|
49
|
+
warnings.push(
|
|
50
|
+
warningForUnsupportedValue({
|
|
51
|
+
path: 'infra.auth.scope',
|
|
52
|
+
value: authScope,
|
|
53
|
+
supported: AUTH_SCOPES,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const authzEngine = manifest.auth?.authorization.engine;
|
|
59
|
+
if (authzEngine !== undefined && !isSupported(authzEngine, AUTHZ_ENGINES)) {
|
|
60
|
+
warnings.push(
|
|
61
|
+
warningForUnsupportedValue({
|
|
62
|
+
path: 'infra.auth.authorization.engine',
|
|
63
|
+
value: authzEngine,
|
|
64
|
+
supported: AUTHZ_ENGINES,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return warnings;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function warningForUnsupportedValue(args: {
|
|
73
|
+
readonly path: string;
|
|
74
|
+
readonly value: string;
|
|
75
|
+
readonly supported: readonly string[];
|
|
76
|
+
}): string {
|
|
77
|
+
return `${args.path} "${args.value}" is not currently supported by implemented generators. Supported values: ${formatSupportedValues(args.supported)}.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatSupportedValues(values: readonly string[]): string {
|
|
81
|
+
if (values.length === 0) {
|
|
82
|
+
return '(none)';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return values.join(', ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isSupported(value: string, supported: readonly string[]): boolean {
|
|
89
|
+
return supported.includes(value);
|
|
90
|
+
}
|