@ankhorage/infra 0.2.1 → 0.3.1

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +43 -2
  3. package/dist/cli/index.d.ts +10 -0
  4. package/dist/cli/index.d.ts.map +1 -0
  5. package/dist/cli/index.js +27 -0
  6. package/dist/cli/index.js.map +1 -0
  7. package/dist/cli.d.ts +10 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +39 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/commandContext.d.ts +12 -0
  12. package/dist/commandContext.d.ts.map +1 -0
  13. package/dist/commandContext.js +15 -0
  14. package/dist/commandContext.js.map +1 -0
  15. package/dist/commands.d.ts +95 -0
  16. package/dist/commands.d.ts.map +1 -0
  17. package/dist/commands.js +256 -0
  18. package/dist/commands.js.map +1 -0
  19. package/dist/infraValidation.d.ts +3 -0
  20. package/dist/infraValidation.d.ts.map +1 -0
  21. package/dist/infraValidation.js +58 -0
  22. package/dist/infraValidation.js.map +1 -0
  23. package/dist/metadata/index.d.ts +2 -0
  24. package/dist/metadata/index.d.ts.map +1 -0
  25. package/dist/metadata/index.js +2 -0
  26. package/dist/metadata/index.js.map +1 -0
  27. package/dist/packageMetadata.d.ts +10 -0
  28. package/dist/packageMetadata.d.ts.map +1 -0
  29. package/dist/packageMetadata.js +17 -0
  30. package/dist/packageMetadata.js.map +1 -0
  31. package/dist/project.d.ts +14 -0
  32. package/dist/project.d.ts.map +1 -0
  33. package/dist/project.js +202 -0
  34. package/dist/project.js.map +1 -0
  35. package/dist/projectInfrastructure.d.ts +22 -0
  36. package/dist/projectInfrastructure.d.ts.map +1 -0
  37. package/dist/projectInfrastructure.js +120 -0
  38. package/dist/projectInfrastructure.js.map +1 -0
  39. package/dist/readme-usage.d.ts +2 -0
  40. package/dist/readme-usage.d.ts.map +1 -0
  41. package/dist/readme-usage.js +30 -0
  42. package/dist/readme-usage.js.map +1 -0
  43. package/dist/runtime.d.ts +26 -0
  44. package/dist/runtime.d.ts.map +1 -0
  45. package/dist/runtime.js +87 -0
  46. package/dist/runtime.js.map +1 -0
  47. package/dist/testSupport.d.ts +24 -0
  48. package/dist/testSupport.d.ts.map +1 -0
  49. package/dist/testSupport.js +73 -0
  50. package/dist/testSupport.js.map +1 -0
  51. package/package.json +23 -3
  52. package/src/cli/index.test.ts +32 -0
  53. package/src/cli/index.ts +53 -0
  54. package/src/cli.test.ts +137 -0
  55. package/src/cli.ts +67 -0
  56. package/src/commandContext.ts +27 -0
  57. package/src/commands.test.ts +219 -0
  58. package/src/commands.ts +404 -0
  59. package/src/infraValidation.ts +90 -0
  60. package/src/metadata/index.ts +7 -0
  61. package/src/package.test.ts +27 -0
  62. package/src/packageMetadata.ts +24 -0
  63. package/src/project.test.ts +218 -0
  64. package/src/project.ts +270 -0
  65. package/src/projectInfrastructure.test.ts +207 -0
  66. package/src/projectInfrastructure.ts +177 -0
  67. package/src/readme-usage.ts +30 -0
  68. package/src/runtime.test.ts +124 -0
  69. package/src/runtime.ts +127 -0
  70. package/src/testSupport.ts +112 -0
@@ -0,0 +1,207 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { promises as fs } from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ import {
7
+ resolveProjectFile,
8
+ resolveProjectInfrastructureTarget,
9
+ syncProjectInfrastructure,
10
+ } from './projectInfrastructure.js';
11
+ import { createAppManifest } from './testSupport.js';
12
+
13
+ const tempRoots = new Set<string>();
14
+
15
+ afterEach(async () => {
16
+ await Promise.all(
17
+ [...tempRoots].map((rootPath) => fs.rm(rootPath, { force: true, recursive: true })),
18
+ );
19
+ tempRoots.clear();
20
+ });
21
+
22
+ describe('project infrastructure sync', () => {
23
+ test('writes generated files and the infra ledger', async () => {
24
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-sync-'));
25
+ tempRoots.add(projectPath);
26
+
27
+ const result = await syncProjectInfrastructure({
28
+ projectId: 'shop',
29
+ projectPath,
30
+ manifest: createAppManifest('shop', {
31
+ deployment: { target: 'minikube', monitoring: false },
32
+ plugins: [],
33
+ }),
34
+ generateInfrastructureImpl() {
35
+ return {
36
+ files: [
37
+ {
38
+ path: 'infra/minikube/scripts/status.sh',
39
+ content: '#!/usr/bin/env bash\n',
40
+ executable: true,
41
+ },
42
+ { path: 'infra/minikube/.env.example', content: 'APP_PORT=1\n' },
43
+ ],
44
+ warnings: ['generated warning'],
45
+ meta: {
46
+ target: 'minikube',
47
+ providers: [],
48
+ },
49
+ dependencies: [],
50
+ };
51
+ },
52
+ });
53
+
54
+ expect(result.generated).toBe(2);
55
+ expect(result.removed).toBe(0);
56
+ expect(result.warnings).toEqual(['generated warning']);
57
+ expect(await fs.readFile(path.join(projectPath, 'infra/minikube/.env.example'), 'utf8')).toBe(
58
+ 'APP_PORT=1\n',
59
+ );
60
+
61
+ const ledger = JSON.parse(
62
+ await fs.readFile(path.join(projectPath, '.ankh/infra-ledger.json'), 'utf8'),
63
+ ) as {
64
+ readonly target: string;
65
+ readonly files: readonly string[];
66
+ };
67
+ expect(ledger.target).toBe('minikube');
68
+ expect(ledger.files).toEqual([
69
+ 'infra/minikube/.env.example',
70
+ 'infra/minikube/scripts/status.sh',
71
+ ]);
72
+ });
73
+
74
+ test('removes stale files when regeneration shrinks the output set', async () => {
75
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-stale-'));
76
+ tempRoots.add(projectPath);
77
+
78
+ const manifest = createAppManifest('shop', {
79
+ deployment: { target: 'minikube', monitoring: false },
80
+ plugins: [],
81
+ });
82
+
83
+ await syncProjectInfrastructure({
84
+ projectId: 'shop',
85
+ projectPath,
86
+ manifest,
87
+ generateInfrastructureImpl() {
88
+ return {
89
+ files: [
90
+ { path: 'infra/minikube/scripts/status.sh', content: 'first' },
91
+ { path: 'infra/minikube/scripts/up.sh', content: 'extra' },
92
+ ],
93
+ warnings: [],
94
+ meta: { target: 'minikube', providers: [] },
95
+ dependencies: [],
96
+ };
97
+ },
98
+ });
99
+
100
+ const result = await syncProjectInfrastructure({
101
+ projectId: 'shop',
102
+ projectPath,
103
+ manifest,
104
+ generateInfrastructureImpl() {
105
+ return {
106
+ files: [{ path: 'infra/minikube/scripts/status.sh', content: 'second' }],
107
+ warnings: [],
108
+ meta: { target: 'minikube', providers: [] },
109
+ dependencies: [],
110
+ };
111
+ },
112
+ });
113
+
114
+ expect(result.removed).toBe(1);
115
+ expect(await pathExists(path.join(projectPath, 'infra/minikube/scripts/up.sh'))).toBe(false);
116
+ });
117
+
118
+ test('cleans generated files when deployment is removed', async () => {
119
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-clean-'));
120
+ tempRoots.add(projectPath);
121
+
122
+ await fs.mkdir(path.join(projectPath, '.ankh'), { recursive: true });
123
+ await fs.mkdir(path.join(projectPath, 'infra/minikube/scripts'), { recursive: true });
124
+ await fs.writeFile(
125
+ path.join(projectPath, 'infra/minikube/scripts/status.sh'),
126
+ 'status',
127
+ 'utf8',
128
+ );
129
+ await fs.writeFile(
130
+ path.join(projectPath, '.ankh/infra-ledger.json'),
131
+ JSON.stringify({
132
+ schemaVersion: 1,
133
+ generatedAt: new Date().toISOString(),
134
+ target: 'minikube',
135
+ files: ['infra/minikube/scripts/status.sh'],
136
+ warnings: [],
137
+ }),
138
+ 'utf8',
139
+ );
140
+
141
+ const result = await syncProjectInfrastructure({
142
+ projectId: 'shop',
143
+ projectPath,
144
+ manifest: createAppManifest('shop', { plugins: [] }),
145
+ });
146
+
147
+ expect(result.generated).toBe(0);
148
+ expect(result.removed).toBe(1);
149
+ expect(await pathExists(path.join(projectPath, '.ankh/infra-ledger.json'))).toBe(false);
150
+ });
151
+
152
+ test('preserves apps/studio skip behavior', async () => {
153
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-studio-'));
154
+ tempRoots.add(projectPath);
155
+
156
+ const result = await syncProjectInfrastructure({
157
+ projectId: 'studio',
158
+ projectPath,
159
+ manifest: createAppManifest('studio', {
160
+ deployment: { target: 'minikube', monitoring: false },
161
+ plugins: [],
162
+ }),
163
+ });
164
+
165
+ expect(result.skipped?.reason).toContain('apps/studio');
166
+ });
167
+
168
+ test('resolves targets from the ledger when deployment metadata is absent', async () => {
169
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-target-'));
170
+ tempRoots.add(projectPath);
171
+
172
+ await fs.mkdir(path.join(projectPath, '.ankh'), { recursive: true });
173
+ await fs.writeFile(
174
+ path.join(projectPath, '.ankh/infra-ledger.json'),
175
+ JSON.stringify({
176
+ schemaVersion: 1,
177
+ generatedAt: new Date().toISOString(),
178
+ target: 'minikube',
179
+ files: [],
180
+ warnings: [],
181
+ }),
182
+ 'utf8',
183
+ );
184
+
185
+ const target = await resolveProjectInfrastructureTarget({
186
+ manifest: createAppManifest('shop', { plugins: [] }),
187
+ projectPath,
188
+ });
189
+
190
+ expect(target).toBe('minikube');
191
+ });
192
+
193
+ test('rejects generated file paths outside the project root', () => {
194
+ expect(() => resolveProjectFile('/workspace/project', '../outside.txt')).toThrow(
195
+ 'Invalid infra output path outside project root',
196
+ );
197
+ });
198
+ });
199
+
200
+ async function pathExists(filePath: string): Promise<boolean> {
201
+ try {
202
+ await fs.access(filePath);
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
@@ -0,0 +1,177 @@
1
+ import type { AppManifest } from '@ankhorage/contracts';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+
5
+ import { generateInfrastructure } from './index.js';
6
+ import { validateInfraSupport } from './infraValidation.js';
7
+
8
+ const INFRA_LEDGER_RELATIVE_PATH = '.ankh/infra-ledger.json';
9
+ const STUDIO_PROJECT_ID = 'studio';
10
+
11
+ interface InfraLedger {
12
+ readonly schemaVersion: 1;
13
+ readonly generatedAt: string;
14
+ readonly target: string;
15
+ readonly files: readonly string[];
16
+ readonly warnings: readonly string[];
17
+ }
18
+
19
+ export interface InfraSyncResult {
20
+ readonly generated: number;
21
+ readonly removed: number;
22
+ readonly warnings: readonly string[];
23
+ readonly skipped?: {
24
+ readonly reason: string;
25
+ };
26
+ }
27
+
28
+ export async function syncProjectInfrastructure(args: {
29
+ readonly generateInfrastructureImpl?: typeof generateInfrastructure;
30
+ readonly manifest: AppManifest;
31
+ readonly projectId: string;
32
+ readonly projectPath: string;
33
+ }): Promise<InfraSyncResult> {
34
+ const supportWarnings = validateInfraSupport(args.manifest.infra);
35
+
36
+ if (args.projectId === STUDIO_PROJECT_ID) {
37
+ return {
38
+ generated: 0,
39
+ removed: 0,
40
+ warnings: supportWarnings,
41
+ skipped: {
42
+ reason: 'apps/studio is the dashboard and is not a generated app target.',
43
+ },
44
+ };
45
+ }
46
+
47
+ const previousLedger = await readInfraLedger(args.projectPath);
48
+ const previousFiles = new Set(previousLedger?.files ?? []);
49
+
50
+ if (args.manifest.infra.deployment === undefined) {
51
+ const removed = await removeFiles(args.projectPath, previousFiles);
52
+ await removeInfraLedger(args.projectPath);
53
+ return {
54
+ generated: 0,
55
+ removed,
56
+ warnings: supportWarnings,
57
+ };
58
+ }
59
+
60
+ const generated = (args.generateInfrastructureImpl ?? generateInfrastructure)(
61
+ args.manifest.infra,
62
+ {
63
+ namespaceHint: args.projectId,
64
+ appManifest: args.manifest,
65
+ },
66
+ );
67
+ const combinedWarnings = uniqueStrings([...supportWarnings, ...generated.warnings]);
68
+ const nextFiles = new Set<string>();
69
+
70
+ for (const file of generated.files) {
71
+ const outputPath = resolveProjectFile(args.projectPath, file.path);
72
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
73
+ await fs.writeFile(outputPath, file.content, 'utf8');
74
+
75
+ if (file.executable === true) {
76
+ await fs.chmod(outputPath, 0o755);
77
+ }
78
+
79
+ nextFiles.add(file.path);
80
+ }
81
+
82
+ const staleFiles = new Set([...previousFiles].filter((filePath) => !nextFiles.has(filePath)));
83
+ const removed = await removeFiles(args.projectPath, staleFiles);
84
+
85
+ await writeInfraLedger(args.projectPath, {
86
+ schemaVersion: 1,
87
+ generatedAt: new Date().toISOString(),
88
+ target: generated.meta.target,
89
+ files: [...nextFiles].sort(),
90
+ warnings: combinedWarnings,
91
+ });
92
+
93
+ return {
94
+ generated: generated.files.length,
95
+ removed,
96
+ warnings: combinedWarnings,
97
+ };
98
+ }
99
+
100
+ export async function resolveProjectInfrastructureTarget(args: {
101
+ readonly manifest: AppManifest;
102
+ readonly projectPath: string;
103
+ }): Promise<string | null> {
104
+ const manifestTarget = args.manifest.infra.deployment?.target;
105
+ if (manifestTarget !== undefined) {
106
+ return manifestTarget;
107
+ }
108
+
109
+ const ledger = await readInfraLedger(args.projectPath);
110
+ return ledger?.target ?? null;
111
+ }
112
+
113
+ export function resolveProjectFile(projectPath: string, filePath: string): string {
114
+ const rootPath = path.resolve(projectPath);
115
+ const resolvedPath = path.resolve(rootPath, filePath);
116
+ const relativePath = path.relative(rootPath, resolvedPath);
117
+
118
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
119
+ throw new Error(`Invalid infra output path outside project root: ${filePath}`);
120
+ }
121
+
122
+ return resolvedPath;
123
+ }
124
+
125
+ async function readInfraLedger(projectPath: string): Promise<InfraLedger | null> {
126
+ const ledgerPath = path.join(projectPath, INFRA_LEDGER_RELATIVE_PATH);
127
+
128
+ try {
129
+ const rawLedger = await fs.readFile(ledgerPath, 'utf8');
130
+ const parsedLedger: unknown = JSON.parse(rawLedger);
131
+ return isInfraLedger(parsedLedger) ? parsedLedger : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ async function writeInfraLedger(projectPath: string, ledger: InfraLedger): Promise<void> {
138
+ const ledgerPath = path.join(projectPath, INFRA_LEDGER_RELATIVE_PATH);
139
+ await fs.mkdir(path.dirname(ledgerPath), { recursive: true });
140
+ await fs.writeFile(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, 'utf8');
141
+ }
142
+
143
+ async function removeInfraLedger(projectPath: string): Promise<void> {
144
+ await fs.rm(path.join(projectPath, INFRA_LEDGER_RELATIVE_PATH), { force: true });
145
+ }
146
+
147
+ async function removeFiles(projectPath: string, files: ReadonlySet<string>): Promise<number> {
148
+ let removedFiles = 0;
149
+
150
+ for (const filePath of files) {
151
+ await fs.rm(resolveProjectFile(projectPath, filePath), { force: true });
152
+ removedFiles += 1;
153
+ }
154
+
155
+ return removedFiles;
156
+ }
157
+
158
+ function isInfraLedger(value: unknown): value is InfraLedger {
159
+ return (
160
+ isRecord(value) &&
161
+ value.schemaVersion === 1 &&
162
+ typeof value.generatedAt === 'string' &&
163
+ typeof value.target === 'string' &&
164
+ Array.isArray(value.files) &&
165
+ value.files.every((filePath) => typeof filePath === 'string') &&
166
+ Array.isArray(value.warnings) &&
167
+ value.warnings.every((warning) => typeof warning === 'string')
168
+ );
169
+ }
170
+
171
+ function isRecord(value: unknown): value is Record<string, unknown> {
172
+ return typeof value === 'object' && value !== null;
173
+ }
174
+
175
+ function uniqueStrings(values: readonly string[]): readonly string[] {
176
+ return [...new Set(values)];
177
+ }
@@ -0,0 +1,30 @@
1
+ import { runCli } from './cli.js';
2
+
3
+ /***
4
+ * Provider and CLI surface
5
+ *
6
+ * `@ankhorage/infra` owns infra command behavior.
7
+ *
8
+ * The same shared command implementation backs both:
9
+ *
10
+ * - `ankh infra ...`
11
+ * - `bunx @ankhorage/infra ...`
12
+ *
13
+ * Current command surface:
14
+ *
15
+ * - `validate`
16
+ * - `generate`
17
+ * - `status`
18
+ * - `up`
19
+ * - `down`
20
+ *
21
+ * `status` runs the generated live runtime status script for a project.
22
+ *
23
+ * Project resolution is project-aware:
24
+ *
25
+ * - pass `[project]`, or
26
+ * - omit it when cwd is already inside `apps/<project>`
27
+ *
28
+ * @usage
29
+ */
30
+ await runCli(['--help']);
@@ -0,0 +1,124 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { promises as fs } from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ import {
7
+ InfraScriptExecutionError,
8
+ resolveProjectInfraScriptPath,
9
+ runProjectInfraScript,
10
+ } from './runtime.js';
11
+ import { createCapturedCommandContext } from './testSupport.js';
12
+
13
+ const tempRoots = new Set<string>();
14
+
15
+ afterEach(async () => {
16
+ await Promise.all(
17
+ [...tempRoots].map((rootPath) => fs.rm(rootPath, { force: true, recursive: true })),
18
+ );
19
+ tempRoots.clear();
20
+ });
21
+
22
+ describe('runtime scripts', () => {
23
+ test('resolves minikube lifecycle scripts under the project infra directory', () => {
24
+ expect(
25
+ resolveProjectInfraScriptPath({
26
+ projectPath: '/workspace/apps/shop',
27
+ script: 'status',
28
+ target: 'minikube',
29
+ }),
30
+ ).toBe('/workspace/apps/shop/infra/minikube/scripts/status.sh');
31
+ });
32
+
33
+ test('streams script output to the command context', async () => {
34
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-runtime-'));
35
+ tempRoots.add(projectPath);
36
+ const scriptDir = path.join(projectPath, 'infra/minikube/scripts');
37
+ await fs.mkdir(scriptDir, { recursive: true });
38
+ await fs.writeFile(
39
+ path.join(scriptDir, 'status.sh'),
40
+ '#!/usr/bin/env bash\necho status-ok\necho status-warn 1>&2\n',
41
+ 'utf8',
42
+ );
43
+
44
+ const captured = createCapturedCommandContext(projectPath);
45
+
46
+ await runProjectInfraScript({
47
+ context: captured.context,
48
+ projectId: 'shop',
49
+ projectPath,
50
+ script: 'status',
51
+ target: 'minikube',
52
+ });
53
+
54
+ expect(captured.stdout.value).toContain('status-ok');
55
+ expect(captured.stderr.value).toContain('status-warn');
56
+ });
57
+
58
+ test('reports missing scripts clearly', async () => {
59
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-missing-'));
60
+ tempRoots.add(projectPath);
61
+ const captured = createCapturedCommandContext(projectPath);
62
+
63
+ await expectRejectMessage(
64
+ runProjectInfraScript({
65
+ context: captured.context,
66
+ projectId: 'shop',
67
+ projectPath,
68
+ script: 'up',
69
+ target: 'minikube',
70
+ }),
71
+ 'Run infra generate for project',
72
+ );
73
+ });
74
+
75
+ test('wraps non-zero script exits in InfraScriptExecutionError', async () => {
76
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-error-'));
77
+ tempRoots.add(projectPath);
78
+ const scriptDir = path.join(projectPath, 'infra/minikube/scripts');
79
+ await fs.mkdir(scriptDir, { recursive: true });
80
+ await fs.writeFile(
81
+ path.join(scriptDir, 'down.sh'),
82
+ '#!/usr/bin/env bash\necho boom 1>&2\nexit 7\n',
83
+ 'utf8',
84
+ );
85
+
86
+ const captured = createCapturedCommandContext(projectPath);
87
+
88
+ try {
89
+ await runProjectInfraScript({
90
+ context: captured.context,
91
+ projectId: 'shop',
92
+ projectPath,
93
+ script: 'down',
94
+ target: 'minikube',
95
+ });
96
+ throw new Error('Expected runtime script to fail.');
97
+ } catch (error) {
98
+ expect(error).toBeInstanceOf(InfraScriptExecutionError);
99
+ }
100
+ });
101
+
102
+ test('rejects unsupported deployment targets', () => {
103
+ expect(() =>
104
+ resolveProjectInfraScriptPath({
105
+ projectPath: '/workspace/apps/shop',
106
+ script: 'status',
107
+ target: 'aws',
108
+ }),
109
+ ).toThrow('Unsupported deployment target for infra scripts');
110
+ });
111
+ });
112
+
113
+ async function expectRejectMessage(promise: Promise<unknown>, text: string): Promise<void> {
114
+ let didReject = false;
115
+
116
+ try {
117
+ await promise;
118
+ } catch (error) {
119
+ didReject = true;
120
+ expect(error instanceof Error ? error.message : String(error)).toContain(text);
121
+ }
122
+
123
+ expect(didReject).toBe(true);
124
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { spawn } from 'child_process';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+
5
+ import type { InfraCommandContext } from './commandContext.js';
6
+
7
+ export type InfraLifecycleScript = 'down' | 'status' | 'up';
8
+
9
+ export class InfraScriptExecutionError extends Error {
10
+ readonly exitCode: number | null;
11
+ readonly stderr: string;
12
+ readonly stdout: string;
13
+
14
+ constructor(args: {
15
+ readonly exitCode: number | null;
16
+ readonly message: string;
17
+ readonly stderr: string;
18
+ readonly stdout: string;
19
+ }) {
20
+ super(args.message);
21
+ this.name = 'InfraScriptExecutionError';
22
+ this.exitCode = args.exitCode;
23
+ this.stderr = args.stderr;
24
+ this.stdout = args.stdout;
25
+ }
26
+ }
27
+
28
+ export async function runProjectInfraScript(args: {
29
+ readonly context: InfraCommandContext;
30
+ readonly projectId: string;
31
+ readonly projectPath: string;
32
+ readonly script: InfraLifecycleScript;
33
+ readonly target: string;
34
+ }): Promise<void> {
35
+ const scriptPath = resolveProjectInfraScriptPath(args);
36
+ if (!(await pathExists(scriptPath))) {
37
+ throw new Error(
38
+ `Infra script not found: ${scriptPath}. Run infra generate for project '${args.projectId}' first.`,
39
+ );
40
+ }
41
+
42
+ await runShellScript({
43
+ context: args.context,
44
+ scriptPath,
45
+ });
46
+ }
47
+
48
+ export function resolveProjectInfraScriptPath(args: {
49
+ readonly projectPath: string;
50
+ readonly script: InfraLifecycleScript;
51
+ readonly target: string;
52
+ }): string {
53
+ return path.join(args.projectPath, getInfraScriptsDirectory(args.target), `${args.script}.sh`);
54
+ }
55
+
56
+ function getInfraScriptsDirectory(target: string): string {
57
+ switch (target) {
58
+ case 'minikube':
59
+ return 'infra/minikube/scripts';
60
+ default:
61
+ throw new Error(`Unsupported deployment target for infra scripts: ${target}`);
62
+ }
63
+ }
64
+
65
+ async function runShellScript(args: {
66
+ readonly context: InfraCommandContext;
67
+ readonly scriptPath: string;
68
+ }): Promise<void> {
69
+ await new Promise<void>((resolve, reject) => {
70
+ const child = spawn('bash', [args.scriptPath], {
71
+ cwd: path.dirname(args.scriptPath),
72
+ env: { ...args.context.env },
73
+ stdio: ['ignore', 'pipe', 'pipe'],
74
+ });
75
+
76
+ let stdout = '';
77
+ let stderr = '';
78
+
79
+ child.stdout.setEncoding('utf8');
80
+ child.stdout.on('data', (chunk: string) => {
81
+ stdout += chunk;
82
+ args.context.writeStdout(chunk);
83
+ });
84
+
85
+ child.stderr.setEncoding('utf8');
86
+ child.stderr.on('data', (chunk: string) => {
87
+ stderr += chunk;
88
+ args.context.writeStderr(chunk);
89
+ });
90
+
91
+ child.once('error', (error) => {
92
+ reject(
93
+ new InfraScriptExecutionError({
94
+ exitCode: null,
95
+ message: `Failed to start infra script '${args.scriptPath}': ${error.message}`,
96
+ stderr,
97
+ stdout,
98
+ }),
99
+ );
100
+ });
101
+
102
+ child.once('close', (exitCode) => {
103
+ if (exitCode === 0) {
104
+ resolve();
105
+ return;
106
+ }
107
+
108
+ reject(
109
+ new InfraScriptExecutionError({
110
+ exitCode,
111
+ message: `Infra script '${args.scriptPath}' exited with code ${exitCode ?? 'unknown'}.`,
112
+ stderr,
113
+ stdout,
114
+ }),
115
+ );
116
+ });
117
+ });
118
+ }
119
+
120
+ async function pathExists(filePath: string): Promise<boolean> {
121
+ try {
122
+ await fs.access(filePath);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }