@ankhorage/infra 0.0.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/adapters/minikube/auth/index.d.ts +7 -0
  4. package/dist/adapters/minikube/auth/index.d.ts.map +1 -0
  5. package/dist/adapters/minikube/auth/index.js +14 -0
  6. package/dist/adapters/minikube/auth/index.js.map +1 -0
  7. package/dist/adapters/minikube/auth/supabase/index.d.ts +7 -0
  8. package/dist/adapters/minikube/auth/supabase/index.d.ts.map +1 -0
  9. package/dist/adapters/minikube/auth/supabase/index.js +213 -0
  10. package/dist/adapters/minikube/auth/supabase/index.js.map +1 -0
  11. package/dist/adapters/minikube/authz/cerbos/index.d.ts +9 -0
  12. package/dist/adapters/minikube/authz/cerbos/index.d.ts.map +1 -0
  13. package/dist/adapters/minikube/authz/cerbos/index.js +360 -0
  14. package/dist/adapters/minikube/authz/cerbos/index.js.map +1 -0
  15. package/dist/adapters/minikube/authz/index.d.ts +9 -0
  16. package/dist/adapters/minikube/authz/index.d.ts.map +1 -0
  17. package/dist/adapters/minikube/authz/index.js +23 -0
  18. package/dist/adapters/minikube/authz/index.js.map +1 -0
  19. package/dist/adapters/minikube/base/index.d.ts +9 -0
  20. package/dist/adapters/minikube/base/index.d.ts.map +1 -0
  21. package/dist/adapters/minikube/base/index.js +1371 -0
  22. package/dist/adapters/minikube/base/index.js.map +1 -0
  23. package/dist/adapters/minikube/contracts.d.ts +9 -0
  24. package/dist/adapters/minikube/contracts.d.ts.map +1 -0
  25. package/dist/adapters/minikube/contracts.js +9 -0
  26. package/dist/adapters/minikube/contracts.js.map +1 -0
  27. package/dist/adapters/minikube/index.d.ts +3 -0
  28. package/dist/adapters/minikube/index.d.ts.map +1 -0
  29. package/dist/adapters/minikube/index.js +114 -0
  30. package/dist/adapters/minikube/index.js.map +1 -0
  31. package/dist/adapters/minikube/storage/index.d.ts +7 -0
  32. package/dist/adapters/minikube/storage/index.d.ts.map +1 -0
  33. package/dist/adapters/minikube/storage/index.js +77 -0
  34. package/dist/adapters/minikube/storage/index.js.map +1 -0
  35. package/dist/adapters/minikube/storage/supabase/index.d.ts +7 -0
  36. package/dist/adapters/minikube/storage/supabase/index.d.ts.map +1 -0
  37. package/dist/adapters/minikube/storage/supabase/index.js +81 -0
  38. package/dist/adapters/minikube/storage/supabase/index.js.map +1 -0
  39. package/dist/datasets.d.ts +11 -0
  40. package/dist/datasets.d.ts.map +1 -0
  41. package/dist/datasets.js +397 -0
  42. package/dist/datasets.js.map +1 -0
  43. package/dist/index.d.ts +7 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +15 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/types.d.ts +70 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +2 -0
  50. package/dist/types.js.map +1 -0
  51. package/package.json +86 -0
  52. package/src/adapters/minikube/README.md +73 -0
  53. package/src/adapters/minikube/auth/index.ts +23 -0
  54. package/src/adapters/minikube/auth/supabase/index.ts +269 -0
  55. package/src/adapters/minikube/authz/cerbos/index.ts +458 -0
  56. package/src/adapters/minikube/authz/index.ts +32 -0
  57. package/src/adapters/minikube/base/index.test.ts +103 -0
  58. package/src/adapters/minikube/base/index.ts +1478 -0
  59. package/src/adapters/minikube/contracts.ts +17 -0
  60. package/src/adapters/minikube/index.ts +149 -0
  61. package/src/adapters/minikube/storage/index.ts +91 -0
  62. package/src/adapters/minikube/storage/supabase/index.ts +112 -0
  63. package/src/datasets.test.ts +178 -0
  64. package/src/datasets.ts +562 -0
  65. package/src/index.test.ts +679 -0
  66. package/src/index.ts +43 -0
  67. package/src/minikubeImageLifecycle.test.ts +34 -0
  68. package/src/types.ts +82 -0
@@ -0,0 +1,458 @@
1
+ import type { AppManifest, NavigatorSpec, RouteDefinition } from '@ankhorage/contracts';
2
+
3
+ import type { InfraManifestInput } from '../../../../types';
4
+ import type { MinikubeAdapterArtifacts } from '../../contracts';
5
+
6
+ export function generateCerbosAuthzArtifacts(args: {
7
+ manifest: InfraManifestInput;
8
+ namespace: string;
9
+ appManifest?: Pick<AppManifest, 'metadata' | 'navigator' | 'screens' | 'settings'>;
10
+ }): MinikubeAdapterArtifacts {
11
+ const { manifest, namespace, appManifest } = args;
12
+
13
+ const root = 'infra/minikube/k8s/authz/cerbos';
14
+ const resourceRoot = 'authz/cerbos';
15
+ const intent = buildCerbosPolicyIntent({ manifest, appManifest });
16
+
17
+ return {
18
+ files: [
19
+ {
20
+ path: `${root}/cerbos.configmap.yaml`,
21
+ content: getCerbosConfigMap(namespace),
22
+ },
23
+ {
24
+ path: `${root}/cerbos.policy.configmap.yaml`,
25
+ content: getCerbosPolicyConfigMap({ namespace, intent }),
26
+ },
27
+ {
28
+ path: `${root}/cerbos.deployment.yaml`,
29
+ content: getCerbosDeployment(namespace),
30
+ },
31
+ {
32
+ path: `${root}/cerbos.service.yaml`,
33
+ content: getCerbosService(namespace),
34
+ },
35
+ ],
36
+ resources: [
37
+ `${resourceRoot}/cerbos.configmap.yaml`,
38
+ `${resourceRoot}/cerbos.policy.configmap.yaml`,
39
+ `${resourceRoot}/cerbos.deployment.yaml`,
40
+ `${resourceRoot}/cerbos.service.yaml`,
41
+ ],
42
+ envEntries: ['CERBOS_URL=http://cerbos:3592'],
43
+ warnings: [],
44
+ };
45
+ }
46
+
47
+ function getCerbosConfigMap(namespace: string) {
48
+ return `apiVersion: v1
49
+ kind: ConfigMap
50
+ metadata:
51
+ name: cerbos-config
52
+ namespace: ${namespace}
53
+ data:
54
+ config.yaml: |
55
+ server:
56
+ httpListenAddr: ":3592"
57
+ storage:
58
+ driver: "disk"
59
+ disk:
60
+ directory: /policies
61
+ `;
62
+ }
63
+
64
+ function getCerbosPolicyConfigMap(args: { namespace: string; intent: CerbosPolicyIntent }) {
65
+ const { namespace, intent } = args;
66
+ const entries = getCerbosPolicyEntries(intent);
67
+ const renderedEntries = entries
68
+ .map((entry) => {
69
+ const indented = indentLines(entry.content, 4);
70
+ return ` ${entry.key}: |
71
+ ${indented}`;
72
+ })
73
+ .join('\n');
74
+
75
+ return `apiVersion: v1
76
+ kind: ConfigMap
77
+ metadata:
78
+ name: cerbos-policy
79
+ namespace: ${namespace}
80
+ data:
81
+ ${renderedEntries}
82
+ `;
83
+ }
84
+
85
+ function getCerbosDeployment(namespace: string) {
86
+ return `apiVersion: apps/v1
87
+ kind: Deployment
88
+ metadata:
89
+ name: cerbos
90
+ namespace: ${namespace}
91
+ labels:
92
+ app.kubernetes.io/name: cerbos
93
+ spec:
94
+ replicas: 1
95
+ selector:
96
+ matchLabels:
97
+ app.kubernetes.io/name: cerbos
98
+ template:
99
+ metadata:
100
+ labels:
101
+ app.kubernetes.io/name: cerbos
102
+ spec:
103
+ containers:
104
+ - name: cerbos
105
+ image: ghcr.io/cerbos/cerbos:0.40.0
106
+ args:
107
+ - server
108
+ - --config=/config/config.yaml
109
+ ports:
110
+ - containerPort: 3592
111
+ volumeMounts:
112
+ - name: cerbos-config
113
+ mountPath: /config
114
+ - name: cerbos-policy
115
+ mountPath: /policies
116
+ volumes:
117
+ - name: cerbos-config
118
+ configMap:
119
+ name: cerbos-config
120
+ - name: cerbos-policy
121
+ configMap:
122
+ name: cerbos-policy
123
+ `;
124
+ }
125
+
126
+ function getCerbosService(namespace: string) {
127
+ return `apiVersion: v1
128
+ kind: Service
129
+ metadata:
130
+ name: cerbos
131
+ namespace: ${namespace}
132
+ spec:
133
+ selector:
134
+ app.kubernetes.io/name: cerbos
135
+ ports:
136
+ - name: http
137
+ port: 3592
138
+ targetPort: 3592
139
+ `;
140
+ }
141
+
142
+ const PUBLIC_GUARD_HINTS = new Set(['public', 'guest', 'anonymous', 'unauthenticated']);
143
+ const AUTH_GUARD_HINTS = new Set([
144
+ 'auth',
145
+ 'authenticated',
146
+ 'private',
147
+ 'protected',
148
+ 'requiresauth',
149
+ 'requires-auth',
150
+ ]);
151
+
152
+ type AuthScope = NonNullable<InfraManifestInput['auth']>['scope'] | 'none';
153
+
154
+ interface CerbosRouteIntent {
155
+ name: string;
156
+ guards: string[];
157
+ screenId?: string;
158
+ }
159
+
160
+ interface CerbosPolicyIntent {
161
+ authScope: AuthScope;
162
+ publicRoutes: string[];
163
+ protectedRoutes: string[];
164
+ publicScreens: string[];
165
+ protectedScreens: string[];
166
+ }
167
+
168
+ interface CerbosPolicyEntry {
169
+ key: string;
170
+ content: string;
171
+ }
172
+
173
+ function getCerbosPolicyEntries(intent: CerbosPolicyIntent): CerbosPolicyEntry[] {
174
+ const entries: CerbosPolicyEntry[] = [
175
+ {
176
+ key: 'app.resource_policy.yaml',
177
+ content: getAppPolicy(intent),
178
+ },
179
+ ];
180
+
181
+ if (intent.publicRoutes.length > 0 || intent.protectedRoutes.length > 0) {
182
+ entries.push({
183
+ key: 'route.resource_policy.yaml',
184
+ content: getRoutePolicy(intent),
185
+ });
186
+ }
187
+
188
+ if (intent.publicScreens.length > 0 || intent.protectedScreens.length > 0) {
189
+ entries.push({
190
+ key: 'screen.resource_policy.yaml',
191
+ content: getScreenPolicy(intent),
192
+ });
193
+ }
194
+
195
+ return entries;
196
+ }
197
+
198
+ function getAppPolicy(intent: CerbosPolicyIntent): string {
199
+ const roles = intent.authScope === 'global' ? ['authenticated'] : ['anonymous', 'authenticated'];
200
+
201
+ return `apiVersion: api.cerbos.dev/v1
202
+ resourcePolicy:
203
+ version: "default"
204
+ resource: "app"
205
+ rules:
206
+ ${indentLines(renderRule({ actions: ['*'], roles }), 4)}
207
+ `;
208
+ }
209
+
210
+ function getRoutePolicy(intent: CerbosPolicyIntent): string {
211
+ const rules: string[] = [];
212
+
213
+ if (intent.publicRoutes.length > 0) {
214
+ rules.push(
215
+ renderRule({
216
+ actions: ['view', 'navigate'],
217
+ roles: ['anonymous', 'authenticated'],
218
+ conditionExpr: getInConditionExpr('route', intent.publicRoutes),
219
+ }),
220
+ );
221
+ }
222
+
223
+ if (intent.protectedRoutes.length > 0) {
224
+ rules.push(
225
+ renderRule({
226
+ actions: ['view', 'navigate'],
227
+ roles: ['authenticated'],
228
+ conditionExpr: getInConditionExpr('route', intent.protectedRoutes),
229
+ }),
230
+ );
231
+ }
232
+
233
+ return `apiVersion: api.cerbos.dev/v1
234
+ resourcePolicy:
235
+ version: "default"
236
+ resource: "route"
237
+ rules:
238
+ ${indentLines(rules.join('\n'), 4)}
239
+ `;
240
+ }
241
+
242
+ function getScreenPolicy(intent: CerbosPolicyIntent): string {
243
+ const rules: string[] = [];
244
+
245
+ if (intent.publicScreens.length > 0) {
246
+ rules.push(
247
+ renderRule({
248
+ actions: ['view', 'render'],
249
+ roles: ['anonymous', 'authenticated'],
250
+ conditionExpr: getInConditionExpr('screen_id', intent.publicScreens),
251
+ }),
252
+ );
253
+ }
254
+
255
+ if (intent.protectedScreens.length > 0) {
256
+ rules.push(
257
+ renderRule({
258
+ actions: ['view', 'render'],
259
+ roles: ['authenticated'],
260
+ conditionExpr: getInConditionExpr('screen_id', intent.protectedScreens),
261
+ }),
262
+ );
263
+ }
264
+
265
+ return `apiVersion: api.cerbos.dev/v1
266
+ resourcePolicy:
267
+ version: "default"
268
+ resource: "screen"
269
+ rules:
270
+ ${indentLines(rules.join('\n'), 4)}
271
+ `;
272
+ }
273
+
274
+ function renderRule(args: { actions: string[]; roles: string[]; conditionExpr?: string }): string {
275
+ const { actions, roles, conditionExpr } = args;
276
+
277
+ const lines = [
278
+ `- actions: ${JSON.stringify(actions)}`,
279
+ ' effect: EFFECT_ALLOW',
280
+ ` roles: ${JSON.stringify(roles)}`,
281
+ ];
282
+
283
+ if (conditionExpr) {
284
+ lines.push(' condition:');
285
+ lines.push(' match:');
286
+ lines.push(` expr: ${JSON.stringify(conditionExpr)}`);
287
+ }
288
+
289
+ return lines.join('\n');
290
+ }
291
+
292
+ function getInConditionExpr(attribute: string, values: string[]): string {
293
+ return `request.resource.attr.${attribute} in ${JSON.stringify(values)}`;
294
+ }
295
+
296
+ function indentLines(content: string, spaces: number): string {
297
+ const prefix = ' '.repeat(spaces);
298
+ return content
299
+ .trimEnd()
300
+ .split('\n')
301
+ .map((line) => `${prefix}${line}`)
302
+ .join('\n');
303
+ }
304
+
305
+ function buildCerbosPolicyIntent(args: {
306
+ manifest: InfraManifestInput;
307
+ appManifest?: Pick<AppManifest, 'metadata' | 'navigator' | 'screens' | 'settings'>;
308
+ }): CerbosPolicyIntent {
309
+ const { manifest, appManifest } = args;
310
+ const authScope = manifest.auth?.scope ?? 'none';
311
+
312
+ const routes = appManifest?.navigator ? flattenNavigatorRoutes(appManifest.navigator) : [];
313
+ const signInRoute = appManifest?.settings.authFlow.signInRoute;
314
+ const signUpRoute = appManifest?.settings.authFlow.signUpRoute;
315
+ const unauthorizedRoute = appManifest?.settings.authFlow.unauthorizedRoute;
316
+
317
+ const publicRouteSet = new Set<string>();
318
+ const protectedRouteSet = new Set<string>();
319
+
320
+ for (const route of routes) {
321
+ const isPublic = isPublicRoute({
322
+ route,
323
+ authScope,
324
+ signInRoute,
325
+ signUpRoute,
326
+ unauthorizedRoute,
327
+ });
328
+
329
+ if (isPublic) {
330
+ publicRouteSet.add(route.name);
331
+ } else {
332
+ protectedRouteSet.add(route.name);
333
+ }
334
+ }
335
+
336
+ const screenVisibility = new Map<string, 'public' | 'protected'>();
337
+ for (const route of routes) {
338
+ if (!route.screenId) continue;
339
+ const isPublic = publicRouteSet.has(route.name);
340
+
341
+ const current = screenVisibility.get(route.screenId);
342
+ if (current === 'protected') continue;
343
+ screenVisibility.set(route.screenId, isPublic ? 'public' : 'protected');
344
+ }
345
+
346
+ if (appManifest?.screens) {
347
+ const defaultVisibility: 'public' | 'protected' =
348
+ authScope === 'global' ? 'protected' : 'public';
349
+
350
+ for (const screenId of Object.keys(appManifest.screens)) {
351
+ if (!screenVisibility.has(screenId)) {
352
+ screenVisibility.set(screenId, defaultVisibility);
353
+ }
354
+ }
355
+ }
356
+
357
+ const publicScreens = [...screenVisibility.entries()]
358
+ .filter(([, visibility]) => visibility === 'public')
359
+ .map(([screenId]) => screenId)
360
+ .sort();
361
+ const protectedScreens = [...screenVisibility.entries()]
362
+ .filter(([, visibility]) => visibility === 'protected')
363
+ .map(([screenId]) => screenId)
364
+ .sort();
365
+
366
+ return {
367
+ authScope,
368
+ publicRoutes: [...publicRouteSet].sort(),
369
+ protectedRoutes: [...protectedRouteSet].sort(),
370
+ publicScreens,
371
+ protectedScreens,
372
+ };
373
+ }
374
+
375
+ function flattenNavigatorRoutes(navigator: NavigatorSpec): CerbosRouteIntent[] {
376
+ const deduped = new Map<string, CerbosRouteIntent>();
377
+
378
+ const walk = (current: NavigatorSpec) => {
379
+ for (const route of current.routes) {
380
+ const normalizedGuards = normalizeGuards(route.guards);
381
+ const existing = deduped.get(route.name);
382
+ deduped.set(route.name, {
383
+ name: route.name,
384
+ guards: unique([...(existing?.guards ?? []), ...normalizedGuards]),
385
+ screenId: route.screenId ?? existing?.screenId,
386
+ });
387
+
388
+ if (route.navigator) {
389
+ walk(route.navigator);
390
+ }
391
+ }
392
+ };
393
+
394
+ walk(navigator);
395
+ return [...deduped.values()];
396
+ }
397
+
398
+ function normalizeGuards(guards?: RouteDefinition['guards']): string[] {
399
+ return (guards ?? []).map((guard) => guard.trim().toLowerCase()).filter(Boolean);
400
+ }
401
+
402
+ function isPublicRoute(args: {
403
+ route: CerbosRouteIntent;
404
+ authScope: AuthScope;
405
+ signInRoute?: string;
406
+ signUpRoute?: string;
407
+ unauthorizedRoute?: string;
408
+ }): boolean {
409
+ const { route, authScope, signInRoute, signUpRoute, unauthorizedRoute } = args;
410
+ const guardSet = new Set(route.guards);
411
+ const signInRouteName = authFlowPathToRouteName(signInRoute);
412
+ const signUpRouteName = authFlowPathToRouteName(signUpRoute);
413
+ const unauthorizedRouteName = authFlowPathToRouteName(unauthorizedRoute);
414
+
415
+ if (
416
+ route.name === signInRouteName ||
417
+ route.name === signUpRouteName ||
418
+ route.name === unauthorizedRouteName
419
+ ) {
420
+ return true;
421
+ }
422
+
423
+ if (hasAny(guardSet, PUBLIC_GUARD_HINTS)) {
424
+ return true;
425
+ }
426
+
427
+ if (hasAny(guardSet, AUTH_GUARD_HINTS)) {
428
+ return false;
429
+ }
430
+
431
+ switch (authScope) {
432
+ case 'none':
433
+ return true;
434
+ case 'global':
435
+ default:
436
+ return false;
437
+ }
438
+ }
439
+
440
+ function hasAny(values: Set<string>, candidates: Set<string>): boolean {
441
+ for (const candidate of candidates) {
442
+ if (values.has(candidate)) return true;
443
+ }
444
+ return false;
445
+ }
446
+
447
+ function unique(values: string[]): string[] {
448
+ return [...new Set(values)];
449
+ }
450
+
451
+ function authFlowPathToRouteName(routePath: string | undefined): string | undefined {
452
+ if (!routePath) {
453
+ return undefined;
454
+ }
455
+
456
+ const normalized = routePath.trim().replace(/^\/+/, '').replace(/\/+$/, '');
457
+ return normalized === '' ? 'index' : normalized;
458
+ }
@@ -0,0 +1,32 @@
1
+ import type { AppManifest } from '@ankhorage/contracts';
2
+
3
+ import type { InfraManifestInput } from '../../../types';
4
+ import { emptyMinikubeArtifacts, type MinikubeAdapterArtifacts } from '../contracts';
5
+ import { generateCerbosAuthzArtifacts } from './cerbos';
6
+
7
+ export function generateAuthorizationArtifacts(args: {
8
+ manifest: InfraManifestInput;
9
+ namespace: string;
10
+ appManifest?: Pick<AppManifest, 'metadata' | 'navigator' | 'screens' | 'settings'>;
11
+ }): MinikubeAdapterArtifacts {
12
+ const { manifest, namespace, appManifest } = args;
13
+ const engine = manifest.auth?.authorization.engine;
14
+
15
+ if (!engine) {
16
+ return emptyMinikubeArtifacts();
17
+ }
18
+
19
+ switch (engine) {
20
+ case 'cerbos':
21
+ return generateCerbosAuthzArtifacts({ manifest, namespace, appManifest });
22
+ case 'native':
23
+ return {
24
+ ...emptyMinikubeArtifacts(),
25
+ warnings: [
26
+ 'Authorization engine "native" selected. No dedicated authz infrastructure artifacts were generated.',
27
+ ],
28
+ };
29
+ default:
30
+ throw new Error(`Unsupported authorization engine for minikube adapter: ${String(engine)}`);
31
+ }
32
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type { InfraManifestInput } from '../../../types';
4
+ import { generateMinikubeBaseArtifacts } from './index';
5
+
6
+ describe('generateMinikubeBaseArtifacts Supabase local ports', () => {
7
+ test('keeps legacy my-app defaults while allocating a different range for another app', () => {
8
+ const myAppEnv = getGeneratedFileContent('my-app', 'infra/minikube/.env.example');
9
+ const chessEnv = getGeneratedFileContent('chess', 'infra/minikube/.env.example');
10
+
11
+ expect(myAppEnv).toContain('SUPABASE_LOCAL_PORT_BASE=55320');
12
+ expect(myAppEnv).toContain('SUPABASE_LOCAL_SHADOW_PORT=55320');
13
+ expect(myAppEnv).toContain('SUPABASE_LOCAL_API_PORT=55321');
14
+ expect(myAppEnv).toContain('SUPABASE_LOCAL_DB_PORT=55322');
15
+ expect(myAppEnv).toContain('SUPABASE_LOCAL_STUDIO_PORT=55323');
16
+ expect(myAppEnv).toContain('SUPABASE_LOCAL_INBUCKET_PORT=55324');
17
+ expect(myAppEnv).toContain('SUPABASE_LOCAL_ANALYTICS_PORT=55325');
18
+
19
+ expect(chessEnv).toContain('SUPABASE_LOCAL_PORT_BASE=62200');
20
+ expect(chessEnv).toContain('SUPABASE_LOCAL_SHADOW_PORT=62200');
21
+ expect(chessEnv).toContain('SUPABASE_LOCAL_API_PORT=62201');
22
+ expect(chessEnv).toContain('SUPABASE_LOCAL_DB_PORT=62202');
23
+ expect(chessEnv).toContain('SUPABASE_LOCAL_STUDIO_PORT=62203');
24
+ expect(chessEnv).toContain('SUPABASE_LOCAL_INBUCKET_PORT=62204');
25
+ expect(chessEnv).toContain('SUPABASE_LOCAL_ANALYTICS_PORT=62205');
26
+ expect(chessEnv).not.toContain('SUPABASE_LOCAL_DB_PORT=55322');
27
+ });
28
+
29
+ test('generates bootstrap logic that derives ports from the app port base and preflights collisions', () => {
30
+ const script = getGeneratedFileContent('chess', 'infra/minikube/scripts/supabase-local-env.sh');
31
+
32
+ expect(script).toContain('SUPABASE_LOCAL_PORT_BASE="${SUPABASE_LOCAL_PORT_BASE:-62200}"');
33
+ expect(script).toContain(
34
+ 'SUPABASE_LOCAL_DB_PORT="${SUPABASE_LOCAL_DB_PORT:-$((SUPABASE_LOCAL_PORT_BASE + 2))}"',
35
+ );
36
+ expect(script).toContain('assert_supabase_local_ports_available');
37
+ expect(script).toContain('Supabase local port preflight failed for project');
38
+ expect(script).toContain(
39
+ 'Override SUPABASE_LOCAL_PORT_BASE or the specific SUPABASE_LOCAL_*_PORT values',
40
+ );
41
+ expect(script).toContain(
42
+ 'upsert_env "${ENV_FILE}" "SUPABASE_LOCAL_PORT_BASE" "${SUPABASE_LOCAL_PORT_BASE}"',
43
+ );
44
+ });
45
+
46
+ test('generates bootstrap logic that retries unhealthy local Supabase startup once', () => {
47
+ const script = getGeneratedFileContent('chess', 'infra/minikube/scripts/supabase-local-env.sh');
48
+
49
+ expect(script).toContain('start_supabase_local_stack() {');
50
+ expect(script).toContain(
51
+ 'Supabase local start failed. Stopping stale local stack and retrying once...',
52
+ );
53
+ expect(script).toContain('supabase stop --no-backup >/dev/null 2>&1 || true');
54
+ expect(script).toContain('Supabase local start failed after retry.');
55
+ expect(script).toContain('start_supabase_local_stack');
56
+ expect(countOccurrences(script, 'supabase start >/dev/null')).toBe(2);
57
+ expect(countOccurrences(script, 'supabase stop --no-backup')).toBe(1);
58
+ });
59
+ });
60
+
61
+ function getGeneratedFileContent(namespace: string, filePath: string): string {
62
+ const artifact = generateMinikubeBaseArtifacts({
63
+ manifest: createInfraManifest(),
64
+ namespace,
65
+ extraResources: [],
66
+ extraEnvEntries: [],
67
+ }).find((file) => file.path === filePath);
68
+
69
+ if (!artifact) {
70
+ throw new Error(`Expected generated artifact at ${filePath}`);
71
+ }
72
+
73
+ return artifact.content;
74
+ }
75
+
76
+ function countOccurrences(source: string, needle: string): number {
77
+ return source.split(needle).length - 1;
78
+ }
79
+
80
+ function createInfraManifest(): InfraManifestInput {
81
+ return {
82
+ deployment: {
83
+ target: 'minikube',
84
+ monitoring: false,
85
+ },
86
+ auth: {
87
+ scope: 'global',
88
+ provider: 'supabase',
89
+ authorization: {
90
+ kind: 'RBAC',
91
+ engine: 'native',
92
+ },
93
+ },
94
+ database: {
95
+ provider: 'supabase',
96
+ tier: 'dev',
97
+ },
98
+ networking: {
99
+ cdn: false,
100
+ },
101
+ plugins: [],
102
+ };
103
+ }