@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,1478 @@
1
+ import type { GeneratedInfrastructureFile } from '../../../types';
2
+ import type { InfraManifestInput } from '../../../types';
3
+
4
+ interface SupabaseLocalPorts {
5
+ base: number;
6
+ shadow: number;
7
+ api: number;
8
+ db: number;
9
+ studio: number;
10
+ inbucket: number;
11
+ analytics: number;
12
+ }
13
+
14
+ interface AppInfraStorageMetadata {
15
+ provider: 'supabase';
16
+ bucketsCsv: string;
17
+ defaultBucket: string;
18
+ }
19
+
20
+ const SUPABASE_LOCAL_PORT_BASE = 55320;
21
+ const SUPABASE_LOCAL_PORT_BUCKET_SIZE = 10;
22
+ const SUPABASE_LOCAL_PORT_BUCKET_COUNT = 1000;
23
+ const SUPABASE_LOCAL_PORT_REFERENCE_PROJECT = 'my-app';
24
+
25
+ export function generateMinikubeBaseArtifacts(args: {
26
+ manifest: InfraManifestInput;
27
+ namespace: string;
28
+ extraResources: string[];
29
+ extraEnvEntries: string[];
30
+ }): GeneratedInfrastructureFile[] {
31
+ const { manifest, namespace, extraResources, extraEnvEntries } = args;
32
+
33
+ const root = 'infra/minikube';
34
+ const k8sRoot = `${root}/k8s`;
35
+ const scriptsRoot = `${root}/scripts`;
36
+ const appImageRoot = `${root}/app-image`;
37
+ const defaultAppImage = getDefaultAppImage(namespace);
38
+ const supabaseLocalPorts = resolveSupabaseLocalPorts(namespace);
39
+
40
+ const authScope = manifest.auth?.scope ?? 'none';
41
+ const authProvider = manifest.auth?.provider ?? 'none';
42
+ const authzEngine = manifest.auth?.authorization.engine ?? 'none';
43
+ const databaseProvider = manifest.database?.provider ?? 'none';
44
+ const storageMetadata = resolveAppInfraStorageMetadata(manifest);
45
+ const monitoringEnabled = manifest.deployment?.monitoring === true;
46
+ const domain = manifest.networking?.domain ?? '';
47
+
48
+ return [
49
+ {
50
+ path: `${root}/README.md`,
51
+ content: getReadmeMarkdown({
52
+ namespace,
53
+ defaultAppImage,
54
+ monitoringEnabled,
55
+ authProvider,
56
+ authzEngine,
57
+ extraResources,
58
+ supabaseLocalPorts,
59
+ }),
60
+ },
61
+ {
62
+ path: `${root}/.env.example`,
63
+ content: getEnvExample({
64
+ namespace,
65
+ domain,
66
+ extraEnvEntries,
67
+ defaultAppImage,
68
+ supabaseLocalPorts,
69
+ }),
70
+ },
71
+ {
72
+ path: `${k8sRoot}/namespace.yaml`,
73
+ content: getNamespaceManifest(namespace),
74
+ },
75
+ {
76
+ path: `${k8sRoot}/app.configmap.yaml`,
77
+ content: getAppConfigMap({
78
+ namespace,
79
+ authScope,
80
+ authProvider,
81
+ databaseProvider,
82
+ storageMetadata,
83
+ monitoringEnabled,
84
+ domain,
85
+ }),
86
+ },
87
+ {
88
+ path: `${k8sRoot}/app/deployment.yaml`,
89
+ content: getAppDeploymentManifest({ namespace, defaultAppImage }),
90
+ },
91
+ {
92
+ path: `${k8sRoot}/app/service.yaml`,
93
+ content: getAppServiceManifest(namespace),
94
+ },
95
+ {
96
+ path: `${k8sRoot}/kustomization.yaml`,
97
+ content: getKustomizationManifest(extraResources),
98
+ },
99
+ {
100
+ path: `${scriptsRoot}/up.sh`,
101
+ content: getUpScript({ defaultNamespace: namespace, defaultAppImage }),
102
+ executable: true,
103
+ },
104
+ {
105
+ path: `${scriptsRoot}/build-app-image.sh`,
106
+ content: getBuildAppImageScript({ defaultNamespace: namespace, defaultAppImage }),
107
+ executable: true,
108
+ },
109
+ {
110
+ path: `${scriptsRoot}/port-forward.sh`,
111
+ content: getPortForwardScript(namespace),
112
+ executable: true,
113
+ },
114
+ {
115
+ path: `${scriptsRoot}/down.sh`,
116
+ content: getDownScript(),
117
+ executable: true,
118
+ },
119
+ {
120
+ path: `${scriptsRoot}/status.sh`,
121
+ content: getStatusScript(namespace),
122
+ executable: true,
123
+ },
124
+ {
125
+ path: `${scriptsRoot}/supabase-local-env.sh`,
126
+ content: getSupabaseLocalEnvScript(supabaseLocalPorts),
127
+ executable: true,
128
+ },
129
+ {
130
+ path: `${appImageRoot}/Dockerfile`,
131
+ content: getAppImageDockerfile(),
132
+ },
133
+ ];
134
+ }
135
+
136
+ function getReadmeMarkdown(args: {
137
+ namespace: string;
138
+ defaultAppImage: string;
139
+ monitoringEnabled: boolean;
140
+ authProvider: string;
141
+ authzEngine: string;
142
+ extraResources: string[];
143
+ supabaseLocalPorts: SupabaseLocalPorts;
144
+ }): string {
145
+ const {
146
+ namespace,
147
+ defaultAppImage,
148
+ monitoringEnabled,
149
+ authProvider,
150
+ authzEngine,
151
+ extraResources,
152
+ supabaseLocalPorts,
153
+ } = args;
154
+ const resourceLines = [
155
+ 'namespace.yaml',
156
+ 'app.configmap.yaml',
157
+ 'app/deployment.yaml',
158
+ 'app/service.yaml',
159
+ ...extraResources,
160
+ ]
161
+ .map((r) => `- \`k8s/${r}\``)
162
+ .join('\n');
163
+
164
+ return `# Minikube Infra
165
+
166
+ This directory is generated from \`ankh.config.json\` (infra manifest).
167
+
168
+ ## Requirements
169
+
170
+ - minikube
171
+ - kubectl
172
+ - docker (when using the default driver)
173
+ - bun (for Expo web export in app image build flow)
174
+ - python3 (for patching generated local Supabase port defaults and checking port availability)
175
+
176
+ ## Quick Start
177
+
178
+ 1. Copy env template:
179
+ \`\`\`bash
180
+ cp .env.example .env
181
+ \`\`\`
182
+ 2. Optional: import local Supabase credentials from \`supabase status\` output:
183
+ \`\`\`bash
184
+ ./scripts/supabase-local-env.sh
185
+ \`\`\`
186
+ 3. Apply baseline infra:
187
+ \`\`\`bash
188
+ ./scripts/up.sh
189
+ \`\`\`
190
+ 4. Start app port-forward:
191
+ \`\`\`bash
192
+ ./scripts/port-forward.sh
193
+ \`\`\`
194
+ 5. Check status:
195
+ \`\`\`bash
196
+ ./scripts/status.sh
197
+ \`\`\`
198
+ 6. Tear down:
199
+ \`\`\`bash
200
+ ./scripts/down.sh
201
+ \`\`\`
202
+
203
+ ## Generated Resources
204
+
205
+ ${resourceLines}
206
+
207
+ ## Generated Helpers
208
+
209
+ - \`scripts/build-app-image.sh\`
210
+ - \`scripts/port-forward.sh\`
211
+ - \`scripts/supabase-local-env.sh\`
212
+ - \`app-image/Dockerfile\`
213
+
214
+ ## Runtime Conventions
215
+
216
+ - Namespace: \`${namespace}\`
217
+ - Monitoring requested: \`${monitoringEnabled ? 'true' : 'false'}\`
218
+ - Auth provider: \`${authProvider}\`
219
+ - Authorization engine: \`${authzEngine}\`
220
+ - Namespace source: \`infra.networking.domain\` first, otherwise project slug hint from CLI.
221
+
222
+ ## App Runtime
223
+
224
+ Default app runtime manifests are generated:
225
+
226
+ - \`k8s/app/deployment.yaml\`
227
+ - \`k8s/app/service.yaml\`
228
+
229
+ Deployment env wiring includes:
230
+
231
+ - \`ConfigMap/app-infra-config\`
232
+ - \`ConfigMap/app-runtime-auth-env\` (optional)
233
+ - \`ConfigMap/app-runtime-storage-env\` (optional)
234
+ - \`Secret/supabase-auth-secrets\` (optional)
235
+
236
+ If \`auth.provider\` is \`supabase\`, use:
237
+
238
+ - \`k8s/auth/supabase/supabase-auth.secret.yaml\`
239
+ - \`k8s/auth/supabase/app-runtime-auth.env.configmap.yaml\`
240
+ - \`auth/supabase-runtime-wiring.md\`
241
+
242
+ The app deployment should import runtime config sources in this order:
243
+
244
+ \`\`\`yaml
245
+ envFrom:
246
+ - configMapRef:
247
+ name: app-runtime-auth-env
248
+ - configMapRef:
249
+ name: app-runtime-storage-env
250
+ - secretRef:
251
+ name: supabase-auth-secrets
252
+ \`\`\`
253
+
254
+ Default runtime knobs in \`.env\`:
255
+
256
+ - \`APP_BUILD_ENABLED\`
257
+ - \`APP_SOURCE_DIR\`
258
+ - \`APP_WEB_EXPORT_DIR\`
259
+ - \`APP_IMAGE\`
260
+ - \`APP_IMAGE_SYNC_STRATEGY\`
261
+ - \`APP_IMAGE_CLEANUP_ON_DOWN\`
262
+ - \`APP_IMAGE_CLEANUP_MINIKUBE\`
263
+ - \`APP_IMAGE_CLEANUP_DOCKER\`
264
+ - \`AUTH_RUNTIME_MODE\`
265
+ - \`APP_PORT_FORWARD_LOCAL_PORT\`
266
+ - \`APP_PORT_FORWARD_REMOTE_PORT\`
267
+ - \`APP_REPLICAS\`
268
+ - \`APP_FORCE_ROLLOUT_RESTART\`
269
+ - \`SUPABASE_PROJECT_DIR\` (optional supabase CLI project path)
270
+ - \`SUPABASE_LOCAL_PORT_BASE\` (generated default \`${supabaseLocalPorts.base}\`)
271
+ - \`SUPABASE_LOCAL_API_PORT\` (generated default \`${supabaseLocalPorts.api}\`)
272
+ - \`SUPABASE_LOCAL_DB_PORT\` (generated default \`${supabaseLocalPorts.db}\`)
273
+ - \`SUPABASE_LOCAL_STUDIO_PORT\` (generated default \`${supabaseLocalPorts.studio}\`)
274
+ - \`SUPABASE_LOCAL_INBUCKET_PORT\` (generated default \`${supabaseLocalPorts.inbucket}\`)
275
+ - \`SUPABASE_LOCAL_ANALYTICS_PORT\` (generated default \`${supabaseLocalPorts.analytics}\`)
276
+ - \`SUPABASE_LOCAL_SHADOW_PORT\` (generated default \`${supabaseLocalPorts.shadow}\`)
277
+ - \`APP_IMAGE_PULL_SECRET_NAME\` + credentials (optional, private registry)
278
+ - \`SUPABASE_SECRET_SYNC_ENABLED\`
279
+
280
+ Generated app Supabase local defaults:
281
+
282
+ - Port base: \`${supabaseLocalPorts.base}\`
283
+ - API: \`http://127.0.0.1:${supabaseLocalPorts.api}\`
284
+ - DB: \`127.0.0.1:${supabaseLocalPorts.db}\`
285
+ - Studio: \`http://127.0.0.1:${supabaseLocalPorts.studio}\`
286
+ - Inbucket: \`http://127.0.0.1:${supabaseLocalPorts.inbucket}\`
287
+ - Analytics: \`http://127.0.0.1:${supabaseLocalPorts.analytics}\`
288
+ - Shadow DB: \`127.0.0.1:${supabaseLocalPorts.shadow}\`
289
+
290
+ Override with:
291
+
292
+ - \`SUPABASE_LOCAL_PORT_BASE\`
293
+ - \`SUPABASE_LOCAL_API_PORT\`
294
+ - \`SUPABASE_LOCAL_DB_PORT\`
295
+ - \`SUPABASE_LOCAL_STUDIO_PORT\`
296
+ - \`SUPABASE_LOCAL_INBUCKET_PORT\`
297
+ - \`SUPABASE_LOCAL_ANALYTICS_PORT\`
298
+ - \`SUPABASE_LOCAL_SHADOW_PORT\`
299
+
300
+ Build flow:
301
+
302
+ - \`./scripts/up.sh\` calls \`./scripts/build-app-image.sh\` when \`APP_BUILD_ENABLED=true\`.
303
+ - \`./scripts/supabase-local-env.sh\` can populate Supabase keys in \`.env\` from local \`supabase status -o env\`.
304
+ - \`./scripts/supabase-local-env.sh\` writes \`EXPO_PUBLIC_SUPABASE_URL\` + \`EXPO_PUBLIC_SUPABASE_ANON_KEY\` into \`$APP_SOURCE_DIR/.env.local\` (e.g. \`apps/card/.env.local\`) for local app runs.
305
+ - With \`AUTH_RUNTIME_MODE=local\` (default), \`up.sh\` auto-runs \`supabase-local-env.sh\` when Supabase keys are missing.
306
+ - \`supabase-local-env.sh\` checks configured host ports before starting a missing local Supabase stack.
307
+ - \`build-app-image.sh\` runs \`bunx expo export --platform web\` from app source.
308
+ - \`app-image/Dockerfile\` builds an nginx image from exported web artifacts.
309
+ - \`build-app-image.sh\` labels the generated Docker image with Ankhorage metadata for diagnostics.
310
+ - \`up.sh\` syncs runtime image by strategy:
311
+ - \`APP_IMAGE_SYNC_STRATEGY=docker-load\` (default): load prebuilt local Docker image into Minikube.
312
+ - \`APP_IMAGE_SYNC_STRATEGY=minikube-build\`: build directly into Minikube image store.
313
+ - \`APP_IMAGE_SYNC_STRATEGY=none\`: skip local image sync.
314
+ - \`up.sh\` applies image/replica changes and (by default) restarts \`deployment/app-runtime\`.
315
+ - If Supabase keys are provided, \`up.sh\` syncs \`Secret/supabase-auth-secrets\` from env.
316
+
317
+ Default generated app image tag: \`${defaultAppImage}\`.
318
+
319
+ ## Notes
320
+
321
+ - Auth/database/authorization resources are generated in adapter modules.
322
+ - Cerbos route/screen policies derive from app navigator + authFlow intent when available.
323
+ - Re-generate infra by saving the manifest or calling the bridge infra endpoint.
324
+ `;
325
+ }
326
+
327
+ function getEnvExample(args: {
328
+ namespace: string;
329
+ domain: string;
330
+ extraEnvEntries: string[];
331
+ defaultAppImage: string;
332
+ supabaseLocalPorts: SupabaseLocalPorts;
333
+ }): string {
334
+ const { namespace, domain, extraEnvEntries, defaultAppImage, supabaseLocalPorts } = args;
335
+
336
+ const baseEntries = [
337
+ '# Minikube runtime configuration',
338
+ `ANKH_NAMESPACE=${namespace}`,
339
+ 'MINIKUBE_PROFILE=minikube',
340
+ 'MINIKUBE_DRIVER=docker',
341
+ 'APP_BUILD_ENABLED=true',
342
+ 'APP_SOURCE_DIR=',
343
+ 'APP_WEB_EXPORT_DIR=.ankh/web-export',
344
+ 'SUPABASE_PROJECT_DIR=',
345
+ `SUPABASE_LOCAL_PORT_BASE=${supabaseLocalPorts.base}`,
346
+ `SUPABASE_LOCAL_SHADOW_PORT=${supabaseLocalPorts.shadow}`,
347
+ `SUPABASE_LOCAL_API_PORT=${supabaseLocalPorts.api}`,
348
+ `SUPABASE_LOCAL_DB_PORT=${supabaseLocalPorts.db}`,
349
+ `SUPABASE_LOCAL_STUDIO_PORT=${supabaseLocalPorts.studio}`,
350
+ `SUPABASE_LOCAL_INBUCKET_PORT=${supabaseLocalPorts.inbucket}`,
351
+ `SUPABASE_LOCAL_ANALYTICS_PORT=${supabaseLocalPorts.analytics}`,
352
+ `APP_IMAGE=${defaultAppImage}`,
353
+ 'APP_IMAGE_SYNC_STRATEGY=docker-load',
354
+ 'APP_IMAGE_CLEANUP_ON_DOWN=true',
355
+ 'APP_IMAGE_CLEANUP_MINIKUBE=true',
356
+ 'APP_IMAGE_CLEANUP_DOCKER=true',
357
+ 'AUTH_RUNTIME_MODE=local',
358
+ 'APP_PORT_FORWARD_LOCAL_PORT=18080',
359
+ 'APP_PORT_FORWARD_REMOTE_PORT=80',
360
+ 'APP_REPLICAS=1',
361
+ 'APP_FORCE_ROLLOUT_RESTART=true',
362
+ '',
363
+ '# Optional private registry auth for APP_IMAGE',
364
+ 'APP_IMAGE_PULL_SECRET_NAME=',
365
+ 'APP_IMAGE_PULL_SECRET_SERVER=ghcr.io',
366
+ 'APP_IMAGE_PULL_SECRET_USERNAME=',
367
+ 'APP_IMAGE_PULL_SECRET_PASSWORD=',
368
+ 'APP_IMAGE_PULL_SECRET_EMAIL=',
369
+ '',
370
+ '# Optional app networking domain',
371
+ `APP_DOMAIN=${domain}`,
372
+ '',
373
+ '# Adapter-provided runtime keys',
374
+ ];
375
+
376
+ const merged = [...baseEntries, ...extraEnvEntries];
377
+ const unique: string[] = [];
378
+ for (const entry of merged) {
379
+ if (!unique.includes(entry)) unique.push(entry);
380
+ }
381
+
382
+ return `${unique.join('\n')}\n`;
383
+ }
384
+
385
+ function getNamespaceManifest(namespace: string): string {
386
+ return `apiVersion: v1
387
+ kind: Namespace
388
+ metadata:
389
+ name: ${namespace}
390
+ labels:
391
+ app.kubernetes.io/part-of: ankhorage
392
+ `;
393
+ }
394
+
395
+ function getAppConfigMap(args: {
396
+ namespace: string;
397
+ authScope: string;
398
+ authProvider: string;
399
+ databaseProvider: string;
400
+ storageMetadata: AppInfraStorageMetadata | null;
401
+ monitoringEnabled: boolean;
402
+ domain: string;
403
+ }): string {
404
+ const {
405
+ namespace,
406
+ authScope,
407
+ authProvider,
408
+ databaseProvider,
409
+ storageMetadata,
410
+ monitoringEnabled,
411
+ domain,
412
+ } = args;
413
+
414
+ const storageLines = storageMetadata
415
+ ? ` STORAGE_PROVIDER: "${storageMetadata.provider}"
416
+ STORAGE_BUCKETS: "${escapeYamlDoubleQuoted(storageMetadata.bucketsCsv)}"
417
+ STORAGE_DEFAULT_BUCKET: "${escapeYamlDoubleQuoted(storageMetadata.defaultBucket)}"
418
+ `
419
+ : '';
420
+
421
+ return `apiVersion: v1
422
+ kind: ConfigMap
423
+ metadata:
424
+ name: app-infra-config
425
+ namespace: ${namespace}
426
+ data:
427
+ DEPLOYMENT_TARGET: "minikube"
428
+ MONITORING_ENABLED: "${monitoringEnabled ? 'true' : 'false'}"
429
+ AUTH_SCOPE: "${authScope}"
430
+ AUTH_PROVIDER: "${authProvider}"
431
+ DATABASE_PROVIDER: "${databaseProvider}"
432
+ ${storageLines} NETWORK_DOMAIN: "${domain}"
433
+ `;
434
+ }
435
+
436
+ function resolveAppInfraStorageMetadata(
437
+ manifest: InfraManifestInput,
438
+ ): AppInfraStorageMetadata | null {
439
+ const spec = manifest.storage;
440
+ if (!spec) return null;
441
+
442
+ const buckets = normalizeBuckets(spec.buckets);
443
+ if (buckets.length === 0) return null;
444
+ const [defaultBucket] = buckets;
445
+ if (!defaultBucket) return null;
446
+
447
+ if (spec.provider === 'supabase') {
448
+ return {
449
+ provider: 'supabase',
450
+ bucketsCsv: buckets.join(','),
451
+ defaultBucket,
452
+ };
453
+ }
454
+
455
+ if (spec.provider === 'auto') {
456
+ const shouldResolveToSupabase =
457
+ manifest.database?.provider === 'supabase' || manifest.auth?.provider === 'supabase';
458
+
459
+ if (!shouldResolveToSupabase) return null;
460
+
461
+ return {
462
+ provider: 'supabase',
463
+ bucketsCsv: buckets.join(','),
464
+ defaultBucket,
465
+ };
466
+ }
467
+
468
+ return null;
469
+ }
470
+
471
+ function normalizeBuckets(rawBuckets: readonly string[]): string[] {
472
+ const normalized: string[] = [];
473
+ const seen = new Set<string>();
474
+
475
+ for (const raw of rawBuckets) {
476
+ const bucket = raw.trim();
477
+ if (!bucket) continue;
478
+ if (seen.has(bucket)) continue;
479
+ seen.add(bucket);
480
+ normalized.push(bucket);
481
+ }
482
+
483
+ return normalized;
484
+ }
485
+
486
+ function escapeYamlDoubleQuoted(value: string): string {
487
+ return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
488
+ }
489
+
490
+ function getKustomizationManifest(extraResources: string[]): string {
491
+ const resources = [
492
+ 'namespace.yaml',
493
+ 'app.configmap.yaml',
494
+ 'app/deployment.yaml',
495
+ 'app/service.yaml',
496
+ ...extraResources,
497
+ ];
498
+ const resourceLines = resources.map((r) => ` - ${r}`).join('\n');
499
+
500
+ return `apiVersion: kustomize.config.k8s.io/v1beta1
501
+ kind: Kustomization
502
+ resources:
503
+ ${resourceLines}
504
+ `;
505
+ }
506
+
507
+ function getAppDeploymentManifest(args: { namespace: string; defaultAppImage: string }): string {
508
+ const { namespace, defaultAppImage } = args;
509
+
510
+ return `apiVersion: apps/v1
511
+ kind: Deployment
512
+ metadata:
513
+ name: app-runtime
514
+ namespace: ${namespace}
515
+ labels:
516
+ app.kubernetes.io/name: app-runtime
517
+ spec:
518
+ replicas: 1
519
+ selector:
520
+ matchLabels:
521
+ app.kubernetes.io/name: app-runtime
522
+ template:
523
+ metadata:
524
+ labels:
525
+ app.kubernetes.io/name: app-runtime
526
+ spec:
527
+ containers:
528
+ - name: app
529
+ image: ${defaultAppImage}
530
+ imagePullPolicy: IfNotPresent
531
+ ports:
532
+ - containerPort: 8080
533
+ name: http
534
+ envFrom:
535
+ - configMapRef:
536
+ name: app-infra-config
537
+ - configMapRef:
538
+ name: app-runtime-auth-env
539
+ optional: true
540
+ - configMapRef:
541
+ name: app-runtime-storage-env
542
+ optional: true
543
+ - secretRef:
544
+ name: supabase-auth-secrets
545
+ optional: true
546
+ readinessProbe:
547
+ httpGet:
548
+ path: /
549
+ port: http
550
+ initialDelaySeconds: 5
551
+ periodSeconds: 10
552
+ livenessProbe:
553
+ httpGet:
554
+ path: /
555
+ port: http
556
+ initialDelaySeconds: 15
557
+ periodSeconds: 20
558
+ `;
559
+ }
560
+
561
+ function getAppServiceManifest(namespace: string): string {
562
+ return `apiVersion: v1
563
+ kind: Service
564
+ metadata:
565
+ name: app-runtime
566
+ namespace: ${namespace}
567
+ labels:
568
+ app.kubernetes.io/name: app-runtime
569
+ spec:
570
+ type: ClusterIP
571
+ selector:
572
+ app.kubernetes.io/name: app-runtime
573
+ ports:
574
+ - name: http
575
+ port: 80
576
+ targetPort: http
577
+ `;
578
+ }
579
+
580
+ function getUpScript(args: { defaultNamespace: string; defaultAppImage: string }): string {
581
+ const { defaultNamespace, defaultAppImage } = args;
582
+
583
+ return `#!/usr/bin/env bash
584
+ set -euo pipefail
585
+
586
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
587
+ ROOT_DIR="$(cd "\${SCRIPT_DIR}/.." && pwd)"
588
+ K8S_DIR="\${ROOT_DIR}/k8s"
589
+ BUILD_SCRIPT="\${SCRIPT_DIR}/build-app-image.sh"
590
+
591
+ if [[ -f "\${ROOT_DIR}/.env" ]]; then
592
+ set -a
593
+ # shellcheck disable=SC1090
594
+ source "\${ROOT_DIR}/.env"
595
+ set +a
596
+ fi
597
+
598
+ PROFILE="\${MINIKUBE_PROFILE:-minikube}"
599
+ DRIVER="\${MINIKUBE_DRIVER:-docker}"
600
+ NAMESPACE="\${ANKH_NAMESPACE:-${defaultNamespace}}"
601
+ APP_BUILD_ENABLED="\${APP_BUILD_ENABLED:-true}"
602
+ APP_SOURCE_DIR="\${APP_SOURCE_DIR:-$(cd "\${ROOT_DIR}/../.." && pwd)}"
603
+ APP_WEB_EXPORT_DIR="\${APP_WEB_EXPORT_DIR:-.ankh/web-export}"
604
+ APP_IMAGE="\${APP_IMAGE:-${defaultAppImage}}"
605
+ APP_IMAGE_SYNC_STRATEGY="\${APP_IMAGE_SYNC_STRATEGY:-docker-load}"
606
+ APP_REPLICAS="\${APP_REPLICAS:-1}"
607
+ APP_FORCE_ROLLOUT_RESTART="\${APP_FORCE_ROLLOUT_RESTART:-true}"
608
+ APP_IMAGE_PULL_SECRET_NAME="\${APP_IMAGE_PULL_SECRET_NAME:-}"
609
+ APP_IMAGE_PULL_SECRET_SERVER="\${APP_IMAGE_PULL_SECRET_SERVER:-ghcr.io}"
610
+ APP_IMAGE_PULL_SECRET_USERNAME="\${APP_IMAGE_PULL_SECRET_USERNAME:-}"
611
+ APP_IMAGE_PULL_SECRET_PASSWORD="\${APP_IMAGE_PULL_SECRET_PASSWORD:-}"
612
+ APP_IMAGE_PULL_SECRET_EMAIL="\${APP_IMAGE_PULL_SECRET_EMAIL:-}"
613
+ AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
614
+ SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
615
+ SUPABASE_LOCAL_ENV_SCRIPT="\${SCRIPT_DIR}/supabase-local-env.sh"
616
+ SUPABASE_URL="\${SUPABASE_URL:-}"
617
+ SUPABASE_ANON_KEY="\${SUPABASE_ANON_KEY:-}"
618
+ SUPABASE_SERVICE_ROLE_KEY="\${SUPABASE_SERVICE_ROLE_KEY:-}"
619
+ SUPABASE_JWT_SECRET="\${SUPABASE_JWT_SECRET:-}"
620
+ SUPABASE_PUBLIC_URL="\${EXPO_PUBLIC_SUPABASE_URL:-\${SUPABASE_URL}}"
621
+ SUPABASE_PUBLIC_ANON_KEY="\${EXPO_PUBLIC_SUPABASE_ANON_KEY:-\${SUPABASE_ANON_KEY}}"
622
+
623
+ refresh_supabase_env() {
624
+ SUPABASE_URL="\${SUPABASE_URL:-}"
625
+ SUPABASE_ANON_KEY="\${SUPABASE_ANON_KEY:-}"
626
+ SUPABASE_SERVICE_ROLE_KEY="\${SUPABASE_SERVICE_ROLE_KEY:-}"
627
+ SUPABASE_JWT_SECRET="\${SUPABASE_JWT_SECRET:-}"
628
+ SUPABASE_PUBLIC_URL="\${EXPO_PUBLIC_SUPABASE_URL:-\${SUPABASE_URL}}"
629
+ SUPABASE_PUBLIC_ANON_KEY="\${EXPO_PUBLIC_SUPABASE_ANON_KEY:-\${SUPABASE_ANON_KEY}}"
630
+ }
631
+
632
+ has_required_supabase_env() {
633
+ [[ -n "\${SUPABASE_URL}" && -n "\${SUPABASE_ANON_KEY}" && -n "\${SUPABASE_SERVICE_ROLE_KEY}" && -n "\${SUPABASE_JWT_SECRET}" ]]
634
+ }
635
+
636
+ refresh_supabase_env
637
+
638
+ if [[ "\${AUTH_RUNTIME_MODE}" != "local" && "\${AUTH_RUNTIME_MODE}" != "remote" ]]; then
639
+ echo "Unsupported AUTH_RUNTIME_MODE=\${AUTH_RUNTIME_MODE}. Use local or remote."
640
+ exit 1
641
+ fi
642
+
643
+ if ! command -v minikube >/dev/null 2>&1; then
644
+ echo "minikube is required but not installed."
645
+ exit 1
646
+ fi
647
+
648
+ if ! command -v kubectl >/dev/null 2>&1; then
649
+ echo "kubectl is required but not installed."
650
+ exit 1
651
+ fi
652
+
653
+ HOST_STATUS="$(minikube -p "\${PROFILE}" status --format='{{.Host}}' 2>/dev/null || true)"
654
+ if [[ "\${HOST_STATUS}" != "Running" ]]; then
655
+ minikube start -p "\${PROFILE}" --driver="\${DRIVER}"
656
+ fi
657
+
658
+ if ! has_required_supabase_env && [[ "\${AUTH_RUNTIME_MODE}" == "local" ]]; then
659
+ if [[ ! -x "\${SUPABASE_LOCAL_ENV_SCRIPT}" ]]; then
660
+ echo "Supabase local bootstrap skipped before app build: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
661
+ else
662
+ echo "Supabase keys missing before app build; running \${SUPABASE_LOCAL_ENV_SCRIPT}."
663
+ if ! "\${SUPABASE_LOCAL_ENV_SCRIPT}"; then
664
+ echo "Supabase local bootstrap failed before app build; continuing."
665
+ fi
666
+ if [[ -f "\${ROOT_DIR}/.env" ]]; then
667
+ set -a
668
+ # shellcheck disable=SC1090
669
+ source "\${ROOT_DIR}/.env"
670
+ set +a
671
+ fi
672
+ refresh_supabase_env
673
+ fi
674
+ fi
675
+
676
+ if [[ "\${APP_BUILD_ENABLED}" == "true" ]]; then
677
+ if [[ ! -x "\${BUILD_SCRIPT}" ]]; then
678
+ echo "Missing build helper script: \${BUILD_SCRIPT}"
679
+ exit 1
680
+ fi
681
+
682
+ "\${BUILD_SCRIPT}"
683
+ fi
684
+
685
+ if [[ "\${APP_WEB_EXPORT_DIR}" = /* ]]; then
686
+ EXPORT_DIR="\${APP_WEB_EXPORT_DIR}"
687
+ else
688
+ EXPORT_DIR="\${APP_SOURCE_DIR}/\${APP_WEB_EXPORT_DIR}"
689
+ fi
690
+
691
+ docker_load_image_to_minikube() {
692
+ if command -v docker >/dev/null 2>&1 && docker image inspect "\${APP_IMAGE}" >/dev/null 2>&1; then
693
+ minikube -p "\${PROFILE}" image load --daemon=true --overwrite=true "\${APP_IMAGE}" >/dev/null
694
+ return 0
695
+ fi
696
+
697
+ return 1
698
+ }
699
+
700
+ case "\${APP_IMAGE_SYNC_STRATEGY}" in
701
+ minikube-build)
702
+ if [[ ! -f "\${EXPORT_DIR}/index.html" ]]; then
703
+ echo "Expected web export output not found at \${EXPORT_DIR}/index.html"
704
+ echo "Run build-app-image.sh first or switch APP_IMAGE_SYNC_STRATEGY=docker-load."
705
+ exit 1
706
+ fi
707
+
708
+ if minikube -p "\${PROFILE}" image build -t "\${APP_IMAGE}" -f "\${ROOT_DIR}/app-image/Dockerfile" "\${EXPORT_DIR}" >/dev/null; then
709
+ if ! minikube -p "\${PROFILE}" image ls 2>/dev/null | grep -Fq "\${APP_IMAGE}"; then
710
+ echo "minikube image build did not register \${APP_IMAGE}; falling back to docker-load."
711
+ if ! docker_load_image_to_minikube; then
712
+ echo "Fallback docker-load failed: local Docker image \${APP_IMAGE} not found."
713
+ exit 1
714
+ fi
715
+ fi
716
+ else
717
+ echo "minikube image build failed for \${APP_IMAGE}; falling back to docker-load."
718
+ if ! docker_load_image_to_minikube; then
719
+ echo "Fallback docker-load failed: local Docker image \${APP_IMAGE} not found."
720
+ exit 1
721
+ fi
722
+ fi
723
+ ;;
724
+ docker-load)
725
+ if ! docker_load_image_to_minikube; then
726
+ echo "Skipping docker-load image sync; local Docker image \${APP_IMAGE} not found."
727
+ fi
728
+ ;;
729
+ none)
730
+ echo "Skipping local image sync (APP_IMAGE_SYNC_STRATEGY=none)."
731
+ ;;
732
+ *)
733
+ echo "Unsupported APP_IMAGE_SYNC_STRATEGY=\${APP_IMAGE_SYNC_STRATEGY}. Use minikube-build, docker-load, or none."
734
+ exit 1
735
+ ;;
736
+ esac
737
+
738
+ kubectl apply -k "\${K8S_DIR}"
739
+
740
+ if [[ "\${SUPABASE_SECRET_SYNC_ENABLED}" == "true" ]]; then
741
+ if ! has_required_supabase_env && [[ "\${AUTH_RUNTIME_MODE}" == "local" ]]; then
742
+ if [[ ! -x "\${SUPABASE_LOCAL_ENV_SCRIPT}" ]]; then
743
+ echo "Supabase local bootstrap skipped: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
744
+ else
745
+ echo "Supabase keys missing; running \${SUPABASE_LOCAL_ENV_SCRIPT}."
746
+ if ! "\${SUPABASE_LOCAL_ENV_SCRIPT}"; then
747
+ echo "Supabase local bootstrap failed; continuing without local credential import."
748
+ fi
749
+ if [[ -f "\${ROOT_DIR}/.env" ]]; then
750
+ set -a
751
+ # shellcheck disable=SC1090
752
+ source "\${ROOT_DIR}/.env"
753
+ set +a
754
+ fi
755
+ refresh_supabase_env
756
+ fi
757
+ fi
758
+
759
+ if ! has_required_supabase_env; then
760
+ if [[ "\${AUTH_RUNTIME_MODE}" == "local" ]]; then
761
+ echo "Supabase secret sync skipped: local credentials still missing after bootstrap."
762
+ echo "Run ./scripts/supabase-local-env.sh and retry."
763
+ else
764
+ echo "Supabase secret sync skipped: set SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, and SUPABASE_JWT_SECRET in .env."
765
+ fi
766
+ else
767
+ TMP_SUPABASE_ENV_FILE="$(mktemp)"
768
+ trap 'rm -f "\${TMP_SUPABASE_ENV_FILE}"' EXIT
769
+
770
+ cat > "\${TMP_SUPABASE_ENV_FILE}" <<EOF
771
+ SUPABASE_URL=\${SUPABASE_URL}
772
+ SUPABASE_ANON_KEY=\${SUPABASE_ANON_KEY}
773
+ SUPABASE_SERVICE_ROLE_KEY=\${SUPABASE_SERVICE_ROLE_KEY}
774
+ SUPABASE_JWT_SECRET=\${SUPABASE_JWT_SECRET}
775
+ EXPO_PUBLIC_SUPABASE_URL=\${SUPABASE_PUBLIC_URL}
776
+ EXPO_PUBLIC_SUPABASE_ANON_KEY=\${SUPABASE_PUBLIC_ANON_KEY}
777
+ EOF
778
+
779
+ kubectl -n "\${NAMESPACE}" create secret generic supabase-auth-secrets --from-env-file="\${TMP_SUPABASE_ENV_FILE}" --dry-run=client -o yaml | kubectl -n "\${NAMESPACE}" apply -f -
780
+
781
+ rm -f "\${TMP_SUPABASE_ENV_FILE}"
782
+ trap - EXIT
783
+ echo "Synchronized supabase-auth-secrets from environment values."
784
+ fi
785
+ fi
786
+
787
+ if [[ -n "\${APP_IMAGE_PULL_SECRET_NAME}" ]]; then
788
+ if [[ -z "\${APP_IMAGE_PULL_SECRET_USERNAME}" || -z "\${APP_IMAGE_PULL_SECRET_PASSWORD}" ]]; then
789
+ echo "APP_IMAGE_PULL_SECRET_NAME is set, but username/password are missing."
790
+ exit 1
791
+ fi
792
+
793
+ SECRET_EMAIL_ARG=()
794
+ if [[ -n "\${APP_IMAGE_PULL_SECRET_EMAIL}" ]]; then
795
+ SECRET_EMAIL_ARG=(--docker-email="\${APP_IMAGE_PULL_SECRET_EMAIL}")
796
+ fi
797
+
798
+ kubectl -n "\${NAMESPACE}" create secret docker-registry "\${APP_IMAGE_PULL_SECRET_NAME}" --docker-server="\${APP_IMAGE_PULL_SECRET_SERVER}" --docker-username="\${APP_IMAGE_PULL_SECRET_USERNAME}" --docker-password="\${APP_IMAGE_PULL_SECRET_PASSWORD}" "\${SECRET_EMAIL_ARG[@]}" --dry-run=client -o yaml | kubectl -n "\${NAMESPACE}" apply -f -
799
+
800
+ PULL_SECRET_PATCH="$(cat <<EOF
801
+ {"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"\${APP_IMAGE_PULL_SECRET_NAME}"}]}}}}
802
+ EOF
803
+ )"
804
+ kubectl -n "\${NAMESPACE}" patch deployment app-runtime --type=merge --patch "\${PULL_SECRET_PATCH}" >/dev/null
805
+ else
806
+ kubectl -n "\${NAMESPACE}" patch deployment app-runtime --type=json --patch='[{"op":"remove","path":"/spec/template/spec/imagePullSecrets"}]' >/dev/null 2>&1 || true
807
+ fi
808
+
809
+ kubectl -n "\${NAMESPACE}" set image deployment/app-runtime app="\${APP_IMAGE}" >/dev/null
810
+ kubectl -n "\${NAMESPACE}" scale deployment/app-runtime --replicas="\${APP_REPLICAS}" >/dev/null
811
+
812
+ if [[ "\${APP_FORCE_ROLLOUT_RESTART}" == "true" ]]; then
813
+ kubectl -n "\${NAMESPACE}" rollout restart deployment/app-runtime >/dev/null
814
+ fi
815
+
816
+ kubectl -n "\${NAMESPACE}" rollout status deployment/app-runtime --timeout=180s >/dev/null
817
+ echo "Minikube infrastructure applied."
818
+ `;
819
+ }
820
+
821
+ function getBuildAppImageScript(args: {
822
+ defaultNamespace: string;
823
+ defaultAppImage: string;
824
+ }): string {
825
+ const { defaultNamespace, defaultAppImage } = args;
826
+
827
+ return `#!/usr/bin/env bash
828
+ set -euo pipefail
829
+
830
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
831
+ ROOT_DIR="$(cd "\${SCRIPT_DIR}/.." && pwd)"
832
+ DOCKERFILE_PATH="\${ROOT_DIR}/app-image/Dockerfile"
833
+
834
+ if [[ -f "\${ROOT_DIR}/.env" ]]; then
835
+ set -a
836
+ # shellcheck disable=SC1090
837
+ source "\${ROOT_DIR}/.env"
838
+ set +a
839
+ fi
840
+
841
+ NAMESPACE="\${ANKH_NAMESPACE:-${defaultNamespace}}"
842
+ APP_BUILD_ENABLED="\${APP_BUILD_ENABLED:-true}"
843
+ APP_SOURCE_DIR="\${APP_SOURCE_DIR:-$(cd "\${ROOT_DIR}/../.." && pwd)}"
844
+ APP_WEB_EXPORT_DIR="\${APP_WEB_EXPORT_DIR:-.ankh/web-export}"
845
+ APP_IMAGE="\${APP_IMAGE:-${defaultAppImage}}"
846
+
847
+ if [[ "\${APP_BUILD_ENABLED}" != "true" ]]; then
848
+ echo "Skipping app image build (APP_BUILD_ENABLED=\${APP_BUILD_ENABLED})."
849
+ exit 0
850
+ fi
851
+
852
+ if ! command -v docker >/dev/null 2>&1; then
853
+ echo "docker is required to build app image."
854
+ exit 1
855
+ fi
856
+
857
+ if ! command -v bunx >/dev/null 2>&1; then
858
+ echo "bunx is required to export the app for container image build."
859
+ exit 1
860
+ fi
861
+
862
+ if [[ ! -f "\${APP_SOURCE_DIR}/package.json" ]]; then
863
+ echo "APP_SOURCE_DIR does not look like an app root: \${APP_SOURCE_DIR}"
864
+ exit 1
865
+ fi
866
+
867
+ if [[ ! -f "\${DOCKERFILE_PATH}" ]]; then
868
+ echo "Dockerfile for app image build is missing: \${DOCKERFILE_PATH}"
869
+ exit 1
870
+ fi
871
+
872
+ (
873
+ cd "\${APP_SOURCE_DIR}"
874
+ bunx expo export --platform web --output-dir "\${APP_WEB_EXPORT_DIR}"
875
+ )
876
+
877
+ if [[ "\${APP_WEB_EXPORT_DIR}" = /* ]]; then
878
+ EXPORT_DIR="\${APP_WEB_EXPORT_DIR}"
879
+ else
880
+ EXPORT_DIR="\${APP_SOURCE_DIR}/\${APP_WEB_EXPORT_DIR}"
881
+ fi
882
+
883
+ if [[ ! -f "\${EXPORT_DIR}/index.html" ]]; then
884
+ echo "Expected web export output not found at \${EXPORT_DIR}/index.html"
885
+ exit 1
886
+ fi
887
+
888
+ docker build -t "\${APP_IMAGE}" \
889
+ --label "ankhorage.kind=generated-app" \
890
+ --label "ankhorage.namespace=\${NAMESPACE}" \
891
+ --label "ankhorage.image=\${APP_IMAGE}" \
892
+ -f "\${DOCKERFILE_PATH}" \
893
+ "\${EXPORT_DIR}"
894
+ echo "Built app image: \${APP_IMAGE} (namespace: \${NAMESPACE})"
895
+ `;
896
+ }
897
+
898
+ function getAppImageDockerfile(): string {
899
+ return `FROM nginx:1.27-alpine
900
+
901
+ RUN cat <<'EOF' > /etc/nginx/conf.d/default.conf
902
+ server {
903
+ listen 8080;
904
+ server_name _;
905
+ root /usr/share/nginx/html;
906
+ index index.html;
907
+
908
+ location / {
909
+ try_files $uri $uri/ /index.html;
910
+ }
911
+ }
912
+ EOF
913
+
914
+ COPY . /usr/share/nginx/html
915
+
916
+ EXPOSE 8080
917
+
918
+ CMD ["nginx", "-g", "daemon off;"]
919
+ `;
920
+ }
921
+
922
+ function getPortForwardScript(defaultNamespace: string): string {
923
+ return `#!/usr/bin/env bash
924
+ set -euo pipefail
925
+
926
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
927
+ ROOT_DIR="$(cd "\${SCRIPT_DIR}/.." && pwd)"
928
+
929
+ if [[ -f "\${ROOT_DIR}/.env" ]]; then
930
+ set -a
931
+ # shellcheck disable=SC1090
932
+ source "\${ROOT_DIR}/.env"
933
+ set +a
934
+ fi
935
+
936
+ NAMESPACE="\${ANKH_NAMESPACE:-${defaultNamespace}}"
937
+ LOCAL_PORT="\${APP_PORT_FORWARD_LOCAL_PORT:-18080}"
938
+ REMOTE_PORT="\${APP_PORT_FORWARD_REMOTE_PORT:-80}"
939
+
940
+ if ! command -v kubectl >/dev/null 2>&1; then
941
+ echo "kubectl is required but not installed."
942
+ exit 1
943
+ fi
944
+
945
+ if ! kubectl get namespace "\${NAMESPACE}" >/dev/null 2>&1; then
946
+ echo "Namespace '\${NAMESPACE}' was not found."
947
+ echo "Run ./scripts/up.sh to create infrastructure first."
948
+ exit 1
949
+ fi
950
+
951
+ if ! kubectl -n "\${NAMESPACE}" get service app-runtime >/dev/null 2>&1; then
952
+ echo "Service 'app-runtime' was not found in namespace '\${NAMESPACE}'."
953
+ echo "Run ./scripts/up.sh to apply runtime resources."
954
+ exit 1
955
+ fi
956
+
957
+ echo "Forwarding app-runtime from namespace '\${NAMESPACE}' to http://127.0.0.1:\${LOCAL_PORT}"
958
+ echo "Press Ctrl+C to stop."
959
+ kubectl -n "\${NAMESPACE}" port-forward service/app-runtime "\${LOCAL_PORT}:\${REMOTE_PORT}"
960
+ `;
961
+ }
962
+
963
+ function getDownScript(): string {
964
+ return `#!/usr/bin/env bash
965
+ set -euo pipefail
966
+
967
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
968
+ ROOT_DIR="$(cd "\${SCRIPT_DIR}/.." && pwd)"
969
+ K8S_DIR="\${ROOT_DIR}/k8s"
970
+
971
+ if [[ -f "\${ROOT_DIR}/.env" ]]; then
972
+ set -a
973
+ # shellcheck disable=SC1090
974
+ source "\${ROOT_DIR}/.env"
975
+ set +a
976
+ fi
977
+
978
+ kubectl delete -k "\${K8S_DIR}" --ignore-not-found
979
+ echo "Minikube infrastructure removed."
980
+ `;
981
+ }
982
+
983
+ function getStatusScript(defaultNamespace: string): string {
984
+ return `#!/usr/bin/env bash
985
+ set -euo pipefail
986
+
987
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
988
+ ROOT_DIR="$(cd "\${SCRIPT_DIR}/.." && pwd)"
989
+
990
+ if [[ -f "\${ROOT_DIR}/.env" ]]; then
991
+ set -a
992
+ # shellcheck disable=SC1090
993
+ source "\${ROOT_DIR}/.env"
994
+ set +a
995
+ fi
996
+
997
+ NAMESPACE="\${ANKH_NAMESPACE:-${defaultNamespace}}"
998
+ AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
999
+ SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
1000
+
1001
+ if ! command -v kubectl >/dev/null 2>&1; then
1002
+ echo "kubectl is required but not installed."
1003
+ exit 1
1004
+ fi
1005
+
1006
+ if ! kubectl config current-context >/dev/null 2>&1; then
1007
+ echo "No active kubectl context is configured."
1008
+ echo "Run ./scripts/up.sh to start Minikube and apply manifests."
1009
+ exit 1
1010
+ fi
1011
+
1012
+ if ! kubectl cluster-info >/dev/null 2>&1; then
1013
+ CONTEXT="$(kubectl config current-context 2>/dev/null || echo "unknown")"
1014
+ echo "Cannot reach Kubernetes API server for context '\${CONTEXT}'."
1015
+ echo "Run ./scripts/up.sh or verify your kubeconfig context."
1016
+ exit 1
1017
+ fi
1018
+
1019
+ if ! kubectl get namespace "\${NAMESPACE}" >/dev/null 2>&1; then
1020
+ echo "Namespace '\${NAMESPACE}' was not found in the current cluster."
1021
+ echo "Run ./scripts/up.sh to create it and apply resources."
1022
+ exit 1
1023
+ fi
1024
+
1025
+ kubectl get all -n "\${NAMESPACE}"
1026
+
1027
+ echo
1028
+ echo "Runtime auth checks:"
1029
+ echo "- AUTH_RUNTIME_MODE=\${AUTH_RUNTIME_MODE}"
1030
+ echo "- SUPABASE_SECRET_SYNC_ENABLED=\${SUPABASE_SECRET_SYNC_ENABLED}"
1031
+
1032
+ if kubectl -n "\${NAMESPACE}" get configmap app-runtime-auth-env >/dev/null 2>&1; then
1033
+ echo "- configmap/app-runtime-auth-env: present"
1034
+ else
1035
+ echo "- configmap/app-runtime-auth-env: missing"
1036
+ fi
1037
+
1038
+ if [[ "\${SUPABASE_SECRET_SYNC_ENABLED}" == "true" ]]; then
1039
+ if kubectl -n "\${NAMESPACE}" get secret supabase-auth-secrets >/dev/null 2>&1; then
1040
+ echo "- secret/supabase-auth-secrets: present"
1041
+
1042
+ has_secret_key() {
1043
+ local key="$1"
1044
+ local value
1045
+ value="$(kubectl -n "\${NAMESPACE}" get secret supabase-auth-secrets -o "jsonpath={.data.\${key}}" 2>/dev/null || true)"
1046
+ [[ -n "\${value}" ]]
1047
+ }
1048
+
1049
+ required_secret_keys=(
1050
+ SUPABASE_URL
1051
+ SUPABASE_ANON_KEY
1052
+ SUPABASE_SERVICE_ROLE_KEY
1053
+ SUPABASE_JWT_SECRET
1054
+ EXPO_PUBLIC_SUPABASE_URL
1055
+ EXPO_PUBLIC_SUPABASE_ANON_KEY
1056
+ )
1057
+
1058
+ for key in "\${required_secret_keys[@]}"; do
1059
+ if has_secret_key "\${key}"; then
1060
+ echo " - \${key}: present"
1061
+ else
1062
+ echo " - \${key}: missing"
1063
+ fi
1064
+ done
1065
+ else
1066
+ echo "- secret/supabase-auth-secrets: missing"
1067
+ fi
1068
+ else
1069
+ echo "- secret/supabase-auth-secrets: skipped (SUPABASE_SECRET_SYNC_ENABLED=false)"
1070
+ fi
1071
+ `;
1072
+ }
1073
+
1074
+ function getSupabaseLocalEnvScript(supabaseLocalPorts: SupabaseLocalPorts): string {
1075
+ return `#!/usr/bin/env bash
1076
+ set -euo pipefail
1077
+
1078
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1079
+ ROOT_DIR="$(cd "\${SCRIPT_DIR}/.." && pwd)"
1080
+ ENV_FILE="\${ROOT_DIR}/.env"
1081
+ ENV_EXAMPLE_FILE="\${ROOT_DIR}/.env.example"
1082
+
1083
+ if [[ -f "\${ENV_FILE}" ]]; then
1084
+ set -a
1085
+ # shellcheck disable=SC1090
1086
+ source "\${ENV_FILE}"
1087
+ set +a
1088
+ fi
1089
+
1090
+ APP_SOURCE_DIR="\${APP_SOURCE_DIR:-$(cd "\${ROOT_DIR}/../.." && pwd)}"
1091
+ SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${APP_SOURCE_DIR}}"
1092
+ APP_SUPABASE_ENV_FILE="\${APP_SUPABASE_ENV_FILE:-\${APP_SOURCE_DIR}/.env.local}"
1093
+
1094
+ SUPABASE_LOCAL_PORT_BASE="\${SUPABASE_LOCAL_PORT_BASE:-${supabaseLocalPorts.base}}"
1095
+ SUPABASE_LOCAL_SHADOW_PORT="\${SUPABASE_LOCAL_SHADOW_PORT:-\${SUPABASE_LOCAL_PORT_BASE}}"
1096
+ SUPABASE_LOCAL_API_PORT="\${SUPABASE_LOCAL_API_PORT:-$((SUPABASE_LOCAL_PORT_BASE + 1))}"
1097
+ SUPABASE_LOCAL_DB_PORT="\${SUPABASE_LOCAL_DB_PORT:-$((SUPABASE_LOCAL_PORT_BASE + 2))}"
1098
+ SUPABASE_LOCAL_STUDIO_PORT="\${SUPABASE_LOCAL_STUDIO_PORT:-$((SUPABASE_LOCAL_PORT_BASE + 3))}"
1099
+ SUPABASE_LOCAL_INBUCKET_PORT="\${SUPABASE_LOCAL_INBUCKET_PORT:-$((SUPABASE_LOCAL_PORT_BASE + 4))}"
1100
+ SUPABASE_LOCAL_ANALYTICS_PORT="\${SUPABASE_LOCAL_ANALYTICS_PORT:-$((SUPABASE_LOCAL_PORT_BASE + 5))}"
1101
+ export SUPABASE_LOCAL_PORT_BASE
1102
+ export SUPABASE_LOCAL_SHADOW_PORT
1103
+ export SUPABASE_LOCAL_API_PORT
1104
+ export SUPABASE_LOCAL_DB_PORT
1105
+ export SUPABASE_LOCAL_STUDIO_PORT
1106
+ export SUPABASE_LOCAL_INBUCKET_PORT
1107
+ export SUPABASE_LOCAL_ANALYTICS_PORT
1108
+ if ! command -v supabase >/dev/null 2>&1; then
1109
+ echo "supabase CLI is required but not installed."
1110
+ echo "Install: https://supabase.com/docs/guides/local-development/cli/getting-started"
1111
+ exit 1
1112
+ fi
1113
+
1114
+ configure_supabase_local_ports() {
1115
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1116
+
1117
+ if ! command -v python3 >/dev/null 2>&1; then
1118
+ echo "python3 is required to patch supabase/config.toml with generated local port defaults."
1119
+ echo "Install python3 (recommended) or manually edit \${config_file} ports:"
1120
+ echo "- [api] port = \${SUPABASE_LOCAL_API_PORT}"
1121
+ echo "- [db] port = \${SUPABASE_LOCAL_DB_PORT}"
1122
+ echo "- [db] shadow_port = \${SUPABASE_LOCAL_SHADOW_PORT}"
1123
+ echo "- [studio] port = \${SUPABASE_LOCAL_STUDIO_PORT}"
1124
+ echo "- [inbucket] port = \${SUPABASE_LOCAL_INBUCKET_PORT}"
1125
+ echo "- [analytics] port = \${SUPABASE_LOCAL_ANALYTICS_PORT}"
1126
+ exit 1
1127
+ fi
1128
+
1129
+ python3 - "\${config_file}" <<'PY'
1130
+ import os
1131
+ import re
1132
+ import sys
1133
+ from dataclasses import dataclass
1134
+
1135
+
1136
+ @dataclass(frozen=True)
1137
+ class Patch:
1138
+ section: str
1139
+ key: str
1140
+ value: str
1141
+
1142
+
1143
+ def _env_required(name):
1144
+ value = os.environ.get(name)
1145
+ if value is None or value == "":
1146
+ raise RuntimeError(f"Missing required env var: {name}")
1147
+ return value
1148
+
1149
+
1150
+ def _is_section_header(line):
1151
+ return re.match(r"^\\s*\\[[^\\]]+\\]\\s*$", line) is not None
1152
+
1153
+
1154
+ def _section_name(line):
1155
+ match = re.match(r"^\\s*\\[([^\\]]+)\\]\\s*$", line)
1156
+ return match.group(1) if match else None
1157
+
1158
+
1159
+ def _patch_section(lines, section, key, value):
1160
+ current_section = None
1161
+ section_start = None
1162
+
1163
+ for i, line in enumerate(lines):
1164
+ if _is_section_header(line):
1165
+ current_section = _section_name(line)
1166
+ if current_section == section and section_start is None:
1167
+ section_start = i
1168
+
1169
+ if section_start is None:
1170
+ if lines and not lines[-1].endswith("\\n"):
1171
+ lines[-1] = lines[-1] + "\\n"
1172
+ if lines and lines[-1].strip() != "":
1173
+ lines.append("\\n")
1174
+ lines.append(f"[{section}]\\n")
1175
+ lines.append(f"{key} = {value}\\n")
1176
+ return lines
1177
+
1178
+ section_end = len(lines)
1179
+ for j in range(section_start + 1, len(lines)):
1180
+ if _is_section_header(lines[j]):
1181
+ section_end = j
1182
+ break
1183
+
1184
+ key_re = re.compile(rf"^(\\s*){re.escape(key)}\\s*=\\s*(.*?)(\\s*(#.*)?)?$")
1185
+ for k in range(section_start + 1, section_end):
1186
+ match = key_re.match(lines[k])
1187
+ if match:
1188
+ indent = match.group(1) or ""
1189
+ trailing = match.group(3) or ""
1190
+ suffix = trailing.rstrip("\\n")
1191
+ lines[k] = f"{indent}{key} = {value}{suffix}\\n"
1192
+ return lines
1193
+
1194
+ insert_at = section_end
1195
+ while insert_at > section_start + 1 and lines[insert_at - 1].strip() == "":
1196
+ insert_at -= 1
1197
+ lines.insert(insert_at, f"{key} = {value}\\n")
1198
+ return lines
1199
+
1200
+
1201
+ def main():
1202
+ if len(sys.argv) != 2:
1203
+ raise RuntimeError("Usage: patch_config.py <supabase/config.toml>")
1204
+
1205
+ config_path = sys.argv[1]
1206
+ with open(config_path, "r", encoding="utf-8") as f:
1207
+ lines = f.readlines()
1208
+
1209
+ patches = [
1210
+ Patch("api", "port", _env_required("SUPABASE_LOCAL_API_PORT")),
1211
+ Patch("db", "port", _env_required("SUPABASE_LOCAL_DB_PORT")),
1212
+ Patch("db", "shadow_port", _env_required("SUPABASE_LOCAL_SHADOW_PORT")),
1213
+ Patch("studio", "port", _env_required("SUPABASE_LOCAL_STUDIO_PORT")),
1214
+ Patch("inbucket", "port", _env_required("SUPABASE_LOCAL_INBUCKET_PORT")),
1215
+ Patch("analytics", "port", _env_required("SUPABASE_LOCAL_ANALYTICS_PORT")),
1216
+ ]
1217
+
1218
+ for p in patches:
1219
+ lines = _patch_section(lines, p.section, p.key, p.value)
1220
+
1221
+ with open(config_path, "w", encoding="utf-8") as f:
1222
+ f.writelines(lines)
1223
+
1224
+ return 0
1225
+
1226
+
1227
+ if __name__ == "__main__":
1228
+ raise SystemExit(main())
1229
+ PY
1230
+ }
1231
+
1232
+ assert_supabase_local_ports_available() {
1233
+ local project_label="\${ANKH_NAMESPACE:-$(basename "\${APP_SOURCE_DIR}")}"
1234
+
1235
+ ANKH_PROJECT_LABEL="\${project_label}" python3 - <<'PY'
1236
+ import os
1237
+ import socket
1238
+ import sys
1239
+
1240
+
1241
+ PORT_KEYS = [
1242
+ "SUPABASE_LOCAL_SHADOW_PORT",
1243
+ "SUPABASE_LOCAL_API_PORT",
1244
+ "SUPABASE_LOCAL_DB_PORT",
1245
+ "SUPABASE_LOCAL_STUDIO_PORT",
1246
+ "SUPABASE_LOCAL_INBUCKET_PORT",
1247
+ "SUPABASE_LOCAL_ANALYTICS_PORT",
1248
+ ]
1249
+
1250
+
1251
+ def read_port(key):
1252
+ value = os.environ.get(key, "")
1253
+ try:
1254
+ port = int(value)
1255
+ except ValueError:
1256
+ raise RuntimeError(f"{key} must be an integer, got {value!r}") from None
1257
+
1258
+ if port < 1 or port > 65535:
1259
+ raise RuntimeError(f"{key} must be between 1 and 65535, got {port}")
1260
+
1261
+ return port
1262
+
1263
+
1264
+ def can_bind(port):
1265
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
1266
+ try:
1267
+ sock.bind(("0.0.0.0", port))
1268
+ except OSError:
1269
+ return False
1270
+ return True
1271
+
1272
+
1273
+ def main():
1274
+ project_label = os.environ.get("ANKH_PROJECT_LABEL", "unknown")
1275
+ ports = [(key, read_port(key)) for key in PORT_KEYS]
1276
+
1277
+ seen = {}
1278
+ duplicates = []
1279
+ for key, port in ports:
1280
+ existing = seen.get(port)
1281
+ if existing is not None:
1282
+ duplicates.append((key, existing, port))
1283
+ else:
1284
+ seen[port] = key
1285
+
1286
+ blocked = [(key, port) for key, port in ports if not can_bind(port)]
1287
+
1288
+ if not duplicates and not blocked:
1289
+ return 0
1290
+
1291
+ print(f"Supabase local port preflight failed for project '{project_label}'.", file=sys.stderr)
1292
+
1293
+ for key, existing, port in duplicates:
1294
+ print(f"- {key} duplicates {existing} on port {port}.", file=sys.stderr)
1295
+
1296
+ for key, port in blocked:
1297
+ print(f"- {key}={port} is already in use on this host.", file=sys.stderr)
1298
+
1299
+ print("Override SUPABASE_LOCAL_PORT_BASE or the specific SUPABASE_LOCAL_*_PORT values in infra/minikube/.env and retry.", file=sys.stderr)
1300
+ return 1
1301
+
1302
+
1303
+ if __name__ == "__main__":
1304
+ raise SystemExit(main())
1305
+ PY
1306
+ }
1307
+
1308
+ if [[ ! -f "\${SUPABASE_PROJECT_DIR}/supabase/config.toml" ]]; then
1309
+ echo "No supabase/config.toml found in \${SUPABASE_PROJECT_DIR}. Initializing Supabase project..."
1310
+ (
1311
+ cd "\${SUPABASE_PROJECT_DIR}"
1312
+ supabase init --yes >/dev/null
1313
+ )
1314
+ fi
1315
+
1316
+ configure_supabase_local_ports
1317
+
1318
+ STATUS_ENV="$(
1319
+ cd "\${SUPABASE_PROJECT_DIR}"
1320
+ supabase status -o env 2>/dev/null || true
1321
+ )"
1322
+
1323
+ start_supabase_local_stack() {
1324
+ if supabase start >/dev/null; then
1325
+ return 0
1326
+ fi
1327
+
1328
+ echo "Supabase local start failed. Stopping stale local stack and retrying once..."
1329
+ supabase stop --no-backup >/dev/null 2>&1 || true
1330
+
1331
+ if supabase start >/dev/null; then
1332
+ return 0
1333
+ fi
1334
+
1335
+ echo "Supabase local start failed after retry."
1336
+ return 1
1337
+ }
1338
+
1339
+ if [[ -z "\${STATUS_ENV}" ]]; then
1340
+ assert_supabase_local_ports_available
1341
+ echo "Supabase local stack not detected. Starting with 'supabase start'..."
1342
+ (
1343
+ cd "\${SUPABASE_PROJECT_DIR}"
1344
+ start_supabase_local_stack
1345
+ )
1346
+
1347
+ STATUS_ENV="$(
1348
+ cd "\${SUPABASE_PROJECT_DIR}"
1349
+ supabase status -o env
1350
+ )"
1351
+ fi
1352
+
1353
+ read_env_value() {
1354
+ local key="$1"
1355
+ local line
1356
+ line="$(printf '%s\\n' "\${STATUS_ENV}" | awk -v key="\${key}" '
1357
+ $0 ~ "^(export[[:space:]]+)?" key "=" {print}
1358
+ ' | tail -n1)"
1359
+
1360
+ if [[ -z "\${line}" ]]; then
1361
+ echo ""
1362
+ return
1363
+ fi
1364
+
1365
+ line="\${line#export }"
1366
+ line="\${line#\${key}=}"
1367
+ line="\${line%\\"}"
1368
+ line="\${line#\\"}"
1369
+ printf '%s' "\${line}"
1370
+ }
1371
+
1372
+ API_URL="$(read_env_value API_URL)"
1373
+ ANON_KEY="$(read_env_value ANON_KEY)"
1374
+ SERVICE_ROLE_KEY="$(read_env_value SERVICE_ROLE_KEY)"
1375
+ JWT_SECRET="$(read_env_value JWT_SECRET)"
1376
+
1377
+ if [[ -z "\${API_URL}" || -z "\${ANON_KEY}" || -z "\${SERVICE_ROLE_KEY}" || -z "\${JWT_SECRET}" ]]; then
1378
+ echo "Unable to read required Supabase values from 'supabase status -o env'."
1379
+ echo "Expected API_URL, ANON_KEY, SERVICE_ROLE_KEY, JWT_SECRET."
1380
+ exit 1
1381
+ fi
1382
+
1383
+ upsert_env() {
1384
+ local file="$1"
1385
+ local key="$2"
1386
+ local value="$3"
1387
+ local tmp_file
1388
+ tmp_file="$(mktemp)"
1389
+
1390
+ awk -v key="\${key}" -v value="\${value}" '
1391
+ BEGIN { replaced = 0 }
1392
+ index($0, key "=") == 1 {
1393
+ print key "=" value
1394
+ replaced = 1
1395
+ next
1396
+ }
1397
+ { print }
1398
+ END {
1399
+ if (replaced == 0) {
1400
+ print key "=" value
1401
+ }
1402
+ }
1403
+ ' "\${file}" > "\${tmp_file}"
1404
+
1405
+ mv "\${tmp_file}" "\${file}"
1406
+ }
1407
+
1408
+ if [[ ! -f "\${ENV_FILE}" ]]; then
1409
+ cp "\${ENV_EXAMPLE_FILE}" "\${ENV_FILE}"
1410
+ fi
1411
+
1412
+ if [[ ! -f "\${APP_SUPABASE_ENV_FILE}" ]]; then
1413
+ touch "\${APP_SUPABASE_ENV_FILE}"
1414
+ fi
1415
+
1416
+ upsert_env "\${ENV_FILE}" "SUPABASE_SECRET_SYNC_ENABLED" "true"
1417
+ upsert_env "\${ENV_FILE}" "SUPABASE_URL" "\${API_URL}"
1418
+ upsert_env "\${ENV_FILE}" "SUPABASE_ANON_KEY" "\${ANON_KEY}"
1419
+ upsert_env "\${ENV_FILE}" "SUPABASE_SERVICE_ROLE_KEY" "\${SERVICE_ROLE_KEY}"
1420
+ upsert_env "\${ENV_FILE}" "SUPABASE_JWT_SECRET" "\${JWT_SECRET}"
1421
+ upsert_env "\${ENV_FILE}" "EXPO_PUBLIC_SUPABASE_URL" "\${API_URL}"
1422
+ upsert_env "\${ENV_FILE}" "EXPO_PUBLIC_SUPABASE_ANON_KEY" "\${ANON_KEY}"
1423
+ upsert_env "\${ENV_FILE}" "SUPABASE_PROJECT_DIR" "\${SUPABASE_PROJECT_DIR}"
1424
+ upsert_env "\${ENV_FILE}" "SUPABASE_LOCAL_PORT_BASE" "\${SUPABASE_LOCAL_PORT_BASE}"
1425
+ upsert_env "\${ENV_FILE}" "SUPABASE_LOCAL_SHADOW_PORT" "\${SUPABASE_LOCAL_SHADOW_PORT}"
1426
+ upsert_env "\${ENV_FILE}" "SUPABASE_LOCAL_API_PORT" "\${SUPABASE_LOCAL_API_PORT}"
1427
+ upsert_env "\${ENV_FILE}" "SUPABASE_LOCAL_DB_PORT" "\${SUPABASE_LOCAL_DB_PORT}"
1428
+ upsert_env "\${ENV_FILE}" "SUPABASE_LOCAL_STUDIO_PORT" "\${SUPABASE_LOCAL_STUDIO_PORT}"
1429
+ upsert_env "\${ENV_FILE}" "SUPABASE_LOCAL_INBUCKET_PORT" "\${SUPABASE_LOCAL_INBUCKET_PORT}"
1430
+ upsert_env "\${ENV_FILE}" "SUPABASE_LOCAL_ANALYTICS_PORT" "\${SUPABASE_LOCAL_ANALYTICS_PORT}"
1431
+
1432
+ upsert_env "\${APP_SUPABASE_ENV_FILE}" "EXPO_PUBLIC_SUPABASE_URL" "\${API_URL}"
1433
+ upsert_env "\${APP_SUPABASE_ENV_FILE}" "EXPO_PUBLIC_SUPABASE_ANON_KEY" "\${ANON_KEY}"
1434
+ upsert_env "\${APP_SUPABASE_ENV_FILE}" "SUPABASE_URL" "\${API_URL}"
1435
+ upsert_env "\${APP_SUPABASE_ENV_FILE}" "SUPABASE_ANON_KEY" "\${ANON_KEY}"
1436
+
1437
+ echo "Updated \${ENV_FILE} with local Supabase credentials from \${SUPABASE_PROJECT_DIR}."
1438
+ echo "Updated \${APP_SUPABASE_ENV_FILE} with Expo Supabase credentials for local app runs."
1439
+ echo "If Expo is already running, restart it to pick up updated EXPO_PUBLIC_* variables."
1440
+ echo "Next: run ./scripts/up.sh to sync Kubernetes secrets and apply infrastructure."
1441
+ `;
1442
+ }
1443
+
1444
+ function getDefaultAppImage(namespace: string): string {
1445
+ return `ankh/${namespace}:dev`;
1446
+ }
1447
+
1448
+ function resolveSupabaseLocalPorts(namespace: string): SupabaseLocalPorts {
1449
+ const base =
1450
+ SUPABASE_LOCAL_PORT_BASE +
1451
+ resolveSupabaseLocalPortBucket(namespace) * SUPABASE_LOCAL_PORT_BUCKET_SIZE;
1452
+
1453
+ return {
1454
+ base,
1455
+ shadow: base,
1456
+ api: base + 1,
1457
+ db: base + 2,
1458
+ studio: base + 3,
1459
+ inbucket: base + 4,
1460
+ analytics: base + 5,
1461
+ };
1462
+ }
1463
+
1464
+ function resolveSupabaseLocalPortBucket(namespace: string): number {
1465
+ const rawBucket = hashProjectId(namespace) - hashProjectId(SUPABASE_LOCAL_PORT_REFERENCE_PROJECT);
1466
+ return (rawBucket + SUPABASE_LOCAL_PORT_BUCKET_COUNT) % SUPABASE_LOCAL_PORT_BUCKET_COUNT;
1467
+ }
1468
+
1469
+ function hashProjectId(value: string): number {
1470
+ const source = value.trim().length > 0 ? value.trim() : 'app';
1471
+ let hash = 0;
1472
+
1473
+ for (const char of source) {
1474
+ hash = (hash * 31 + char.charCodeAt(0)) % SUPABASE_LOCAL_PORT_BUCKET_COUNT;
1475
+ }
1476
+
1477
+ return hash;
1478
+ }