@ankhorage/infra 1.0.2 → 1.0.4

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.
@@ -0,0 +1,2 @@
1
+ export declare function getSupabaseMigrationCommandScript(): string;
2
+ //# sourceMappingURL=supabaseMigrations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supabaseMigrations.d.ts","sourceRoot":"","sources":["../../../../src/adapters/minikube/base/supabaseMigrations.ts"],"names":[],"mappings":"AAAA,wBAAgB,iCAAiC,IAAI,MAAM,CAI1D"}
@@ -0,0 +1,6 @@
1
+ export function getSupabaseMigrationCommandScript() {
2
+ return `echo "Applying pending Supabase migrations..."
3
+ SUPABASE_TELEMETRY_DISABLED=1 supabase --yes migration up --db-url "\${SUPABASE_DB_URL}"
4
+ echo "Supabase migrations applied."`;
5
+ }
6
+ //# sourceMappingURL=supabaseMigrations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supabaseMigrations.js","sourceRoot":"","sources":["../../../../src/adapters/minikube/base/supabaseMigrations.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,iCAAiC;IAC/C,OAAO;;oCAE2B,CAAC;AACrC,CAAC","sourcesContent":["export function getSupabaseMigrationCommandScript(): string {\n return `echo \"Applying pending Supabase migrations...\"\nSUPABASE_TELEMETRY_DISABLED=1 supabase --yes migration up --db-url \"\\${SUPABASE_DB_URL}\"\necho \"Supabase migrations applied.\"`;\n}\n"]}
@@ -93,7 +93,8 @@ ${SUPABASE_VAULT_MIGRATION_PATH}
93
93
  \`\`\`
94
94
 
95
95
  is applied by the Kubernetes-owned Supabase migration lifecycle during \`scripts/up.sh\`
96
- with \`supabase migration up --db-url "$SUPABASE_DB_URL"\`.
96
+ with command-scoped CLI telemetry disabled around
97
+ \`supabase --yes migration up --db-url "$SUPABASE_DB_URL"\`.
97
98
 
98
99
  ## Security boundary
99
100
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../src/adapters/minikube/secrets/supabase-vault/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,4BAA4B,EAC5B,oCAAoC,GACrC,MAAM,2BAA2B,CAAC;AAKnC,MAAM,CAAC,MAAM,6BAA6B,GACxC,8EAA8E,CAAC;AAEjF,MAAM,UAAU,yCAAyC,CAAC,IAGzD;IACC,6BAA6B,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAE7C,OAAO;QACL,KAAK,EAAE;YACL;gBACE,IAAI,EAAE,6BAA6B;gBACnC,OAAO,EAAE,mCAAmC,EAAE;aAC/C;YACD;gBACE,IAAI,EAAE,0CAA0C;gBAChD,OAAO,EAAE,eAAe,CAAC,IAAI,CAAC,SAAS,CAAC;aACzC;SACF;QACD,SAAS,EAAE,EAAE;QACb,iBAAiB,EAAE,EAAE;QACrB,UAAU,EAAE,CAAC,yBAAyB,oCAAoC,EAAE,CAAC;QAC7E,QAAQ,EAAE,EAAE;KACb,CAAC;AACJ,CAAC;AAED,SAAS,mCAAmC;IAC1C,OAAO,GAAG,4BAA4B,CAAC,IAAI,EAAE;SAC1C,OAAO,CACN,uEAAuE,EACvE,GAAG,EAAE,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAuCR,CACC;SACA,IAAI,EAAE,IAAI,CAAC;AAChB,CAAC;AAED,SAAS,6BAA6B,CAAC,QAA4B;IACjE,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IACnC,IAAI,CAAC,KAAK,EAAE,OAAO;QAAE,OAAO;IAE5B,MAAM,gBAAgB,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,OAAO,KAAK,KAAK,CAAC,CAAC;IAC1F,IAAI,gBAAgB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;IACxE,CAAC;IAED,KAAK,MAAM,QAAQ,IAAI,gBAAgB,EAAE,CAAC;QACxC,IAAI,CAAC,QAAQ,CAAC,cAAc,EAAE,IAAI,EAAE,EAAE,CAAC;YACrC,MAAM,IAAI,KAAK,CACb,mBAAmB,QAAQ,CAAC,EAAE,kDAAkD,CACjF,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,eAAe,CAAC,SAAiB;IACxC,OAAO;;;;;+BAKsB,oCAAoC;;;;;;EAMjE,6BAA6B;;;;;;;;;;;;;;;;;;;;;;oCAsBK,SAAS;CAC5C,CAAC;AACF,CAAC","sourcesContent":["import {\n SUPABASE_VAULT_MIGRATION_SQL,\n SUPABASE_VAULT_SECRET_STORE_PROVIDER,\n} from '@ankhorage/supabase-vault';\n\nimport type { InfraManifestInput } from '../../../../types';\nimport type { MinikubeAdapterArtifacts } from '../../contracts';\n\nexport const SUPABASE_VAULT_MIGRATION_PATH =\n 'infra/minikube/supabase/migrations/202607120001_ankhorage_supabase_vault.sql';\n\nexport function generateSupabaseVaultSecretStoreArtifacts(args: {\n readonly manifest: InfraManifestInput;\n readonly namespace: string;\n}): MinikubeAdapterArtifacts {\n validateOAuthSecretReferences(args.manifest);\n\n return {\n files: [\n {\n path: SUPABASE_VAULT_MIGRATION_PATH,\n content: getCurrentSupabaseVaultMigrationSql(),\n },\n {\n path: 'infra/minikube/secrets/supabase-vault.md',\n content: getRuntimeGuide(args.namespace),\n },\n ],\n resources: [],\n providerLifecycle: [],\n envEntries: [`SECRET_STORE_PROVIDER=${SUPABASE_VAULT_SECRET_STORE_PROVIDER}`],\n warnings: [],\n };\n}\n\nfunction getCurrentSupabaseVaultMigrationSql(): string {\n return `${SUPABASE_VAULT_MIGRATION_SQL.trim()\n .replace(\n 'create extension if not exists supabase_vault with schema extensions;',\n () => `create schema if not exists vault;\ndo $$\nbegin\n create extension if not exists supabase_vault with schema vault;\nexception\n when insufficient_privilege then\n create table if not exists vault.secrets (\n id uuid primary key default gen_random_uuid(),\n secret text not null,\n name text,\n description text,\n created_at timestamptz not null default now()\n );\n\n create or replace function vault.create_secret(\n secret text,\n name text default null,\n description text default null\n )\n returns uuid\n language plpgsql\n security definer\n set search_path = vault, public\n as $vault$\n declare\n secret_id uuid;\n begin\n insert into vault.secrets (secret, name, description)\n values (secret, name, description)\n returning id into secret_id;\n\n return secret_id;\n end;\n $vault$;\n\n create or replace view vault.decrypted_secrets as\n select id, secret as decrypted_secret\n from vault.secrets;\nend\n$$;`,\n )\n .trim()}\\n`;\n}\n\nfunction validateOAuthSecretReferences(manifest: InfraManifestInput): void {\n const oauth = manifest.auth?.oauth;\n if (!oauth?.enabled) return;\n\n const enabledProviders = oauth.providers.filter((provider) => provider.enabled !== false);\n if (enabledProviders.length === 0) {\n throw new Error('OAuth is enabled but no OAuth provider is enabled.');\n }\n\n for (const provider of enabledProviders) {\n if (!provider.credentialsRef?.trim()) {\n throw new Error(\n `OAuth provider \"${provider.id}\" is enabled but does not define credentialsRef.`,\n );\n }\n }\n}\n\nfunction getRuntimeGuide(namespace: string): string {\n return `# Supabase Vault secret store\n\nThis project selects the provider-neutral secret-store backend through:\n\n\\`\\`\\`text\ninfra.secretStore.provider = ${SUPABASE_VAULT_SECRET_STORE_PROVIDER}\n\\`\\`\\`\n\nThe immutable migration at:\n\n\\`\\`\\`text\n${SUPABASE_VAULT_MIGRATION_PATH}\n\\`\\`\\`\n\nis applied by the Kubernetes-owned Supabase migration lifecycle during \\`scripts/up.sh\\`\nwith \\`supabase migration up --db-url \"$SUPABASE_DB_URL\"\\`.\n\n## Security boundary\n\n- Secret values are stored only through Supabase Vault.\n- Public manifests contain logical references such as \\`auth/oauth/google\\`, never values.\n- Project and environment scope are supplied by trusted server code.\n- The browser may receive secret metadata, but must never receive resolved values or Vault IDs.\n- Bootstrap database access must come from trusted process environment or workload identity.\n- Do not expose service-role keys, provider secrets, or resolved payloads through\n \\`NEXT_PUBLIC_*\\`, \\`EXPO_PUBLIC_*\\`, generated YAML, tracked JSON, or app bundles.\n\n## Trusted adapter composition\n\nUse \\`createInfraSecretStoreAdapter\\` from \\`@ankhorage/infra\\` in server-only code and pass\na trusted Supabase Vault SQL client. The app manifest chooses the provider; privileged connection\nconfiguration does not belong in the manifest.\n\nGenerated Kubernetes namespace: \\`${namespace}\\`.\n`;\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../src/adapters/minikube/secrets/supabase-vault/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,4BAA4B,EAC5B,oCAAoC,GACrC,MAAM,2BAA2B,CAAC;AAKnC,MAAM,CAAC,MAAM,6BAA6B,GACxC,8EAA8E,CAAC;AAEjF,MAAM,UAAU,yCAAyC,CAAC,IAGzD;IACC,6BAA6B,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAE7C,OAAO;QACL,KAAK,EAAE;YACL;gBACE,IAAI,EAAE,6BAA6B;gBACnC,OAAO,EAAE,mCAAmC,EAAE;aAC/C;YACD;gBACE,IAAI,EAAE,0CAA0C;gBAChD,OAAO,EAAE,eAAe,CAAC,IAAI,CAAC,SAAS,CAAC;aACzC;SACF;QACD,SAAS,EAAE,EAAE;QACb,iBAAiB,EAAE,EAAE;QACrB,UAAU,EAAE,CAAC,yBAAyB,oCAAoC,EAAE,CAAC;QAC7E,QAAQ,EAAE,EAAE;KACb,CAAC;AACJ,CAAC;AAED,SAAS,mCAAmC;IAC1C,OAAO,GAAG,4BAA4B,CAAC,IAAI,EAAE;SAC1C,OAAO,CACN,uEAAuE,EACvE,GAAG,EAAE,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAuCR,CACC;SACA,IAAI,EAAE,IAAI,CAAC;AAChB,CAAC;AAED,SAAS,6BAA6B,CAAC,QAA4B;IACjE,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IACnC,IAAI,CAAC,KAAK,EAAE,OAAO;QAAE,OAAO;IAE5B,MAAM,gBAAgB,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,OAAO,KAAK,KAAK,CAAC,CAAC;IAC1F,IAAI,gBAAgB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;IACxE,CAAC;IAED,KAAK,MAAM,QAAQ,IAAI,gBAAgB,EAAE,CAAC;QACxC,IAAI,CAAC,QAAQ,CAAC,cAAc,EAAE,IAAI,EAAE,EAAE,CAAC;YACrC,MAAM,IAAI,KAAK,CACb,mBAAmB,QAAQ,CAAC,EAAE,kDAAkD,CACjF,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,eAAe,CAAC,SAAiB;IACxC,OAAO;;;;;+BAKsB,oCAAoC;;;;;;EAMjE,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;oCAuBK,SAAS;CAC5C,CAAC;AACF,CAAC","sourcesContent":["import {\n SUPABASE_VAULT_MIGRATION_SQL,\n SUPABASE_VAULT_SECRET_STORE_PROVIDER,\n} from '@ankhorage/supabase-vault';\n\nimport type { InfraManifestInput } from '../../../../types';\nimport type { MinikubeAdapterArtifacts } from '../../contracts';\n\nexport const SUPABASE_VAULT_MIGRATION_PATH =\n 'infra/minikube/supabase/migrations/202607120001_ankhorage_supabase_vault.sql';\n\nexport function generateSupabaseVaultSecretStoreArtifacts(args: {\n readonly manifest: InfraManifestInput;\n readonly namespace: string;\n}): MinikubeAdapterArtifacts {\n validateOAuthSecretReferences(args.manifest);\n\n return {\n files: [\n {\n path: SUPABASE_VAULT_MIGRATION_PATH,\n content: getCurrentSupabaseVaultMigrationSql(),\n },\n {\n path: 'infra/minikube/secrets/supabase-vault.md',\n content: getRuntimeGuide(args.namespace),\n },\n ],\n resources: [],\n providerLifecycle: [],\n envEntries: [`SECRET_STORE_PROVIDER=${SUPABASE_VAULT_SECRET_STORE_PROVIDER}`],\n warnings: [],\n };\n}\n\nfunction getCurrentSupabaseVaultMigrationSql(): string {\n return `${SUPABASE_VAULT_MIGRATION_SQL.trim()\n .replace(\n 'create extension if not exists supabase_vault with schema extensions;',\n () => `create schema if not exists vault;\ndo $$\nbegin\n create extension if not exists supabase_vault with schema vault;\nexception\n when insufficient_privilege then\n create table if not exists vault.secrets (\n id uuid primary key default gen_random_uuid(),\n secret text not null,\n name text,\n description text,\n created_at timestamptz not null default now()\n );\n\n create or replace function vault.create_secret(\n secret text,\n name text default null,\n description text default null\n )\n returns uuid\n language plpgsql\n security definer\n set search_path = vault, public\n as $vault$\n declare\n secret_id uuid;\n begin\n insert into vault.secrets (secret, name, description)\n values (secret, name, description)\n returning id into secret_id;\n\n return secret_id;\n end;\n $vault$;\n\n create or replace view vault.decrypted_secrets as\n select id, secret as decrypted_secret\n from vault.secrets;\nend\n$$;`,\n )\n .trim()}\\n`;\n}\n\nfunction validateOAuthSecretReferences(manifest: InfraManifestInput): void {\n const oauth = manifest.auth?.oauth;\n if (!oauth?.enabled) return;\n\n const enabledProviders = oauth.providers.filter((provider) => provider.enabled !== false);\n if (enabledProviders.length === 0) {\n throw new Error('OAuth is enabled but no OAuth provider is enabled.');\n }\n\n for (const provider of enabledProviders) {\n if (!provider.credentialsRef?.trim()) {\n throw new Error(\n `OAuth provider \"${provider.id}\" is enabled but does not define credentialsRef.`,\n );\n }\n }\n}\n\nfunction getRuntimeGuide(namespace: string): string {\n return `# Supabase Vault secret store\n\nThis project selects the provider-neutral secret-store backend through:\n\n\\`\\`\\`text\ninfra.secretStore.provider = ${SUPABASE_VAULT_SECRET_STORE_PROVIDER}\n\\`\\`\\`\n\nThe immutable migration at:\n\n\\`\\`\\`text\n${SUPABASE_VAULT_MIGRATION_PATH}\n\\`\\`\\`\n\nis applied by the Kubernetes-owned Supabase migration lifecycle during \\`scripts/up.sh\\`\nwith command-scoped CLI telemetry disabled around\n\\`supabase --yes migration up --db-url \"$SUPABASE_DB_URL\"\\`.\n\n## Security boundary\n\n- Secret values are stored only through Supabase Vault.\n- Public manifests contain logical references such as \\`auth/oauth/google\\`, never values.\n- Project and environment scope are supplied by trusted server code.\n- The browser may receive secret metadata, but must never receive resolved values or Vault IDs.\n- Bootstrap database access must come from trusted process environment or workload identity.\n- Do not expose service-role keys, provider secrets, or resolved payloads through\n \\`NEXT_PUBLIC_*\\`, \\`EXPO_PUBLIC_*\\`, generated YAML, tracked JSON, or app bundles.\n\n## Trusted adapter composition\n\nUse \\`createInfraSecretStoreAdapter\\` from \\`@ankhorage/infra\\` in server-only code and pass\na trusted Supabase Vault SQL client. The app manifest chooses the provider; privileged connection\nconfiguration does not belong in the manifest.\n\nGenerated Kubernetes namespace: \\`${namespace}\\`.\n`;\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/infra",
3
3
  "type": "module",
4
- "version": "1.0.2",
4
+ "version": "1.0.4",
5
5
  "description": "Project-aware infrastructure generation library and executable Ankh provider for Ankhorage.",
6
6
  "homepage": "https://github.com/ankhorage/infra#readme",
7
7
  "bugs": {
@@ -25,7 +25,7 @@ when provided by caller.
25
25
 
26
26
  ## Runtime Scripts
27
27
 
28
- - `scripts/up.sh`: starts `minikube -p <slug>`, applies manifests, waits for Supabase, runs migrations with `supabase migration up --db-url "$SUPABASE_DB_URL"`, builds/loads the app image, and starts slug-owned port-forwards.
28
+ - `scripts/up.sh`: starts `minikube -p <slug>`, applies manifests, waits for Supabase, runs migrations with CLI telemetry disabled for that command, builds/loads the app image, and starts slug-owned port-forwards.
29
29
  - `scripts/down.sh`: stops slug-owned port-forwards, then runs `minikube stop -p <slug>`. Persistent profile data remains.
30
30
  - `scripts/reset.sh`: requires `ANKH_RESET_CONFIRM=<slug>` and deletes/recreates namespaces `app` and `supabase`, including Supabase DB and Storage PVC data. It does not delete the Minikube profile.
31
31
  - `scripts/destroy.sh`: stops slug-owned port-forwards and runs `minikube delete -p <slug>`.
@@ -37,7 +37,14 @@ when provided by caller.
37
37
 
38
38
  Runtime ownership is Kubernetes. Migration authoring/history remains Supabase migration
39
39
  files. Migration execution targets the Kubernetes Postgres endpoint via
40
- `supabase migration up --db-url "$SUPABASE_DB_URL"`.
40
+ `SUPABASE_TELEMETRY_DISABLED=1 supabase --yes migration up --db-url "$SUPABASE_DB_URL"`.
41
+ The migration process does not require stdin. `--yes` remains defense in depth for future
42
+ CLI prompts; it is not the hang fix. Command-scoped telemetry disable avoids the unbounded
43
+ telemetry shutdown in Supabase CLI 2.106.0 when PostHog is blocked, without changing the
44
+ user's persistent telemetry preference. Migration failures remain fatal under
45
+ `set -Eeuo pipefail`, and the completion message is printed only after a zero exit status.
46
+ The lifecycle adds no migration deadline and supports the affected CLI version; using a
47
+ current Supabase CLI is still recommended.
41
48
 
42
49
  The generated manifests are based on the current official Supabase self-hosting Docker
43
50
  topology, service documentation, environment-variable contracts, and pinned official
@@ -48,7 +55,9 @@ official Supabase Kubernetes distribution.
48
55
 
49
56
  - Default app image: `ankh/<slug>:dev`.
50
57
  - By default `up.sh` triggers `build-app-image.sh` (`APP_BUILD_ENABLED=true`) before apply.
51
- - `build-app-image.sh` runs `bunx expo export --platform web`, then builds via `app-image/Dockerfile`.
58
+ - `build-app-image.sh` runs `bunx expo export --platform web --clear`, then builds via
59
+ `app-image/Dockerfile`. Clearing the Expo/Metro cache ensures rotated `EXPO_PUBLIC_*`
60
+ values replace cached transforms before the browser bundle and Docker image are created.
52
61
  - `up.sh` syncs runtime image using `APP_IMAGE_SYNC_STRATEGY`:
53
62
  - `docker-load` (default): loads existing local Docker image into the app profile.
54
63
  - `minikube-build`: builds exported web artifacts directly into the profile image store.
@@ -74,6 +83,10 @@ For local Expo/Metro development, `up.sh` also mirrors only the browser-safe
74
83
  app root `.env.local`, preserving unrelated entries. Restart Expo after Infra Up so the
75
84
  client bundle sees the refreshed public Supabase values.
76
85
 
86
+ Only browser-safe `EXPO_PUBLIC_SUPABASE_URL` and `EXPO_PUBLIC_SUPABASE_ANON_KEY` values are
87
+ eligible for client bundling. Service-role and other privileged Supabase credentials remain
88
+ outside the app public environment and must never be referenced through `EXPO_PUBLIC_*` names.
89
+
77
90
  ## Practical Selector Rule
78
91
 
79
92
  When building config UI or pickers, only show currently supported values from adapter-owned
@@ -0,0 +1,263 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { expect, test } from 'bun:test';
7
+
8
+ import { generateInfrastructure } from '../../../index';
9
+ import { createAppManifest } from '../../../testSupport';
10
+
11
+ const APP_SLUG = 'cache-rotation';
12
+ const PUBLIC_URL = 'http://127.0.0.1:65431';
13
+ const PUBLIC_KEY_A = 'fixture-public-anon-key-a';
14
+ const PUBLIC_KEY_B = 'fixture-public-anon-key-b';
15
+ const PRIVATE_SERVICE_ROLE_KEY = 'fixture-private-service-role-key';
16
+
17
+ test('re-exports rotated public environment values before building the app image', async () => {
18
+ const workspaceRoot = await mkdtemp(path.join(tmpdir(), 'ankh-infra-app-image-'));
19
+
20
+ try {
21
+ const appRoot = path.join(workspaceRoot, 'apps', APP_SLUG);
22
+ const infraRoot = path.join(appRoot, 'infra', 'minikube');
23
+ const scriptsRoot = path.join(infraRoot, 'scripts');
24
+ const fakeBin = path.join(workspaceRoot, 'bin');
25
+ const buildScriptPath = path.join(scriptsRoot, 'build-app-image.sh');
26
+ const dockerfilePath = path.join(infraRoot, 'app-image', 'Dockerfile');
27
+ const metroCachePath = path.join(workspaceRoot, 'fake-metro-cache');
28
+ const bunxArgsPath = path.join(workspaceRoot, 'bunx-args');
29
+ const dockerArgsPath = path.join(workspaceRoot, 'docker-args');
30
+ const exportDir = path.join(appRoot, '.ankh', 'web-export');
31
+ const bundlePath = path.join(exportDir, '_expo', 'static', 'js', 'web', 'index-fixture.js');
32
+ const generated = generateInfrastructure(
33
+ {
34
+ deployment: { target: 'minikube', monitoring: false },
35
+ auth: { scope: 'global', provider: 'supabase' },
36
+ plugins: [],
37
+ },
38
+ { appManifest: createAppManifest(APP_SLUG) },
39
+ );
40
+ const buildScript = getGeneratedFile(
41
+ generated.files,
42
+ 'infra/minikube/scripts/build-app-image.sh',
43
+ );
44
+
45
+ await mkdir(path.dirname(dockerfilePath), { recursive: true });
46
+ await mkdir(fakeBin, { recursive: true });
47
+ await mkdir(scriptsRoot, { recursive: true });
48
+ await writeFile(path.join(appRoot, 'package.json'), '{}\n', 'utf8');
49
+ await writeFile(dockerfilePath, 'FROM scratch\n', 'utf8');
50
+ await writeFile(buildScriptPath, buildScript, 'utf8');
51
+ await chmod(buildScriptPath, 0o755);
52
+ await writeFakeBunx(path.join(fakeBin, 'bunx'));
53
+ await writeFakeDocker(path.join(fakeBin, 'docker'));
54
+
55
+ await writePublicEnvironment(appRoot, infraRoot, PUBLIC_KEY_A);
56
+ await runBuildScript({
57
+ appRoot,
58
+ buildScriptPath,
59
+ bunxArgsPath,
60
+ dockerArgsPath,
61
+ fakeBin,
62
+ metroCachePath,
63
+ });
64
+
65
+ const firstBundle = await readFile(bundlePath, 'utf8');
66
+ expect(firstBundle).toContain(PUBLIC_KEY_A);
67
+ expect(firstBundle).not.toContain(PRIVATE_SERVICE_ROLE_KEY);
68
+
69
+ await writePublicEnvironment(appRoot, infraRoot, PUBLIC_KEY_B);
70
+ await runBuildScript({
71
+ appRoot,
72
+ buildScriptPath,
73
+ bunxArgsPath,
74
+ dockerArgsPath,
75
+ fakeBin,
76
+ metroCachePath,
77
+ });
78
+
79
+ const secondBundle = await readFile(bundlePath, 'utf8');
80
+ expect(secondBundle).toContain(PUBLIC_KEY_B);
81
+ expect(secondBundle).not.toContain(PUBLIC_KEY_A);
82
+ expect(secondBundle).not.toContain(PRIVATE_SERVICE_ROLE_KEY);
83
+ expect((await readFile(bunxArgsPath, 'utf8')).trim().split('\n')).toEqual([
84
+ 'expo',
85
+ 'export',
86
+ '--platform',
87
+ 'web',
88
+ '--clear',
89
+ '--output-dir',
90
+ '.ankh/web-export',
91
+ ]);
92
+
93
+ const dockerArgs = (await readFile(dockerArgsPath, 'utf8')).trim().split('\n');
94
+ expect(dockerArgs.at(-1)).toBe(exportDir);
95
+ expect(dockerArgs).toContain(dockerfilePath);
96
+ } finally {
97
+ await rm(workspaceRoot, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ async function writePublicEnvironment(
102
+ appRoot: string,
103
+ infraRoot: string,
104
+ publicKey: string,
105
+ ): Promise<void> {
106
+ await writeFile(
107
+ path.join(appRoot, '.env.local'),
108
+ `EXPO_PUBLIC_SUPABASE_URL=${PUBLIC_URL}\nEXPO_PUBLIC_SUPABASE_ANON_KEY=${publicKey}\n`,
109
+ 'utf8',
110
+ );
111
+ await writeFile(
112
+ path.join(infraRoot, '.env'),
113
+ `ANKH_APP_SLUG=${APP_SLUG}
114
+ APP_BUILD_ENABLED=true
115
+ APP_WEB_EXPORT_DIR=.ankh/web-export
116
+ APP_IMAGE=ankh/${APP_SLUG}:test
117
+ EXPO_PUBLIC_SUPABASE_URL=${PUBLIC_URL}
118
+ EXPO_PUBLIC_SUPABASE_ANON_KEY=${publicKey}
119
+ SUPABASE_SERVICE_ROLE_KEY=${PRIVATE_SERVICE_ROLE_KEY}
120
+ `,
121
+ 'utf8',
122
+ );
123
+ }
124
+
125
+ async function writeFakeBunx(filePath: string): Promise<void> {
126
+ await writeFile(
127
+ filePath,
128
+ `#!/usr/bin/env bash
129
+ set -euo pipefail
130
+
131
+ printf '%s\\n' "$@" > "\${FAKE_BUNX_ARGS_PATH:?}"
132
+
133
+ if [[ "\${1:-}" != "expo" || "\${2:-}" != "export" ]]; then
134
+ echo "Expected expo export invocation." >&2
135
+ exit 64
136
+ fi
137
+ shift 2
138
+
139
+ clear_cache=false
140
+ output_dir=""
141
+ platform=""
142
+ while [[ "$#" -gt 0 ]]; do
143
+ case "$1" in
144
+ --platform)
145
+ platform="\${2:-}"
146
+ shift 2
147
+ ;;
148
+ --clear)
149
+ clear_cache=true
150
+ shift
151
+ ;;
152
+ --output-dir)
153
+ output_dir="\${2:-}"
154
+ shift 2
155
+ ;;
156
+ *)
157
+ echo "Unexpected expo export argument: $1" >&2
158
+ exit 64
159
+ ;;
160
+ esac
161
+ done
162
+
163
+ if [[ "\${platform}" != "web" || -z "\${output_dir}" ]]; then
164
+ echo "Expected web platform and output directory." >&2
165
+ exit 64
166
+ fi
167
+
168
+ if [[ "\${clear_cache}" == "true" ]]; then
169
+ : > "\${FAKE_METRO_CACHE_PATH:?}"
170
+ fi
171
+ if [[ ! -s "\${FAKE_METRO_CACHE_PATH:?}" ]]; then
172
+ printf '%s' "\${EXPO_PUBLIC_SUPABASE_ANON_KEY:?}" > "\${FAKE_METRO_CACHE_PATH}"
173
+ fi
174
+
175
+ cached_public_key="$(<"\${FAKE_METRO_CACHE_PATH}")"
176
+ bundle_dir="\${output_dir}/_expo/static/js/web"
177
+ mkdir -p "\${bundle_dir}"
178
+ printf '<script src="/_expo/static/js/web/index-fixture.js"></script>\\n' > "\${output_dir}/index.html"
179
+ printf 'const supabaseUrl = "%s";\\nconst supabaseAnonKey = "%s";\\n' \
180
+ "\${EXPO_PUBLIC_SUPABASE_URL:?}" \
181
+ "\${cached_public_key}" \
182
+ > "\${bundle_dir}/index-fixture.js"
183
+ `,
184
+ 'utf8',
185
+ );
186
+ await chmod(filePath, 0o755);
187
+ }
188
+
189
+ async function writeFakeDocker(filePath: string): Promise<void> {
190
+ await writeFile(
191
+ filePath,
192
+ `#!/usr/bin/env bash
193
+ set -euo pipefail
194
+
195
+ printf '%s\\n' "$@" > "\${FAKE_DOCKER_ARGS_PATH:?}"
196
+ context=""
197
+ for argument in "$@"; do
198
+ context="\${argument}"
199
+ done
200
+
201
+ if [[ ! -f "\${context}/index.html" ]]; then
202
+ echo "Docker context does not contain the fresh web export." >&2
203
+ exit 65
204
+ fi
205
+ `,
206
+ 'utf8',
207
+ );
208
+ await chmod(filePath, 0o755);
209
+ }
210
+
211
+ async function runBuildScript(args: {
212
+ appRoot: string;
213
+ buildScriptPath: string;
214
+ bunxArgsPath: string;
215
+ dockerArgsPath: string;
216
+ fakeBin: string;
217
+ metroCachePath: string;
218
+ }): Promise<void> {
219
+ const child = spawn('bash', [args.buildScriptPath], {
220
+ cwd: args.appRoot,
221
+ env: {
222
+ ...process.env,
223
+ FAKE_BUNX_ARGS_PATH: args.bunxArgsPath,
224
+ FAKE_DOCKER_ARGS_PATH: args.dockerArgsPath,
225
+ FAKE_METRO_CACHE_PATH: args.metroCachePath,
226
+ PATH: `${args.fakeBin}:${process.env.PATH ?? ''}`,
227
+ },
228
+ stdio: ['ignore', 'pipe', 'pipe'],
229
+ });
230
+ const exitCodePromise = new Promise<number | null>((resolve, reject) => {
231
+ child.once('error', reject);
232
+ child.once('close', resolve);
233
+ });
234
+ const [stdout, stderr, exitCode] = await Promise.all([
235
+ readStream(child.stdout),
236
+ readStream(child.stderr),
237
+ exitCodePromise,
238
+ ]);
239
+
240
+ if (exitCode !== 0) {
241
+ throw new Error(
242
+ `Generated build script exited with ${String(exitCode)}. stdout=${stdout} stderr=${stderr}`,
243
+ );
244
+ }
245
+ }
246
+
247
+ async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
248
+ let output = '';
249
+ stream.setEncoding('utf8');
250
+ for await (const chunk of stream) {
251
+ output += String(chunk);
252
+ }
253
+ return output;
254
+ }
255
+
256
+ function getGeneratedFile(
257
+ files: readonly { path: string; content: string }[],
258
+ filePath: string,
259
+ ): string {
260
+ const file = files.find((candidate) => candidate.path === filePath);
261
+ if (!file) throw new Error(`Missing generated file: ${filePath}`);
262
+ return file.content;
263
+ }
@@ -69,10 +69,22 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
69
69
  const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
70
70
  const readme = getFile(result.files, 'infra/minikube/README.md');
71
71
 
72
- expect(upScript).toContain('supabase migration up --db-url "${SUPABASE_DB_URL}"');
72
+ const migrationFunction = upScript.slice(
73
+ upScript.indexOf('run_supabase_migrations()'),
74
+ upScript.indexOf('reconcile_supabase_profile()'),
75
+ );
76
+
77
+ expect(migrationFunction).toContain(
78
+ 'SUPABASE_TELEMETRY_DISABLED=1 supabase --yes migration up --db-url "${SUPABASE_DB_URL}"',
79
+ );
80
+ expect(migrationFunction).not.toContain('supabase migration up --db-url "${SUPABASE_DB_URL}"');
81
+ expect(migrationFunction).toContain('echo "Applying pending Supabase migrations..."');
82
+ expect(migrationFunction).toContain('echo "Supabase migrations applied."');
73
83
  expect(upScript).toContain('export SUPABASE_DB_URL PGSSLMODE=disable');
74
84
  expect(upScript).not.toContain('migration up --local');
75
85
  expect(upScript).toContain('cd "${ROOT_DIR}"');
86
+ expect(migrationFunction).toContain('if [[ "${SUPABASE_RUNTIME_ENABLED}" != "true" ]]; then');
87
+ expect(migrationFunction).toContain('if [[ -d "${ROOT_DIR}/supabase/migrations" ]]; then');
76
88
  expect(upScript).toContain('set_required_env_default POSTGRES_PASSWORD "$(random_hex 32)"');
77
89
  expect(upScript).not.toContain('set_env_default POSTGRES_PASSWORD "$(random_base64');
78
90
  expect(upScript).toContain(
@@ -82,6 +94,30 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
82
94
  expect(readme).toContain('Migration authoring/history remains Supabase');
83
95
  });
84
96
 
97
+ test('preserves Supabase-disabled migration behavior', () => {
98
+ const result = generateInfrastructure(
99
+ {
100
+ deployment: {
101
+ target: 'minikube',
102
+ monitoring: false,
103
+ },
104
+ plugins: [],
105
+ },
106
+ {
107
+ appManifest: createAppManifest('frontend-only'),
108
+ },
109
+ );
110
+ const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
111
+ const migrationFunction = upScript.slice(
112
+ upScript.indexOf('run_supabase_migrations()'),
113
+ upScript.indexOf('reconcile_supabase_profile()'),
114
+ );
115
+
116
+ expect(upScript).toContain('SUPABASE_RUNTIME_ENABLED="false"');
117
+ expect(migrationFunction).toContain('if [[ "${SUPABASE_RUNTIME_ENABLED}" != "true" ]]; then');
118
+ expect(migrationFunction).toContain('return 0');
119
+ });
120
+
85
121
  test('lists and generates Supabase Kubernetes bootstrap and gateway contract files', () => {
86
122
  const result = generateInfrastructure(createSupabaseManifest(), {
87
123
  appManifest: createAppManifest('scanner'),
@@ -338,6 +374,8 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
338
374
  appManifest: createAppManifest('books'),
339
375
  });
340
376
  const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
377
+ const buildScript = getFile(result.files, 'infra/minikube/scripts/build-app-image.sh');
378
+ const readme = getFile(result.files, 'infra/minikube/README.md');
341
379
 
342
380
  expect(upScript).toContain('sync_app_public_supabase_env()');
343
381
  expect(upScript).toContain('local app_env_file="${APP_SOURCE_DIR}/.env.local"');
@@ -352,6 +390,12 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
352
390
  upScript.indexOf('if [[ "${APP_BUILD_ENABLED}" == "true" ]]'),
353
391
  );
354
392
  expect(upScript).not.toContain('SUPABASE_SERVICE_ROLE_KEY" "${SUPABASE_SERVICE_ROLE_KEY}"');
393
+ expect(buildScript).toContain(
394
+ 'bunx expo export --platform web --clear --output-dir "${APP_WEB_EXPORT_DIR}"',
395
+ );
396
+ expect(readme).toContain(
397
+ '`build-app-image.sh` clears the Expo/Metro bundler cache before every web export.',
398
+ );
355
399
  });
356
400
 
357
401
  test('starts Supabase schema owners before app migrations and profile reconciliation', () => {
@@ -3,6 +3,7 @@ import type { InfraManifestInput } from '../../../types';
3
3
  import type { ResolvedProfileModel } from '../auth/supabase/profile';
4
4
  import { resolveSupabaseProfileModel } from '../auth/supabase/profile';
5
5
  import type { MinikubeProviderLifecycle } from '../contracts';
6
+ import { getSupabaseMigrationCommandScript } from './supabaseMigrations';
6
7
 
7
8
  export const APP_NAMESPACE = 'app';
8
9
  const SUPABASE_NAMESPACE = 'supabase';
@@ -379,7 +380,7 @@ host-level Supabase Compose runtime.
379
380
 
380
381
  ## Lifecycle Semantics
381
382
 
382
- - \`up.sh\`: starts \`minikube -p ${appSlug}\`, deploys provider namespaces, runs migrations with \`supabase migration up --db-url "$SUPABASE_DB_URL"\`, starts slug-owned port-forwards, and deploys the app runtime.
383
+ - \`up.sh\`: starts \`minikube -p ${appSlug}\`, deploys provider namespaces, runs migrations with CLI telemetry disabled for that command, starts slug-owned port-forwards, and deploys the app runtime.
383
384
  - \`down.sh\`: stops slug-owned port-forwards, then stops \`minikube -p ${appSlug}\`. Persistent data remains in the profile.
384
385
  - \`reset.sh\`: requires \`ANKH_RESET_CONFIRM=${appSlug}\`; ${resetDescription}. It does not delete the Minikube profile.
385
386
  - \`destroy.sh\`: deletes only \`minikube -p ${appSlug}\`.
@@ -392,7 +393,13 @@ ${resourceLines}
392
393
 
393
394
  Supabase runtime ownership is Kubernetes. Migration authoring/history remains Supabase
394
395
  migration files. Migration execution targets the Kubernetes Postgres endpoint through
395
- \`supabase migration up --db-url "$SUPABASE_DB_URL"\`.
396
+ \`SUPABASE_TELEMETRY_DISABLED=1 supabase --yes migration up --db-url "$SUPABASE_DB_URL"\`.
397
+ The migration process does not require stdin. \`--yes\` remains defense in depth for future
398
+ CLI prompts; it is not the hang fix. Command-scoped telemetry disable avoids the unbounded
399
+ telemetry shutdown in Supabase CLI 2.106.0 when PostHog is blocked, without changing the
400
+ user's persistent telemetry preference. Migration failures stop \`up.sh\`, and the completion
401
+ message is printed only after a zero exit status. No migration deadline is added; using a
402
+ current Supabase CLI remains recommended.
396
403
 
397
404
  Supabase manifests are generated from the current official Supabase self-hosting Docker
398
405
  topology, service documentation, environment-variable contracts, and pinned official images.
@@ -417,6 +424,13 @@ Pinned images:
417
424
  - Studio: \`http://127.0.0.1:${supabaseHostPorts.studio}\`
418
425
  - DB migration endpoint: \`127.0.0.1:${supabaseHostPorts.db}\`
419
426
 
427
+ ## App Image Export
428
+
429
+ \`build-app-image.sh\` clears the Expo/Metro bundler cache before every web export. Expo
430
+ inlines \`EXPO_PUBLIC_*\` values into the browser bundle, so cache invalidation ensures a
431
+ rotated browser-safe Supabase URL or anon key replaces the previous value before the Docker
432
+ image is built. Privileged Supabase credentials are not written to the app's public env file.
433
+
420
434
  ## Runtime Conventions
421
435
 
422
436
  - Monitoring requested: \`${monitoringEnabled ? 'true' : 'false'}\`
@@ -2266,6 +2280,7 @@ function getUpScript(args: {
2266
2280
  oauthProviders,
2267
2281
  secretStoreProvider,
2268
2282
  });
2283
+ const supabaseMigrationCommand = getSupabaseMigrationCommandScript();
2269
2284
 
2270
2285
  return `#!/usr/bin/env bash
2271
2286
  set -Eeuo pipefail
@@ -2621,7 +2636,7 @@ run_supabase_migrations() {
2621
2636
  require_command supabase
2622
2637
  (
2623
2638
  cd "\${ROOT_DIR}"
2624
- supabase migration up --db-url "\${SUPABASE_DB_URL}"
2639
+ ${indentShellBlock(supabaseMigrationCommand, 6)}
2625
2640
  )
2626
2641
  fi
2627
2642
  }
@@ -2922,7 +2937,7 @@ fi
2922
2937
 
2923
2938
  (
2924
2939
  cd "\${APP_SOURCE_DIR}"
2925
- bunx expo export --platform web --output-dir "\${APP_WEB_EXPORT_DIR}"
2940
+ bunx expo export --platform web --clear --output-dir "\${APP_WEB_EXPORT_DIR}"
2926
2941
  )
2927
2942
 
2928
2943
  if [[ "\${APP_WEB_EXPORT_DIR}" = /* ]]; then