@ankhorage/infra 2.0.1 → 3.1.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 (40) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/dist/project/index.d.ts +4 -0
  4. package/dist/project/index.d.ts.map +1 -0
  5. package/dist/project/index.js +4 -0
  6. package/dist/project/index.js.map +1 -0
  7. package/dist/project.d.ts.map +1 -1
  8. package/dist/project.js +5 -58
  9. package/dist/project.js.map +1 -1
  10. package/dist/readme-usage.js +13 -9
  11. package/dist/readme-usage.js.map +1 -1
  12. package/dist/runtime.d.ts +23 -1
  13. package/dist/runtime.d.ts.map +1 -1
  14. package/dist/runtime.js +82 -24
  15. package/dist/runtime.js.map +1 -1
  16. package/dist/testSupport.js +1 -1
  17. package/dist/testSupport.js.map +1 -1
  18. package/package.json +7 -2
  19. package/src/adapters/minikube/auth/supabase/index.test.ts +1 -1
  20. package/src/adapters/minikube/auth/supabase/oauthRuntime.test.ts +1 -1
  21. package/src/adapters/minikube/auth/supabase/profile.integration.test.ts +1 -1
  22. package/src/adapters/minikube/auth/supabase/providerAssignments.test.ts +1 -1
  23. package/src/adapters/minikube/base/appImageBuild.test.ts +1 -1
  24. package/src/adapters/minikube/base/index.test.ts +2 -2
  25. package/src/adapters/minikube/base/twoAppIsolation.integration.test.ts +2 -2
  26. package/src/adapters/minikube/secrets/index.test.ts +1 -1
  27. package/src/commands.test.ts +3 -3
  28. package/src/generatedApiDatabase.test.ts +1 -1
  29. package/src/index.test.ts +6 -6
  30. package/src/minikubeImageLifecycle.test.ts +1 -1
  31. package/src/package.test.ts +5 -0
  32. package/src/project/index.test.ts +70 -0
  33. package/src/project/index.ts +17 -0
  34. package/src/project.test.ts +2 -2
  35. package/src/project.ts +6 -81
  36. package/src/projectContractsParser.test.ts +49 -0
  37. package/src/projectInfrastructure.test.ts +5 -5
  38. package/src/readme-usage.ts +13 -9
  39. package/src/runtime.ts +131 -38
  40. package/src/testSupport.ts +1 -1
@@ -101,7 +101,7 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
101
101
  target: 'minikube',
102
102
  monitoring: false,
103
103
  },
104
- plugins: [],
104
+ modules: [],
105
105
  },
106
106
  {
107
107
  appManifest: createAppManifest('frontend-only'),
@@ -508,7 +508,7 @@ function createSupabaseManifest(): InfraManifestInput {
508
508
  provider: 'supabase',
509
509
  buckets: ['assets'],
510
510
  },
511
- plugins: [],
511
+ modules: [],
512
512
  };
513
513
  }
514
514
 
@@ -140,7 +140,7 @@ function generateAppOnlyInfrastructure(slug: string) {
140
140
  target: 'minikube',
141
141
  monitoring: false,
142
142
  },
143
- plugins: [],
143
+ modules: [],
144
144
  };
145
145
 
146
146
  return generateInfrastructure(manifest, {
@@ -280,7 +280,7 @@ function generateSupabaseInfrastructure(slug: string) {
280
280
  secretStore: {
281
281
  provider: 'supabase-vault',
282
282
  },
283
- plugins: [],
283
+ modules: [],
284
284
  };
285
285
 
286
286
  return generateInfrastructure(manifest, {
@@ -14,7 +14,7 @@ function createManifest(overrides: Partial<InfraManifestInput> = {}): InfraManif
14
14
  secretStore: {
15
15
  provider: 'supabase-vault',
16
16
  },
17
- plugins: [],
17
+ modules: [],
18
18
  ...overrides,
19
19
  };
20
20
  }
@@ -66,7 +66,7 @@ describe('infra command table', () => {
66
66
  manifestPath: '/workspace/apps/shop/ankh.config.json',
67
67
  projectId: 'shop',
68
68
  projectPath: '/workspace/apps/shop',
69
- manifest: createAppManifest('shop', { plugins: [] }),
69
+ manifest: createAppManifest('shop', { modules: [] }),
70
70
  });
71
71
  },
72
72
  validateInfraSupport() {
@@ -104,7 +104,7 @@ describe('infra command table', () => {
104
104
  manifestPath: '/workspace/apps/studio/ankh.config.json',
105
105
  projectId: 'studio',
106
106
  projectPath: '/workspace/apps/studio',
107
- manifest: createAppManifest('studio', { plugins: [] }),
107
+ manifest: createAppManifest('studio', { modules: [] }),
108
108
  });
109
109
  },
110
110
  syncProjectInfrastructure() {
@@ -146,7 +146,7 @@ describe('infra command table', () => {
146
146
  projectPath: '/workspace/apps/shop',
147
147
  manifest: createAppManifest('shop', {
148
148
  deployment: { target: 'minikube', monitoring: false },
149
- plugins: [],
149
+ modules: [],
150
150
  }),
151
151
  });
152
152
  },
@@ -327,7 +327,7 @@ describe('generated API database bridge', () => {
327
327
  const infra: InfraManifestInput = {
328
328
  deployment: { target: 'minikube', monitoring: false },
329
329
  database: { provider: 'supabase', tier: 'dev' },
330
- plugins: [],
330
+ modules: [],
331
331
  };
332
332
  const appManifest = {
333
333
  ...createAppManifest('catalog', infra),
package/src/index.test.ts CHANGED
@@ -42,7 +42,7 @@ describe('generateInfrastructure', () => {
42
42
  domain: 'alpha.local',
43
43
  cdn: false,
44
44
  },
45
- plugins: [],
45
+ modules: [],
46
46
  };
47
47
 
48
48
  const result = generateInfrastructure(manifest, { appManifest: createAppManifest('alpha') });
@@ -125,7 +125,7 @@ describe('generateInfrastructure', () => {
125
125
  provider: 'supabase',
126
126
  buckets: ['assets', ' assets ', '', 'assets', 'uploads'],
127
127
  },
128
- plugins: [],
128
+ modules: [],
129
129
  };
130
130
 
131
131
  const result = generateInfrastructure(manifest, {
@@ -163,7 +163,7 @@ describe('generateInfrastructure', () => {
163
163
  provider: 'supabase',
164
164
  tier: 'dev',
165
165
  },
166
- plugins: [],
166
+ modules: [],
167
167
  };
168
168
 
169
169
  const appManifest: Pick<AppManifest, 'metadata' | 'navigator' | 'screens' | 'settings'> = {
@@ -237,7 +237,7 @@ describe('generateInfrastructure', () => {
237
237
 
238
238
  test('throws for missing deployment', () => {
239
239
  const manifest: InfraManifestInput = {
240
- plugins: [],
240
+ modules: [],
241
241
  };
242
242
 
243
243
  expect(() => generateInfrastructure(manifest)).toThrow('Deployment configuration is missing.');
@@ -249,7 +249,7 @@ describe('generateInfrastructure', () => {
249
249
  target: 'kubernetes',
250
250
  monitoring: false,
251
251
  },
252
- plugins: [],
252
+ modules: [],
253
253
  };
254
254
 
255
255
  expect(() => generateInfrastructure(manifest)).toThrow(
@@ -267,7 +267,7 @@ describe('generateInfrastructure', () => {
267
267
  provider: 'supabase',
268
268
  tier: 'dev',
269
269
  },
270
- plugins: [],
270
+ modules: [],
271
271
  };
272
272
 
273
273
  expect(() => generateInfrastructure(manifest, { namespaceHint: 'shop' })).toThrow(
@@ -11,7 +11,7 @@ describe('minikube generated app image lifecycle', () => {
11
11
  target: 'minikube',
12
12
  monitoring: false,
13
13
  },
14
- plugins: [],
14
+ modules: [],
15
15
  };
16
16
 
17
17
  const result = generateInfrastructure(manifest, { appManifest: createAppManifest('shop') });
@@ -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,70 @@
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
+ resolveProjectInfrastructurePortForward,
8
+ runProjectInfrastructureLifecycle,
9
+ } from './index.js';
10
+
11
+ const temporaryPaths: string[] = [];
12
+
13
+ afterEach(async () => {
14
+ await Promise.all(
15
+ temporaryPaths.splice(0).map((entry) => fs.rm(entry, { force: true, recursive: true })),
16
+ );
17
+ });
18
+
19
+ async function createProjectFixture(): Promise<string> {
20
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ankh-infra-project-'));
21
+ temporaryPaths.push(projectPath);
22
+ const infraRoot = path.join(projectPath, 'infra', 'minikube');
23
+ const scriptsRoot = path.join(infraRoot, 'scripts');
24
+ await fs.mkdir(scriptsRoot, { recursive: true });
25
+ await fs.writeFile(
26
+ path.join(scriptsRoot, 'status.sh'),
27
+ "#!/usr/bin/env bash\nprintf 'status-ok'\nprintf 'status-warning' >&2\n",
28
+ 'utf8',
29
+ );
30
+ await fs.writeFile(
31
+ path.join(infraRoot, '.env.example'),
32
+ 'APP_PORT_FORWARD_LOCAL_PORT=48123\n',
33
+ 'utf8',
34
+ );
35
+ return projectPath;
36
+ }
37
+
38
+ describe('@ankhorage/infra/project', () => {
39
+ test('executes generated lifecycle scripts without exposing script paths to consumers', async () => {
40
+ const projectPath = await createProjectFixture();
41
+ const stdout: string[] = [];
42
+ const stderr: string[] = [];
43
+
44
+ const result = await runProjectInfrastructureLifecycle({
45
+ projectId: 'example',
46
+ projectPath,
47
+ script: 'status',
48
+ target: 'minikube',
49
+ onStdout: (chunk) => stdout.push(chunk),
50
+ onStderr: (chunk) => stderr.push(chunk),
51
+ });
52
+
53
+ expect(result).toEqual({ stdout: 'status-ok', stderr: 'status-warning' });
54
+ expect(stdout.join('')).toBe('status-ok');
55
+ expect(stderr.join('')).toBe('status-warning');
56
+ });
57
+
58
+ test('resolves the generated app port-forward endpoint through the owner API', async () => {
59
+ const projectPath = await createProjectFixture();
60
+ const result = await resolveProjectInfrastructurePortForward({
61
+ projectPath,
62
+ target: 'minikube',
63
+ });
64
+
65
+ expect(result).toEqual({
66
+ localPort: 48123,
67
+ url: 'http://127.0.0.1:48123',
68
+ });
69
+ });
70
+ });
@@ -0,0 +1,17 @@
1
+ export {
2
+ type ResolvedInfraProject,
3
+ resolveInfraProject as resolveInfraWorkspaceProject,
4
+ } from '../project.js';
5
+ export {
6
+ type InfraSyncResult,
7
+ resolveProjectInfrastructureTarget,
8
+ syncProjectInfrastructure,
9
+ } from '../projectInfrastructure.js';
10
+ export {
11
+ type InfraLifecycleScript,
12
+ type InfraPortForwardInfo,
13
+ InfraScriptExecutionError,
14
+ type InfraScriptOutput,
15
+ resolveProjectInfrastructurePortForward,
16
+ runProjectInfrastructureLifecycle,
17
+ } from '../runtime.js';
@@ -58,7 +58,7 @@ describe('resolveInfraProject', () => {
58
58
 
59
59
  expect(project.manifest.metadata.name).toBe('shop');
60
60
  expect(project.manifest.metadata.slug).toBe('shop');
61
- expect(project.manifest.infra.plugins).toEqual([]);
61
+ expect(project.manifest.infra.modules).toEqual([]);
62
62
  });
63
63
 
64
64
  test('rejects malformed manifest JSON', async () => {
@@ -186,7 +186,7 @@ describe('resolveInfraProject', () => {
186
186
  monitoring: false,
187
187
  target: 'minikube',
188
188
  },
189
- plugins: [],
189
+ modules: [],
190
190
  });
191
191
  const fixture = await createWorkspaceFixture({
192
192
  manifest,
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
- plugins: [],
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.plugins);
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
+ }
@@ -29,7 +29,7 @@ describe('project infrastructure sync', () => {
29
29
  projectPath,
30
30
  manifest: createAppManifest('shop', {
31
31
  deployment: { target: 'minikube', monitoring: false },
32
- plugins: [],
32
+ modules: [],
33
33
  }),
34
34
  generateInfrastructureImpl() {
35
35
  return {
@@ -77,7 +77,7 @@ describe('project infrastructure sync', () => {
77
77
 
78
78
  const manifest = createAppManifest('shop', {
79
79
  deployment: { target: 'minikube', monitoring: false },
80
- plugins: [],
80
+ modules: [],
81
81
  });
82
82
 
83
83
  await syncProjectInfrastructure({
@@ -141,7 +141,7 @@ describe('project infrastructure sync', () => {
141
141
  const result = await syncProjectInfrastructure({
142
142
  projectId: 'shop',
143
143
  projectPath,
144
- manifest: createAppManifest('shop', { plugins: [] }),
144
+ manifest: createAppManifest('shop', { modules: [] }),
145
145
  });
146
146
 
147
147
  expect(result.generated).toBe(0);
@@ -158,7 +158,7 @@ describe('project infrastructure sync', () => {
158
158
  projectPath,
159
159
  manifest: createAppManifest('studio', {
160
160
  deployment: { target: 'minikube', monitoring: false },
161
- plugins: [],
161
+ modules: [],
162
162
  }),
163
163
  });
164
164
 
@@ -183,7 +183,7 @@ describe('project infrastructure sync', () => {
183
183
  );
184
184
 
185
185
  const target = await resolveProjectInfrastructureTarget({
186
- manifest: createAppManifest('shop', { plugins: [] }),
186
+ manifest: createAppManifest('shop', { modules: [] }),
187
187
  projectPath,
188
188
  });
189
189
 
@@ -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
  */