@ankhorage/infra 3.0.0 → 3.2.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.
@@ -25,6 +25,18 @@ export interface InfraSyncResult {
25
25
  };
26
26
  }
27
27
 
28
+ export interface InfraProjectInspection {
29
+ readonly generated: boolean;
30
+ readonly generatedAt: string | null;
31
+ readonly hasDeployment: boolean;
32
+ readonly target: string | null;
33
+ readonly trackedFiles: number;
34
+ readonly warnings: readonly string[];
35
+ readonly skipped?: {
36
+ readonly reason: string;
37
+ };
38
+ }
39
+
28
40
  export async function syncProjectInfrastructure(args: {
29
41
  readonly generateInfrastructureImpl?: typeof generateInfrastructure;
30
42
  readonly manifest: AppManifest;
@@ -97,6 +109,38 @@ export async function syncProjectInfrastructure(args: {
97
109
  };
98
110
  }
99
111
 
112
+ export async function inspectProjectInfrastructure(args: {
113
+ readonly manifest: AppManifest;
114
+ readonly projectId: string;
115
+ readonly projectPath: string;
116
+ }): Promise<InfraProjectInspection> {
117
+ const supportWarnings = validateInfraSupport(args.manifest.infra);
118
+
119
+ if (args.projectId === STUDIO_PROJECT_ID) {
120
+ return {
121
+ generated: false,
122
+ generatedAt: null,
123
+ hasDeployment: false,
124
+ target: null,
125
+ trackedFiles: 0,
126
+ warnings: supportWarnings,
127
+ skipped: {
128
+ reason: 'apps/studio is the dashboard and is not a generated app target.',
129
+ },
130
+ };
131
+ }
132
+
133
+ const ledger = await readInfraLedger(args.projectPath);
134
+ return {
135
+ generated: ledger !== null,
136
+ generatedAt: ledger?.generatedAt ?? null,
137
+ hasDeployment: args.manifest.infra.deployment !== undefined,
138
+ target: args.manifest.infra.deployment?.target ?? ledger?.target ?? null,
139
+ trackedFiles: ledger?.files.length ?? 0,
140
+ warnings: uniqueStrings([...supportWarnings, ...(ledger?.warnings ?? [])]),
141
+ };
142
+ }
143
+
100
144
  export async function resolveProjectInfrastructureTarget(args: {
101
145
  readonly manifest: AppManifest;
102
146
  readonly projectPath: string;
@@ -1,11 +1,17 @@
1
1
  import { runCli } from './cli/bin.js';
2
2
 
3
3
  /***
4
- * Provider and CLI surface
4
+ * Provider, CLI, and project API surface
5
5
  *
6
- * `@ankhorage/infra` owns infra command behavior.
6
+ * `@ankhorage/infra` owns infrastructure generation, project reconciliation,
7
+ * and generated lifecycle execution.
7
8
  *
8
- * The same shared command implementation backs both:
9
+ * The same owner implementation backs CLI consumers and the public
10
+ * `@ankhorage/infra/project` application-service boundary. Trusted hosts such
11
+ * as Studio should call that subpath instead of reproducing ledger, generated
12
+ * file, lifecycle-script, or port-forward configuration semantics.
13
+ *
14
+ * CLI entrypoints:
9
15
  *
10
16
  * - `ankh infra ...`
11
17
  * - `bunx @ankhorage/infra ...`
@@ -18,12 +24,10 @@ import { runCli } from './cli/bin.js';
18
24
  * - `up`
19
25
  * - `down`
20
26
  *
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
+ * Project resolution for the CLI is workspace-aware: pass `[project]`, or
28
+ * omit it when cwd is already inside `apps/<project>`. Programmatic project
29
+ * operations accept an explicit project path and do not require Studio or a
30
+ * particular workspace layout.
27
31
  *
28
32
  * @usage
29
33
  */
package/src/runtime.ts CHANGED
@@ -3,8 +3,19 @@ import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
4
 
5
5
  import type { InfraCommandContext } from './commandContext.js';
6
+ import { readProjectInfrastructureEnvironment } from './projectEnvironment.js';
6
7
 
7
- export type InfraLifecycleScript = 'down' | 'status' | 'up';
8
+ export type InfraLifecycleScript = 'destroy' | 'down' | 'port-forward' | 'reset' | 'status' | 'up';
9
+
10
+ export interface InfraScriptOutput {
11
+ readonly stderr: string;
12
+ readonly stdout: string;
13
+ }
14
+
15
+ export interface InfraPortForwardInfo {
16
+ readonly localPort: number;
17
+ readonly url: string;
18
+ }
8
19
 
9
20
  export class InfraScriptExecutionError extends Error {
10
21
  readonly exitCode: number | null;
@@ -25,13 +36,16 @@ export class InfraScriptExecutionError extends Error {
25
36
  }
26
37
  }
27
38
 
28
- export async function runProjectInfraScript(args: {
29
- readonly context: InfraCommandContext;
39
+ export async function runProjectInfrastructureLifecycle(args: {
40
+ readonly args?: readonly string[];
41
+ readonly env?: Readonly<Record<string, string | undefined>>;
42
+ readonly onStderr?: (chunk: string) => void;
43
+ readonly onStdout?: (chunk: string) => void;
30
44
  readonly projectId: string;
31
45
  readonly projectPath: string;
32
46
  readonly script: InfraLifecycleScript;
33
47
  readonly target: string;
34
- }): Promise<void> {
48
+ }): Promise<InfraScriptOutput> {
35
49
  const scriptPath = resolveProjectInfraScriptPath(args);
36
50
  if (!(await pathExists(scriptPath))) {
37
51
  throw new Error(
@@ -39,12 +53,56 @@ export async function runProjectInfraScript(args: {
39
53
  );
40
54
  }
41
55
 
42
- await runShellScript({
43
- context: args.context,
56
+ return runShellScript({
57
+ args: args.args ?? [],
58
+ env: args.env ?? process.env,
59
+ onStderr: args.onStderr,
60
+ onStdout: args.onStdout,
44
61
  scriptPath,
45
62
  });
46
63
  }
47
64
 
65
+ export async function resolveProjectInfrastructurePortForward(args: {
66
+ readonly projectPath: string;
67
+ readonly target: string;
68
+ }): Promise<InfraPortForwardInfo> {
69
+ const environment = await readProjectInfrastructureEnvironment({
70
+ keys: ['APP_PORT_FORWARD_LOCAL_PORT'],
71
+ projectPath: args.projectPath,
72
+ target: args.target,
73
+ });
74
+ const localPort = parsePositivePort(environment.APP_PORT_FORWARD_LOCAL_PORT);
75
+
76
+ if (localPort === null) {
77
+ throw new Error(
78
+ 'Generated Infra did not provide a valid APP_PORT_FORWARD_LOCAL_PORT. Regenerate infrastructure first.',
79
+ );
80
+ }
81
+
82
+ return {
83
+ localPort,
84
+ url: `http://127.0.0.1:${localPort}`,
85
+ };
86
+ }
87
+
88
+ export async function runProjectInfraScript(args: {
89
+ readonly context: InfraCommandContext;
90
+ readonly projectId: string;
91
+ readonly projectPath: string;
92
+ readonly script: InfraLifecycleScript;
93
+ readonly target: string;
94
+ }): Promise<void> {
95
+ await runProjectInfrastructureLifecycle({
96
+ env: args.context.env,
97
+ onStderr: (chunk) => args.context.writeStderr(chunk),
98
+ onStdout: (chunk) => args.context.writeStdout(chunk),
99
+ projectId: args.projectId,
100
+ projectPath: args.projectPath,
101
+ script: args.script,
102
+ target: args.target,
103
+ });
104
+ }
105
+
48
106
  export function resolveProjectInfraScriptPath(args: {
49
107
  readonly projectPath: string;
50
108
  readonly script: InfraLifecycleScript;
@@ -63,60 +121,78 @@ function getInfraScriptsDirectory(target: string): string {
63
121
  }
64
122
 
65
123
  async function runShellScript(args: {
66
- readonly context: InfraCommandContext;
124
+ readonly args: readonly string[];
125
+ readonly env: Readonly<Record<string, string | undefined>>;
126
+ readonly onStderr?: (chunk: string) => void;
127
+ readonly onStdout?: (chunk: string) => void;
67
128
  readonly scriptPath: string;
68
- }): Promise<void> {
69
- await new Promise<void>((resolve, reject) => {
70
- const child = spawn('bash', [args.scriptPath], {
129
+ }): Promise<InfraScriptOutput> {
130
+ return new Promise<InfraScriptOutput>((resolve, reject) => {
131
+ const child = spawn('bash', [args.scriptPath, ...args.args], {
71
132
  cwd: path.dirname(args.scriptPath),
72
- env: { ...args.context.env },
133
+ env: { ...args.env },
73
134
  stdio: ['ignore', 'pipe', 'pipe'],
74
135
  });
75
136
 
76
137
  let stdout = '';
77
138
  let stderr = '';
78
-
79
139
  child.stdout.setEncoding('utf8');
140
+ child.stderr.setEncoding('utf8');
80
141
  child.stdout.on('data', (chunk: string) => {
81
142
  stdout += chunk;
82
- args.context.writeStdout(chunk);
143
+ args.onStdout?.(chunk);
83
144
  });
84
-
85
- child.stderr.setEncoding('utf8');
86
145
  child.stderr.on('data', (chunk: string) => {
87
146
  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
- );
147
+ args.onStderr?.(chunk);
100
148
  });
101
-
149
+ child.once('error', (error) =>
150
+ reject(createStartError(args.scriptPath, error, stdout, stderr)),
151
+ );
102
152
  child.once('close', (exitCode) => {
103
153
  if (exitCode === 0) {
104
- resolve();
154
+ resolve({ stderr, stdout });
105
155
  return;
106
156
  }
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
- );
157
+ reject(createExitError(args.scriptPath, exitCode, stdout, stderr));
116
158
  });
117
159
  });
118
160
  }
119
161
 
162
+ function createStartError(
163
+ scriptPath: string,
164
+ error: Error,
165
+ stdout: string,
166
+ stderr: string,
167
+ ): InfraScriptExecutionError {
168
+ return new InfraScriptExecutionError({
169
+ exitCode: null,
170
+ message: `Failed to start infra script '${scriptPath}': ${error.message}`,
171
+ stderr,
172
+ stdout,
173
+ });
174
+ }
175
+
176
+ function createExitError(
177
+ scriptPath: string,
178
+ exitCode: number | null,
179
+ stdout: string,
180
+ stderr: string,
181
+ ): InfraScriptExecutionError {
182
+ return new InfraScriptExecutionError({
183
+ exitCode,
184
+ message: `Infra script '${scriptPath}' exited with code ${exitCode ?? 'unknown'}.`,
185
+ stderr,
186
+ stdout,
187
+ });
188
+ }
189
+
190
+ function parsePositivePort(value: string | undefined): number | null {
191
+ if (value === undefined) return null;
192
+ const parsed = Number.parseInt(value.trim(), 10);
193
+ return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65_535 ? parsed : null;
194
+ }
195
+
120
196
  async function pathExists(filePath: string): Promise<boolean> {
121
197
  try {
122
198
  await fs.access(filePath);