@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.
package/dist/runtime.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
+ import { readProjectInfrastructureEnvironment } from './projectEnvironment.js';
4
5
  export class InfraScriptExecutionError extends Error {
5
6
  exitCode;
6
7
  stderr;
@@ -13,16 +14,45 @@ export class InfraScriptExecutionError extends Error {
13
14
  this.stdout = args.stdout;
14
15
  }
15
16
  }
16
- export async function runProjectInfraScript(args) {
17
+ export async function runProjectInfrastructureLifecycle(args) {
17
18
  const scriptPath = resolveProjectInfraScriptPath(args);
18
19
  if (!(await pathExists(scriptPath))) {
19
20
  throw new Error(`Infra script not found: ${scriptPath}. Run infra generate for project '${args.projectId}' first.`);
20
21
  }
21
- await runShellScript({
22
- context: args.context,
22
+ return runShellScript({
23
+ args: args.args ?? [],
24
+ env: args.env ?? process.env,
25
+ onStderr: args.onStderr,
26
+ onStdout: args.onStdout,
23
27
  scriptPath,
24
28
  });
25
29
  }
30
+ export async function resolveProjectInfrastructurePortForward(args) {
31
+ const environment = await readProjectInfrastructureEnvironment({
32
+ keys: ['APP_PORT_FORWARD_LOCAL_PORT'],
33
+ projectPath: args.projectPath,
34
+ target: args.target,
35
+ });
36
+ const localPort = parsePositivePort(environment.APP_PORT_FORWARD_LOCAL_PORT);
37
+ if (localPort === null) {
38
+ throw new Error('Generated Infra did not provide a valid APP_PORT_FORWARD_LOCAL_PORT. Regenerate infrastructure first.');
39
+ }
40
+ return {
41
+ localPort,
42
+ url: `http://127.0.0.1:${localPort}`,
43
+ };
44
+ }
45
+ export async function runProjectInfraScript(args) {
46
+ await runProjectInfrastructureLifecycle({
47
+ env: args.context.env,
48
+ onStderr: (chunk) => args.context.writeStderr(chunk),
49
+ onStdout: (chunk) => args.context.writeStdout(chunk),
50
+ projectId: args.projectId,
51
+ projectPath: args.projectPath,
52
+ script: args.script,
53
+ target: args.target,
54
+ });
55
+ }
26
56
  export function resolveProjectInfraScriptPath(args) {
27
57
  return path.join(args.projectPath, getInfraScriptsDirectory(args.target), `${args.script}.sh`);
28
58
  }
@@ -35,46 +65,56 @@ function getInfraScriptsDirectory(target) {
35
65
  }
36
66
  }
37
67
  async function runShellScript(args) {
38
- await new Promise((resolve, reject) => {
39
- const child = spawn('bash', [args.scriptPath], {
68
+ return new Promise((resolve, reject) => {
69
+ const child = spawn('bash', [args.scriptPath, ...args.args], {
40
70
  cwd: path.dirname(args.scriptPath),
41
- env: { ...args.context.env },
71
+ env: { ...args.env },
42
72
  stdio: ['ignore', 'pipe', 'pipe'],
43
73
  });
44
74
  let stdout = '';
45
75
  let stderr = '';
46
76
  child.stdout.setEncoding('utf8');
77
+ child.stderr.setEncoding('utf8');
47
78
  child.stdout.on('data', (chunk) => {
48
79
  stdout += chunk;
49
- args.context.writeStdout(chunk);
80
+ args.onStdout?.(chunk);
50
81
  });
51
- child.stderr.setEncoding('utf8');
52
82
  child.stderr.on('data', (chunk) => {
53
83
  stderr += chunk;
54
- args.context.writeStderr(chunk);
55
- });
56
- child.once('error', (error) => {
57
- reject(new InfraScriptExecutionError({
58
- exitCode: null,
59
- message: `Failed to start infra script '${args.scriptPath}': ${error.message}`,
60
- stderr,
61
- stdout,
62
- }));
84
+ args.onStderr?.(chunk);
63
85
  });
86
+ child.once('error', (error) => reject(createStartError(args.scriptPath, error, stdout, stderr)));
64
87
  child.once('close', (exitCode) => {
65
88
  if (exitCode === 0) {
66
- resolve();
89
+ resolve({ stderr, stdout });
67
90
  return;
68
91
  }
69
- reject(new InfraScriptExecutionError({
70
- exitCode,
71
- message: `Infra script '${args.scriptPath}' exited with code ${exitCode ?? 'unknown'}.`,
72
- stderr,
73
- stdout,
74
- }));
92
+ reject(createExitError(args.scriptPath, exitCode, stdout, stderr));
75
93
  });
76
94
  });
77
95
  }
96
+ function createStartError(scriptPath, error, stdout, stderr) {
97
+ return new InfraScriptExecutionError({
98
+ exitCode: null,
99
+ message: `Failed to start infra script '${scriptPath}': ${error.message}`,
100
+ stderr,
101
+ stdout,
102
+ });
103
+ }
104
+ function createExitError(scriptPath, exitCode, stdout, stderr) {
105
+ return new InfraScriptExecutionError({
106
+ exitCode,
107
+ message: `Infra script '${scriptPath}' exited with code ${exitCode ?? 'unknown'}.`,
108
+ stderr,
109
+ stdout,
110
+ });
111
+ }
112
+ function parsePositivePort(value) {
113
+ if (value === undefined)
114
+ return null;
115
+ const parsed = Number.parseInt(value.trim(), 10);
116
+ return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65_535 ? parsed : null;
117
+ }
78
118
  async function pathExists(filePath) {
79
119
  try {
80
120
  await fs.access(filePath);
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from 'child_process';\nimport { promises as fs } from 'fs';\nimport path from 'path';\n\nimport type { InfraCommandContext } from './commandContext.js';\n\nexport type InfraLifecycleScript = 'down' | 'status' | 'up';\n\nexport class InfraScriptExecutionError extends Error {\n readonly exitCode: number | null;\n readonly stderr: string;\n readonly stdout: string;\n\n constructor(args: {\n readonly exitCode: number | null;\n readonly message: string;\n readonly stderr: string;\n readonly stdout: string;\n }) {\n super(args.message);\n this.name = 'InfraScriptExecutionError';\n this.exitCode = args.exitCode;\n this.stderr = args.stderr;\n this.stdout = args.stdout;\n }\n}\n\nexport async function runProjectInfraScript(args: {\n readonly context: InfraCommandContext;\n readonly projectId: string;\n readonly projectPath: string;\n readonly script: InfraLifecycleScript;\n readonly target: string;\n}): Promise<void> {\n const scriptPath = resolveProjectInfraScriptPath(args);\n if (!(await pathExists(scriptPath))) {\n throw new Error(\n `Infra script not found: ${scriptPath}. Run infra generate for project '${args.projectId}' first.`,\n );\n }\n\n await runShellScript({\n context: args.context,\n scriptPath,\n });\n}\n\nexport function resolveProjectInfraScriptPath(args: {\n readonly projectPath: string;\n readonly script: InfraLifecycleScript;\n readonly target: string;\n}): string {\n return path.join(args.projectPath, getInfraScriptsDirectory(args.target), `${args.script}.sh`);\n}\n\nfunction getInfraScriptsDirectory(target: string): string {\n switch (target) {\n case 'minikube':\n return 'infra/minikube/scripts';\n default:\n throw new Error(`Unsupported deployment target for infra scripts: ${target}`);\n }\n}\n\nasync function runShellScript(args: {\n readonly context: InfraCommandContext;\n readonly scriptPath: string;\n}): Promise<void> {\n await new Promise<void>((resolve, reject) => {\n const child = spawn('bash', [args.scriptPath], {\n cwd: path.dirname(args.scriptPath),\n env: { ...args.context.env },\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n\n let stdout = '';\n let stderr = '';\n\n child.stdout.setEncoding('utf8');\n child.stdout.on('data', (chunk: string) => {\n stdout += chunk;\n args.context.writeStdout(chunk);\n });\n\n child.stderr.setEncoding('utf8');\n child.stderr.on('data', (chunk: string) => {\n stderr += chunk;\n args.context.writeStderr(chunk);\n });\n\n child.once('error', (error) => {\n reject(\n new InfraScriptExecutionError({\n exitCode: null,\n message: `Failed to start infra script '${args.scriptPath}': ${error.message}`,\n stderr,\n stdout,\n }),\n );\n });\n\n child.once('close', (exitCode) => {\n if (exitCode === 0) {\n resolve();\n return;\n }\n\n reject(\n new InfraScriptExecutionError({\n exitCode,\n message: `Infra script '${args.scriptPath}' exited with code ${exitCode ?? 'unknown'}.`,\n stderr,\n stdout,\n }),\n );\n });\n });\n}\n\nasync function pathExists(filePath: string): Promise<boolean> {\n try {\n await fs.access(filePath);\n return true;\n } catch {\n return false;\n }\n}\n"]}
1
+ {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from 'child_process';\nimport { promises as fs } from 'fs';\nimport path from 'path';\n\nimport type { InfraCommandContext } from './commandContext.js';\nimport { readProjectInfrastructureEnvironment } from './projectEnvironment.js';\n\nexport type InfraLifecycleScript = 'destroy' | 'down' | 'port-forward' | 'reset' | 'status' | 'up';\n\nexport interface InfraScriptOutput {\n readonly stderr: string;\n readonly stdout: string;\n}\n\nexport interface InfraPortForwardInfo {\n readonly localPort: number;\n readonly url: string;\n}\n\nexport class InfraScriptExecutionError extends Error {\n readonly exitCode: number | null;\n readonly stderr: string;\n readonly stdout: string;\n\n constructor(args: {\n readonly exitCode: number | null;\n readonly message: string;\n readonly stderr: string;\n readonly stdout: string;\n }) {\n super(args.message);\n this.name = 'InfraScriptExecutionError';\n this.exitCode = args.exitCode;\n this.stderr = args.stderr;\n this.stdout = args.stdout;\n }\n}\n\nexport async function runProjectInfrastructureLifecycle(args: {\n readonly args?: readonly string[];\n readonly env?: Readonly<Record<string, string | undefined>>;\n readonly onStderr?: (chunk: string) => void;\n readonly onStdout?: (chunk: string) => void;\n readonly projectId: string;\n readonly projectPath: string;\n readonly script: InfraLifecycleScript;\n readonly target: string;\n}): Promise<InfraScriptOutput> {\n const scriptPath = resolveProjectInfraScriptPath(args);\n if (!(await pathExists(scriptPath))) {\n throw new Error(\n `Infra script not found: ${scriptPath}. Run infra generate for project '${args.projectId}' first.`,\n );\n }\n\n return runShellScript({\n args: args.args ?? [],\n env: args.env ?? process.env,\n onStderr: args.onStderr,\n onStdout: args.onStdout,\n scriptPath,\n });\n}\n\nexport async function resolveProjectInfrastructurePortForward(args: {\n readonly projectPath: string;\n readonly target: string;\n}): Promise<InfraPortForwardInfo> {\n const environment = await readProjectInfrastructureEnvironment({\n keys: ['APP_PORT_FORWARD_LOCAL_PORT'],\n projectPath: args.projectPath,\n target: args.target,\n });\n const localPort = parsePositivePort(environment.APP_PORT_FORWARD_LOCAL_PORT);\n\n if (localPort === null) {\n throw new Error(\n 'Generated Infra did not provide a valid APP_PORT_FORWARD_LOCAL_PORT. Regenerate infrastructure first.',\n );\n }\n\n return {\n localPort,\n url: `http://127.0.0.1:${localPort}`,\n };\n}\n\nexport async function runProjectInfraScript(args: {\n readonly context: InfraCommandContext;\n readonly projectId: string;\n readonly projectPath: string;\n readonly script: InfraLifecycleScript;\n readonly target: string;\n}): Promise<void> {\n await runProjectInfrastructureLifecycle({\n env: args.context.env,\n onStderr: (chunk) => args.context.writeStderr(chunk),\n onStdout: (chunk) => args.context.writeStdout(chunk),\n projectId: args.projectId,\n projectPath: args.projectPath,\n script: args.script,\n target: args.target,\n });\n}\n\nexport function resolveProjectInfraScriptPath(args: {\n readonly projectPath: string;\n readonly script: InfraLifecycleScript;\n readonly target: string;\n}): string {\n return path.join(args.projectPath, getInfraScriptsDirectory(args.target), `${args.script}.sh`);\n}\n\nfunction getInfraScriptsDirectory(target: string): string {\n switch (target) {\n case 'minikube':\n return 'infra/minikube/scripts';\n default:\n throw new Error(`Unsupported deployment target for infra scripts: ${target}`);\n }\n}\n\nasync function runShellScript(args: {\n readonly args: readonly string[];\n readonly env: Readonly<Record<string, string | undefined>>;\n readonly onStderr?: (chunk: string) => void;\n readonly onStdout?: (chunk: string) => void;\n readonly scriptPath: string;\n}): Promise<InfraScriptOutput> {\n return new Promise<InfraScriptOutput>((resolve, reject) => {\n const child = spawn('bash', [args.scriptPath, ...args.args], {\n cwd: path.dirname(args.scriptPath),\n env: { ...args.env },\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n\n let stdout = '';\n let stderr = '';\n child.stdout.setEncoding('utf8');\n child.stderr.setEncoding('utf8');\n child.stdout.on('data', (chunk: string) => {\n stdout += chunk;\n args.onStdout?.(chunk);\n });\n child.stderr.on('data', (chunk: string) => {\n stderr += chunk;\n args.onStderr?.(chunk);\n });\n child.once('error', (error) =>\n reject(createStartError(args.scriptPath, error, stdout, stderr)),\n );\n child.once('close', (exitCode) => {\n if (exitCode === 0) {\n resolve({ stderr, stdout });\n return;\n }\n reject(createExitError(args.scriptPath, exitCode, stdout, stderr));\n });\n });\n}\n\nfunction createStartError(\n scriptPath: string,\n error: Error,\n stdout: string,\n stderr: string,\n): InfraScriptExecutionError {\n return new InfraScriptExecutionError({\n exitCode: null,\n message: `Failed to start infra script '${scriptPath}': ${error.message}`,\n stderr,\n stdout,\n });\n}\n\nfunction createExitError(\n scriptPath: string,\n exitCode: number | null,\n stdout: string,\n stderr: string,\n): InfraScriptExecutionError {\n return new InfraScriptExecutionError({\n exitCode,\n message: `Infra script '${scriptPath}' exited with code ${exitCode ?? 'unknown'}.`,\n stderr,\n stdout,\n });\n}\n\nfunction parsePositivePort(value: string | undefined): number | null {\n if (value === undefined) return null;\n const parsed = Number.parseInt(value.trim(), 10);\n return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65_535 ? parsed : null;\n}\n\nasync function pathExists(filePath: string): Promise<boolean> {\n try {\n await fs.access(filePath);\n return true;\n } catch {\n return false;\n }\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/infra",
3
3
  "type": "module",
4
- "version": "3.0.0",
4
+ "version": "3.2.0",
5
5
  "description": "Project-aware infrastructure generation library and executable Ankh provider for Ankhorage.",
6
6
  "homepage": "https://github.com/ankhorage/infra#readme",
7
7
  "bugs": {
@@ -35,7 +35,7 @@
35
35
  "openapi"
36
36
  ],
37
37
  "dependencies": {
38
- "@ankhorage/contracts": "^6.0.0",
38
+ "@ankhorage/contracts": "^7.2.0",
39
39
  "@ankhorage/supabase-vault": "^0.2.4"
40
40
  },
41
41
  "files": [
@@ -62,6 +62,11 @@
62
62
  "import": "./dist/metadata/index.js",
63
63
  "default": "./dist/metadata/index.js"
64
64
  },
65
+ "./project": {
66
+ "types": "./dist/project/index.d.ts",
67
+ "import": "./dist/project/index.js",
68
+ "default": "./dist/project/index.js"
69
+ },
65
70
  "./package.json": "./package.json"
66
71
  },
67
72
  "bin": {
@@ -24,6 +24,11 @@ describe('package metadata', () => {
24
24
  'ankhorage-infra': './dist/cli/bin.js',
25
25
  });
26
26
  expect(packageJson.ankh).toEqual(expectedAnkhMetadata);
27
+ expect(packageJson.exports['./project']).toEqual({
28
+ types: './dist/project/index.d.ts',
29
+ import: './dist/project/index.js',
30
+ default: './dist/project/index.js',
31
+ });
27
32
  expect(JSON.parse(JSON.stringify(expectedAnkhMetadata))).toEqual(expectedAnkhMetadata);
28
33
 
29
34
  const capabilityText = JSON.stringify(packageJson.ankh.capabilities);
@@ -0,0 +1,149 @@
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 { createAppManifest } from '../testSupport.js';
7
+ import {
8
+ inspectProjectInfrastructure,
9
+ readProjectInfrastructureEnvironment,
10
+ resolveProjectInfrastructureDatabaseUrl,
11
+ resolveProjectInfrastructurePortForward,
12
+ runProjectInfrastructureLifecycle,
13
+ } from './index.js';
14
+
15
+ const temporaryPaths: string[] = [];
16
+
17
+ afterEach(async () => {
18
+ await Promise.all(
19
+ temporaryPaths.splice(0).map((entry) => fs.rm(entry, { force: true, recursive: true })),
20
+ );
21
+ });
22
+
23
+ async function createProjectFixture(): Promise<string> {
24
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ankh-infra-project-'));
25
+ temporaryPaths.push(projectPath);
26
+ const infraRoot = path.join(projectPath, 'infra', 'minikube');
27
+ const scriptsRoot = path.join(infraRoot, 'scripts');
28
+ await fs.mkdir(scriptsRoot, { recursive: true });
29
+ await fs.writeFile(
30
+ path.join(scriptsRoot, 'status.sh'),
31
+ "#!/usr/bin/env bash\nprintf 'status-ok'\nprintf 'status-warning' >&2\n",
32
+ 'utf8',
33
+ );
34
+ await fs.writeFile(
35
+ path.join(infraRoot, '.env.example'),
36
+ [
37
+ 'APP_PORT_FORWARD_LOCAL_PORT=48123',
38
+ 'SITE_URL=http://example.local',
39
+ 'DATABASE_URL=postgres://example',
40
+ 'PRIVATE_VALUE=not-selected',
41
+ '',
42
+ ].join('\n'),
43
+ 'utf8',
44
+ );
45
+ return projectPath;
46
+ }
47
+
48
+ describe('@ankhorage/infra/project', () => {
49
+ test('executes generated lifecycle scripts without exposing script paths to consumers', async () => {
50
+ const projectPath = await createProjectFixture();
51
+ const stdout: string[] = [];
52
+ const stderr: string[] = [];
53
+
54
+ const result = await runProjectInfrastructureLifecycle({
55
+ projectId: 'example',
56
+ projectPath,
57
+ script: 'status',
58
+ target: 'minikube',
59
+ onStdout: (chunk) => stdout.push(chunk),
60
+ onStderr: (chunk) => stderr.push(chunk),
61
+ });
62
+
63
+ expect(result).toEqual({ stdout: 'status-ok', stderr: 'status-warning' });
64
+ expect(stdout.join('')).toBe('status-ok');
65
+ expect(stderr.join('')).toBe('status-warning');
66
+ });
67
+
68
+ test('resolves the generated app port-forward endpoint through the owner API', async () => {
69
+ const projectPath = await createProjectFixture();
70
+ const result = await resolveProjectInfrastructurePortForward({
71
+ projectPath,
72
+ target: 'minikube',
73
+ });
74
+
75
+ expect(result).toEqual({
76
+ localPort: 48123,
77
+ url: 'http://127.0.0.1:48123',
78
+ });
79
+ });
80
+
81
+ test('reads only requested runtime environment values from the generated fallback', async () => {
82
+ const projectPath = await createProjectFixture();
83
+ const result = await readProjectInfrastructureEnvironment({
84
+ keys: ['SITE_URL', 'MISSING_VALUE'],
85
+ projectPath,
86
+ target: 'minikube',
87
+ });
88
+
89
+ expect(result).toEqual({ SITE_URL: 'http://example.local' });
90
+ });
91
+
92
+ test('prefers generated runtime environment and resolves the trusted database URL', async () => {
93
+ const projectPath = await createProjectFixture();
94
+ const envPath = path.join(projectPath, 'infra', 'minikube', '.env');
95
+ await fs.writeFile(
96
+ envPath,
97
+ 'SITE_URL=http://runtime.local\nDATABASE_URL=postgres://runtime\n',
98
+ 'utf8',
99
+ );
100
+
101
+ const environment = await readProjectInfrastructureEnvironment({
102
+ keys: ['SITE_URL'],
103
+ projectPath,
104
+ target: 'minikube',
105
+ });
106
+ const databaseUrl = await resolveProjectInfrastructureDatabaseUrl({
107
+ projectPath,
108
+ target: 'minikube',
109
+ });
110
+
111
+ expect(environment).toEqual({ SITE_URL: 'http://runtime.local' });
112
+ expect(databaseUrl).toBe('postgres://runtime');
113
+ });
114
+
115
+ test('inspects deployment and generated state through the owner ledger', async () => {
116
+ const projectPath = await createProjectFixture();
117
+ await fs.mkdir(path.join(projectPath, '.ankh'), { recursive: true });
118
+ await fs.writeFile(
119
+ path.join(projectPath, '.ankh', 'infra-ledger.json'),
120
+ JSON.stringify({
121
+ schemaVersion: 1,
122
+ generatedAt: '2026-08-12T04:00:00.000Z',
123
+ target: 'minikube',
124
+ files: ['infra/minikube/.env.example', 'infra/minikube/scripts/status.sh'],
125
+ warnings: ['generated warning'],
126
+ }),
127
+ 'utf8',
128
+ );
129
+ const manifest = createAppManifest('example', {
130
+ deployment: { monitoring: false, target: 'minikube' },
131
+ modules: [],
132
+ });
133
+
134
+ const result = await inspectProjectInfrastructure({
135
+ manifest,
136
+ projectId: 'example',
137
+ projectPath,
138
+ });
139
+
140
+ expect(result).toEqual({
141
+ generated: true,
142
+ generatedAt: '2026-08-12T04:00:00.000Z',
143
+ hasDeployment: true,
144
+ target: 'minikube',
145
+ trackedFiles: 2,
146
+ warnings: ['generated warning'],
147
+ });
148
+ });
149
+ });
@@ -0,0 +1,21 @@
1
+ export {
2
+ type ResolvedInfraProject,
3
+ resolveInfraProject as resolveInfraWorkspaceProject,
4
+ } from '../project.js';
5
+ export { resolveProjectInfrastructureDatabaseUrl } from '../projectDatabase.js';
6
+ export { readProjectInfrastructureEnvironment } from '../projectEnvironment.js';
7
+ export {
8
+ type InfraProjectInspection,
9
+ type InfraSyncResult,
10
+ inspectProjectInfrastructure,
11
+ resolveProjectInfrastructureTarget,
12
+ syncProjectInfrastructure,
13
+ } from '../projectInfrastructure.js';
14
+ export {
15
+ type InfraLifecycleScript,
16
+ type InfraPortForwardInfo,
17
+ InfraScriptExecutionError,
18
+ type InfraScriptOutput,
19
+ resolveProjectInfrastructurePortForward,
20
+ runProjectInfrastructureLifecycle,
21
+ } from '../runtime.js';
package/src/project.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { APP_CATEGORIES, type AppManifest, type NavigatorSpec } from '@ankhorage/contracts';
1
+ import { type AppManifest, isAppManifest } from '@ankhorage/contracts';
2
2
  import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
4
 
@@ -37,11 +37,7 @@ export async function resolveInfraProject(options: {
37
37
  }
38
38
 
39
39
  const manifestPath = path.join(projectPath, 'ankh.config.json');
40
- const manifest = await readProjectManifest({
41
- manifestPath,
42
- projectId,
43
- projectPath,
44
- });
40
+ const manifest = await readProjectManifest({ manifestPath, projectId });
45
41
 
46
42
  return {
47
43
  appsRoot,
@@ -75,9 +71,7 @@ function createFallbackManifest(projectId: string): AppManifest {
75
71
  },
76
72
  themes: [],
77
73
  activeThemeId: 'default',
78
- infra: {
79
- modules: [],
80
- },
74
+ infra: { modules: [] },
81
75
  navigator: {
82
76
  type: 'stack',
83
77
  routes: [{ name: 'index', screenId: 'index' }],
@@ -95,15 +89,14 @@ function createFallbackManifest(projectId: string): AppManifest {
95
89
  async function readProjectManifest(args: {
96
90
  readonly manifestPath: string;
97
91
  readonly projectId: string;
98
- readonly projectPath: string;
99
92
  }): Promise<AppManifest> {
100
93
  if (!(await pathExists(args.manifestPath))) {
101
94
  return createFallbackManifest(args.projectId);
102
95
  }
103
96
 
104
97
  const rawManifest = await fs.readFile(args.manifestPath, 'utf8');
105
-
106
98
  let parsed: unknown;
99
+
107
100
  try {
108
101
  parsed = JSON.parse(rawManifest);
109
102
  } catch (error) {
@@ -125,15 +118,10 @@ async function findWorkspaceRoot(startPath: string): Promise<string | null> {
125
118
 
126
119
  for (;;) {
127
120
  const appsRoot = path.join(currentPath, 'apps');
128
- if (await isDirectory(appsRoot)) {
129
- return currentPath;
130
- }
121
+ if (await isDirectory(appsRoot)) return currentPath;
131
122
 
132
123
  const parentPath = path.dirname(currentPath);
133
- if (parentPath === currentPath) {
134
- return null;
135
- }
136
-
124
+ if (parentPath === currentPath) return null;
137
125
  currentPath = parentPath;
138
126
  }
139
127
  }
@@ -166,69 +154,6 @@ function validateExplicitProjectId(projectId: string): string {
166
154
  return projectId;
167
155
  }
168
156
 
169
- function isAppManifest(value: unknown): value is AppManifest {
170
- if (!isRecord(value)) {
171
- return false;
172
- }
173
-
174
- return (
175
- isAppManifestMetadata(value.metadata) &&
176
- Array.isArray(value.themes) &&
177
- typeof value.activeThemeId === 'string' &&
178
- isInfraManifestRecord(value.infra) &&
179
- isNavigatorSpec(value.navigator) &&
180
- isRecord(value.screens) &&
181
- isSettingsRecord(value.settings)
182
- );
183
- }
184
-
185
- function isAppManifestMetadata(value: unknown): value is AppManifest['metadata'] {
186
- return (
187
- isRecord(value) &&
188
- typeof value.name === 'string' &&
189
- typeof value.slug === 'string' &&
190
- typeof value.version === 'string' &&
191
- APP_CATEGORIES.some((category) => category === value.category) &&
192
- typeof value.themeId === 'string' &&
193
- (value.created === undefined || typeof value.created === 'string') &&
194
- (value.updated === undefined || typeof value.updated === 'string')
195
- );
196
- }
197
-
198
- function isInfraManifestRecord(value: unknown): value is AppManifest['infra'] {
199
- return isRecord(value) && Array.isArray(value.modules);
200
- }
201
-
202
- function isNavigatorSpec(value: unknown): value is NavigatorSpec {
203
- return (
204
- isRecord(value) &&
205
- typeof value.type === 'string' &&
206
- Array.isArray(value.routes) &&
207
- (value.initialRouteName === undefined || typeof value.initialRouteName === 'string')
208
- );
209
- }
210
-
211
- function isSettingsRecord(value: unknown): value is AppManifest['settings'] {
212
- return (
213
- isRecord(value) &&
214
- (value.apiBaseUrl === undefined || typeof value.apiBaseUrl === 'string') &&
215
- isLocalizationRecord(value.localization)
216
- );
217
- }
218
-
219
- function isLocalizationRecord(value: unknown): value is AppManifest['settings']['localization'] {
220
- return (
221
- isRecord(value) &&
222
- typeof value.defaultLocale === 'string' &&
223
- Array.isArray(value.locales) &&
224
- value.locales.every((locale) => typeof locale === 'string')
225
- );
226
- }
227
-
228
- function isRecord(value: unknown): value is Record<string, unknown> {
229
- return typeof value === 'object' && value !== null;
230
- }
231
-
232
157
  async function isDirectory(filePath: string): Promise<boolean> {
233
158
  try {
234
159
  const stat = await fs.stat(filePath);
@@ -0,0 +1,49 @@
1
+ import { afterEach, expect, test } from 'bun:test';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+
5
+ import { resolveInfraProject } from './project.js';
6
+ import { createAppManifest, createWorkspaceFixture } from './testSupport.js';
7
+
8
+ const tempRoots = new Set<string>();
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(
12
+ [...tempRoots].map((rootPath) => fs.rm(rootPath, { force: true, recursive: true })),
13
+ );
14
+ tempRoots.clear();
15
+ });
16
+
17
+ test('Infra rejects nested manifest shapes rejected by Contracts', async () => {
18
+ const manifest = createAppManifest('cards', { modules: [] });
19
+ const fixture = await createWorkspaceFixture({ manifest, projectId: 'cards' });
20
+ tempRoots.add(fixture.rootPath);
21
+
22
+ const malformedManifest = {
23
+ ...manifest,
24
+ navigator: {
25
+ ...manifest.navigator,
26
+ type: 'not-a-canonical-navigator',
27
+ },
28
+ };
29
+ await fs.writeFile(
30
+ path.join(fixture.projectPath, 'ankh.config.json'),
31
+ JSON.stringify(malformedManifest),
32
+ 'utf8',
33
+ );
34
+
35
+ const error = await captureError(
36
+ resolveInfraProject({ cwd: fixture.rootPath, projectId: fixture.projectId }),
37
+ );
38
+ expect(error.message).toContain('Project manifest has an invalid shape');
39
+ });
40
+
41
+ async function captureError(promise: Promise<unknown>): Promise<Error> {
42
+ try {
43
+ await promise;
44
+ } catch (error) {
45
+ if (error instanceof Error) return error;
46
+ throw error;
47
+ }
48
+ throw new Error('Expected project resolution to reject.');
49
+ }
@@ -0,0 +1,27 @@
1
+ import { readProjectInfrastructureEnvironment } from './projectEnvironment.js';
2
+
3
+ const DATABASE_URL_KEYS = [
4
+ 'ANKH_SECRET_STORE_DATABASE_URL',
5
+ 'SUPABASE_DB_URL',
6
+ 'POSTGRES_URL',
7
+ 'DB_URL',
8
+ 'DATABASE_URL',
9
+ ] as const;
10
+
11
+ export async function resolveProjectInfrastructureDatabaseUrl(args: {
12
+ readonly projectPath: string;
13
+ readonly target: string;
14
+ }): Promise<string | null> {
15
+ const environment = await readProjectInfrastructureEnvironment({
16
+ keys: DATABASE_URL_KEYS,
17
+ projectPath: args.projectPath,
18
+ target: args.target,
19
+ });
20
+
21
+ for (const key of DATABASE_URL_KEYS) {
22
+ const value = environment[key]?.trim();
23
+ if (value) return value;
24
+ }
25
+
26
+ return null;
27
+ }
@@ -0,0 +1,51 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+
4
+ export async function readProjectInfrastructureEnvironment(args: {
5
+ readonly keys: readonly string[];
6
+ readonly projectPath: string;
7
+ readonly target: string;
8
+ }): Promise<Readonly<Record<string, string>>> {
9
+ const infraRoot = path.join(args.projectPath, 'infra', args.target);
10
+ const envPath = path.join(infraRoot, '.env');
11
+ const fallbackPath = path.join(infraRoot, '.env.example');
12
+ const sourcePath = (await pathExists(envPath)) ? envPath : fallbackPath;
13
+ const environment = await readEnvironmentFile(sourcePath);
14
+ const selected: Record<string, string> = {};
15
+
16
+ for (const key of new Set(args.keys)) {
17
+ const value = environment.get(key);
18
+ if (value !== undefined) selected[key] = value;
19
+ }
20
+
21
+ return selected;
22
+ }
23
+
24
+ async function readEnvironmentFile(filePath: string): Promise<Map<string, string>> {
25
+ const out = new Map<string, string>();
26
+ const content = await fs.readFile(filePath, 'utf8');
27
+
28
+ for (const line of content.split(/\r?\n/u)) {
29
+ const trimmed = line.trim();
30
+ if (!trimmed || trimmed.startsWith('#')) continue;
31
+ const separator = trimmed.indexOf('=');
32
+ if (separator <= 0) continue;
33
+ const key = trimmed.slice(0, separator).trim();
34
+ const value = trimmed
35
+ .slice(separator + 1)
36
+ .trim()
37
+ .replace(/^['"]|['"]$/g, '');
38
+ out.set(key, value);
39
+ }
40
+
41
+ return out;
42
+ }
43
+
44
+ async function pathExists(filePath: string): Promise<boolean> {
45
+ try {
46
+ await fs.access(filePath);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }