@ankhorage/infra 0.3.7 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -1
  3. package/dist/adapters/minikube/base/index.d.ts +1 -0
  4. package/dist/adapters/minikube/base/index.d.ts.map +1 -1
  5. package/dist/adapters/minikube/base/index.js +239 -22
  6. package/dist/adapters/minikube/base/index.js.map +1 -1
  7. package/dist/adapters/minikube/index.d.ts.map +1 -1
  8. package/dist/adapters/minikube/index.js +34 -0
  9. package/dist/adapters/minikube/index.js.map +1 -1
  10. package/dist/adapters/minikube/secrets/index.d.ts +7 -0
  11. package/dist/adapters/minikube/secrets/index.d.ts.map +1 -0
  12. package/dist/adapters/minikube/secrets/index.js +13 -0
  13. package/dist/adapters/minikube/secrets/index.js.map +1 -0
  14. package/dist/adapters/minikube/secrets/supabase-vault/index.d.ts +8 -0
  15. package/dist/adapters/minikube/secrets/supabase-vault/index.d.ts.map +1 -0
  16. package/dist/adapters/minikube/secrets/supabase-vault/index.js +74 -0
  17. package/dist/adapters/minikube/secrets/supabase-vault/index.js.map +1 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/secretStore.d.ts +19 -0
  23. package/dist/secretStore.d.ts.map +1 -0
  24. package/dist/secretStore.js +22 -0
  25. package/dist/secretStore.js.map +1 -0
  26. package/package.json +7 -3
  27. package/src/adapters/minikube/base/index.test.ts +456 -0
  28. package/src/adapters/minikube/base/index.ts +244 -20
  29. package/src/adapters/minikube/index.ts +44 -0
  30. package/src/adapters/minikube/secrets/index.test.ts +139 -0
  31. package/src/adapters/minikube/secrets/index.ts +19 -0
  32. package/src/adapters/minikube/secrets/supabase-vault/index.ts +91 -0
  33. package/src/index.test.ts +151 -12
  34. package/src/index.ts +5 -0
  35. package/src/package.test.ts +8 -1
  36. package/src/secretStore.test.ts +76 -0
  37. package/src/secretStore.ts +44 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @ankhorage/infra
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - f48b53f: Preserve app-specific Supabase local project identity while keeping the canonical Minikube workdir.
8
+
9
+ ## 0.4.0
10
+
11
+ ### Minor Changes
12
+
13
+ - dd7c668: Compose the canonical secret-store adapter from `infra.secretStore.provider`, generate the released Supabase Vault migration through the existing local Supabase lifecycle, and validate OAuth credential references before infrastructure generation.
14
+
3
15
  ## 0.3.7
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # INFRA
5
5
 
6
- ![license: MIT](././paradox/badges/license.svg) ![npm: v0.3.6](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
6
+ ![license: MIT](././paradox/badges/license.svg) ![npm: v0.4.0](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
7
7
 
8
8
  Executable infra provider and standalone CLI for Ankhorage project workflows.
9
9
 
@@ -56,3 +56,4 @@ bunx @ankhorage/infra
56
56
  - [Module relationships](././paradox/diagrams/module-relationships.mmd)
57
57
  - [Export graph](././paradox/diagrams/export-graph.mmd)
58
58
  - [ankhorage-infra sequence](././paradox/diagrams/sequences/ankhorage-infra.mmd)
59
+ - [createInfraSecretStoreAdapter sequence](././paradox/diagrams/sequences/create-infra-secret-store-adapter.mmd)
@@ -3,6 +3,7 @@ import type { InfraManifestInput } from '../../../types';
3
3
  export declare function generateMinikubeBaseArtifacts(args: {
4
4
  manifest: InfraManifestInput;
5
5
  namespace: string;
6
+ supabaseProjectId: string | null;
6
7
  extraResources: string[];
7
8
  extraEnvEntries: string[];
8
9
  }): GeneratedInfrastructureFile[];
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/adapters/minikube/base/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AA4BzD,wBAAgB,6BAA6B,CAAC,IAAI,EAAE;IAClD,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B,GAAG,2BAA2B,EAAE,CAyHhC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/adapters/minikube/base/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AA4BzD,wBAAgB,6BAA6B,CAAC,IAAI,EAAE;IAClD,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B,GAAG,2BAA2B,EAAE,CAiIhC"}
@@ -4,22 +4,26 @@ const SUPABASE_LOCAL_PORT_BUCKET_SIZE = 10;
4
4
  const SUPABASE_LOCAL_PORT_BUCKET_COUNT = 1000;
5
5
  const SUPABASE_LOCAL_PORT_REFERENCE_PROJECT = 'my-app';
6
6
  export function generateMinikubeBaseArtifacts(args) {
7
- const { manifest, namespace, extraResources, extraEnvEntries } = args;
7
+ const { manifest, namespace, supabaseProjectId, extraResources, extraEnvEntries } = args;
8
8
  const root = 'infra/minikube';
9
9
  const k8sRoot = `${root}/k8s`;
10
10
  const scriptsRoot = `${root}/scripts`;
11
11
  const appImageRoot = `${root}/app-image`;
12
12
  const defaultAppImage = getDefaultAppImage(namespace);
13
- const supabaseLocalPorts = resolveSupabaseLocalPorts(namespace);
13
+ const supabaseLocalPorts = resolveSupabaseLocalPorts(supabaseProjectId ?? namespace);
14
14
  const authScope = manifest.auth?.scope ?? 'none';
15
15
  const authProvider = manifest.auth?.provider ?? 'none';
16
16
  const authzEngine = manifest.auth?.authorization?.engine ?? 'none';
17
17
  const databaseProvider = manifest.database?.provider ?? 'none';
18
+ const secretStoreProvider = manifest.secretStore?.provider ?? 'none';
18
19
  const storageMetadata = resolveAppInfraStorageMetadata(manifest);
19
20
  const profileModel = resolveSupabaseProfileModel(manifest);
20
21
  const monitoringEnabled = manifest.deployment?.monitoring === true;
21
22
  const domain = manifest.networking?.domain ?? '';
22
- const supabaseLocalEnabled = authProvider === 'supabase' || databaseProvider === 'supabase' || storageMetadata !== null;
23
+ const supabaseLocalEnabled = authProvider === 'supabase' ||
24
+ databaseProvider === 'supabase' ||
25
+ storageMetadata !== null ||
26
+ secretStoreProvider === 'supabase-vault';
23
27
  return [
24
28
  {
25
29
  path: `${root}/README.md`,
@@ -31,6 +35,7 @@ export function generateMinikubeBaseArtifacts(args) {
31
35
  authzEngine,
32
36
  extraResources,
33
37
  supabaseLocalPorts,
38
+ supabaseProjectId,
34
39
  supabaseLocalEnabled,
35
40
  profileModel,
36
41
  }),
@@ -57,6 +62,7 @@ export function generateMinikubeBaseArtifacts(args) {
57
62
  authScope,
58
63
  authProvider,
59
64
  databaseProvider,
65
+ secretStoreProvider,
60
66
  storageMetadata,
61
67
  monitoringEnabled,
62
68
  domain,
@@ -102,6 +108,7 @@ export function generateMinikubeBaseArtifacts(args) {
102
108
  path: `${scriptsRoot}/status.sh`,
103
109
  content: getStatusScript({
104
110
  defaultNamespace: namespace,
111
+ supabaseProjectId,
105
112
  supabaseLocalEnabled,
106
113
  profileModel,
107
114
  }),
@@ -110,6 +117,7 @@ export function generateMinikubeBaseArtifacts(args) {
110
117
  {
111
118
  path: `${scriptsRoot}/supabase-local-env.sh`,
112
119
  content: getSupabaseLocalEnvScript({
120
+ supabaseProjectId,
113
121
  supabaseLocalPorts,
114
122
  profileModel,
115
123
  }),
@@ -122,7 +130,7 @@ export function generateMinikubeBaseArtifacts(args) {
122
130
  ];
123
131
  }
124
132
  function getReadmeMarkdown(args) {
125
- const { namespace, defaultAppImage, monitoringEnabled, authProvider, authzEngine, extraResources, supabaseLocalPorts, supabaseLocalEnabled, profileModel, } = args;
133
+ const { namespace, defaultAppImage, monitoringEnabled, authProvider, authzEngine, extraResources, supabaseLocalPorts, supabaseProjectId, supabaseLocalEnabled, profileModel, } = args;
126
134
  const resourceLines = [
127
135
  'namespace.yaml',
128
136
  'app.configmap.yaml',
@@ -185,6 +193,7 @@ ${resourceLines}
185
193
  ## Runtime Conventions
186
194
 
187
195
  - Namespace: \`${namespace}\`
196
+ - Supabase local project identity: \`${supabaseProjectId ?? 'unused'}\`
188
197
  - Monitoring requested: \`${monitoringEnabled ? 'true' : 'false'}\`
189
198
  - Auth provider: \`${authProvider}\`
190
199
  - Authorization engine: \`${authzEngine}\`
@@ -272,6 +281,7 @@ Build flow:
272
281
 
273
282
  - \`./scripts/up.sh\` calls \`./scripts/build-app-image.sh\` when \`APP_BUILD_ENABLED=true\`.
274
283
  - Supabase local project root: \`infra/minikube\`${supabaseLocalEnabled ? '' : ' (unused unless Supabase-backed auth/database/storage is enabled)'}.
284
+ - Supabase local project identity: \`${supabaseProjectId ?? 'unused'}\`; existing local configs with another \`project_id\` must be destroyed and recreated manually.
275
285
  - Immutable migrations live in \`supabase/migrations/\` and are applied with \`supabase migration up --local\`.
276
286
  ${profileModel.enabled ? '- Generated profile desired-state reconciliation lives in `supabase/generated/auth_profiles.sql` and records checksum state in `ankhorage_internal.generated_schema_state`.\n' : ''}- \`./scripts/supabase-local-env.sh\` can populate Supabase keys in \`.env\` from local \`supabase status -o env\`.
277
287
  - \`./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.
@@ -356,7 +366,7 @@ metadata:
356
366
  `;
357
367
  }
358
368
  function getAppConfigMap(args) {
359
- const { namespace, authScope, authProvider, databaseProvider, storageMetadata, monitoringEnabled, domain, } = args;
369
+ const { namespace, authScope, authProvider, databaseProvider, secretStoreProvider, storageMetadata, monitoringEnabled, domain, } = args;
360
370
  const storageLines = storageMetadata
361
371
  ? ` STORAGE_PROVIDER: "${storageMetadata.provider}"
362
372
  STORAGE_BUCKETS: "${escapeYamlDoubleQuoted(storageMetadata.bucketsCsv)}"
@@ -374,6 +384,7 @@ data:
374
384
  AUTH_SCOPE: "${authScope}"
375
385
  AUTH_PROVIDER: "${authProvider}"
376
386
  DATABASE_PROVIDER: "${databaseProvider}"
387
+ SECRET_STORE_PROVIDER: "${secretStoreProvider}"
377
388
  ${storageLines} NETWORK_DOMAIN: "${domain}"
378
389
  `;
379
390
  }
@@ -1287,7 +1298,7 @@ end;
1287
1298
  $$;`;
1288
1299
  }
1289
1300
  function getStatusScript(args) {
1290
- const { defaultNamespace, supabaseLocalEnabled, profileModel } = args;
1301
+ const { defaultNamespace, supabaseProjectId, supabaseLocalEnabled, profileModel } = args;
1291
1302
  const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1292
1303
  const profileVerificationSql = profileModel.enabled
1293
1304
  ? getSupabaseProfileVerificationSql(profileModel)
@@ -1310,8 +1321,28 @@ AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
1310
1321
  SUPABASE_LOCAL_ENABLED="\${SUPABASE_LOCAL_ENABLED:-${supabaseLocalEnabled ? 'true' : 'false'}}"
1311
1322
  SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
1312
1323
  SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1324
+ EXPECTED_SUPABASE_PROJECT_ID="${supabaseProjectId ?? ''}"
1313
1325
  SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1314
1326
  SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1327
+ export EXPECTED_SUPABASE_PROJECT_ID
1328
+
1329
+ reject_supabase_project_id_override() {
1330
+ if [[ -n "\${SUPABASE_PROJECT_ID:-}" ]]; then
1331
+ echo "SUPABASE_PROJECT_ID must not be set for generated local Infra scripts."
1332
+ echo "Unset SUPABASE_PROJECT_ID and use supabase/config.toml project_id instead."
1333
+ return 1
1334
+ fi
1335
+
1336
+ unset SUPABASE_PROJECT_ID
1337
+ }
1338
+
1339
+ require_expected_supabase_project_identity() {
1340
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1341
+ echo "Cannot run local Supabase infrastructure: expected Supabase project identity is empty."
1342
+ echo "Regenerate Infra with appManifest.metadata.slug for Supabase-backed local services."
1343
+ return 1
1344
+ fi
1345
+ }
1315
1346
 
1316
1347
  if ! command -v kubectl >/dev/null 2>&1; then
1317
1348
  echo "kubectl is required but not installed."
@@ -1475,6 +1506,61 @@ run_supabase_sql() {
1475
1506
  return 1
1476
1507
  }
1477
1508
 
1509
+ validate_supabase_project_identity() {
1510
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1511
+
1512
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1513
+ return 0
1514
+ fi
1515
+
1516
+ if [[ ! -f "\${config_file}" ]]; then
1517
+ echo "- local Supabase project identity: config missing at \${config_file}"
1518
+ return 1
1519
+ fi
1520
+
1521
+ if ! command -v python3 >/dev/null 2>&1; then
1522
+ echo "- local Supabase project identity: python3 is required to read \${config_file}"
1523
+ return 1
1524
+ fi
1525
+
1526
+ python3 - "\${config_file}" <<'PY'
1527
+ import os
1528
+ import re
1529
+ import sys
1530
+
1531
+
1532
+ def read_top_level_project_id(config_path):
1533
+ key_re = re.compile(r'^\\s*project_id\\s*=\\s*(.*?)\\s*(?:#.*)?$')
1534
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
1535
+
1536
+ with open(config_path, 'r', encoding='utf-8') as f:
1537
+ for line in f:
1538
+ if section_re.match(line):
1539
+ return None
1540
+ match = key_re.match(line)
1541
+ if not match:
1542
+ continue
1543
+ raw = match.group(1).strip()
1544
+ quoted = re.match(r'^["\\']([^"\\']*)["\\']$', raw)
1545
+ return quoted.group(1) if quoted else raw
1546
+
1547
+ return None
1548
+
1549
+
1550
+ expected = os.environ['EXPECTED_SUPABASE_PROJECT_ID']
1551
+ actual = read_top_level_project_id(sys.argv[1])
1552
+
1553
+ if actual == expected:
1554
+ raise SystemExit(0)
1555
+
1556
+ found = 'missing' if actual is None else f'"{actual}"'
1557
+ print('Supabase project identity mismatch.', file=sys.stderr)
1558
+ print(f'Expected "{expected}", found {found}.', file=sys.stderr)
1559
+ print('Destroy the invalid local stack and run Infra Up again.', file=sys.stderr)
1560
+ raise SystemExit(1)
1561
+ PY
1562
+ }
1563
+
1478
1564
  check_immutable_migrations_applied() {
1479
1565
  local migrations_dir="\${SUPABASE_PROJECT_DIR}/supabase/migrations"
1480
1566
  local applied_versions
@@ -1509,8 +1595,14 @@ if [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "tru
1509
1595
  echo "Local Supabase database checks:"
1510
1596
  status_failed=0
1511
1597
 
1512
- if require_supabase_cli_capabilities; then
1513
- if supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1598
+ if ! require_expected_supabase_project_identity; then
1599
+ status_failed=1
1600
+ elif ! reject_supabase_project_id_override; then
1601
+ status_failed=1
1602
+ elif require_supabase_cli_capabilities; then
1603
+ if ! validate_supabase_project_identity; then
1604
+ status_failed=1
1605
+ elif supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1514
1606
  STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1515
1607
  SUPABASE_DB_URL="$(read_env_value "\${STATUS_ENV}" DB_URL)"
1516
1608
  if [[ -z "\${SUPABASE_DB_URL}" ]]; then
@@ -1581,7 +1673,7 @@ fi
1581
1673
  `;
1582
1674
  }
1583
1675
  function getSupabaseLocalEnvScript(args) {
1584
- const { supabaseLocalPorts, profileModel } = args;
1676
+ const { supabaseProjectId, supabaseLocalPorts, profileModel } = args;
1585
1677
  const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1586
1678
  const profileVerificationSql = profileModel.enabled
1587
1679
  ? getSupabaseProfileVerificationSql(profileModel)
@@ -1603,9 +1695,29 @@ fi
1603
1695
 
1604
1696
  APP_SOURCE_DIR="\${APP_SOURCE_DIR:-$(cd "\${ROOT_DIR}/../.." && pwd)}"
1605
1697
  SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1698
+ EXPECTED_SUPABASE_PROJECT_ID="${supabaseProjectId ?? ''}"
1606
1699
  APP_SUPABASE_ENV_FILE="\${APP_SUPABASE_ENV_FILE:-\${APP_SOURCE_DIR}/.env.local}"
1607
1700
  SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1608
1701
  SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1702
+ export EXPECTED_SUPABASE_PROJECT_ID
1703
+
1704
+ reject_supabase_project_id_override() {
1705
+ if [[ -n "\${SUPABASE_PROJECT_ID:-}" ]]; then
1706
+ echo "SUPABASE_PROJECT_ID must not be set for generated local Infra scripts."
1707
+ echo "Unset SUPABASE_PROJECT_ID and use supabase/config.toml project_id instead."
1708
+ exit 1
1709
+ fi
1710
+
1711
+ unset SUPABASE_PROJECT_ID
1712
+ }
1713
+
1714
+ require_expected_supabase_project_identity() {
1715
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1716
+ echo "Cannot run local Supabase infrastructure: expected Supabase project identity is empty."
1717
+ echo "Regenerate Infra with appManifest.metadata.slug for Supabase-backed local services."
1718
+ exit 1
1719
+ fi
1720
+ }
1609
1721
 
1610
1722
  SUPABASE_LOCAL_PORT_BASE="\${SUPABASE_LOCAL_PORT_BASE:-${supabaseLocalPorts.base}}"
1611
1723
  SUPABASE_LOCAL_SHADOW_PORT="\${SUPABASE_LOCAL_SHADOW_PORT:-\${SUPABASE_LOCAL_PORT_BASE}}"
@@ -1651,6 +1763,11 @@ require_supabase_cli_capabilities() {
1651
1763
  exit 1
1652
1764
  fi
1653
1765
 
1766
+ if ! command -v python3 >/dev/null 2>&1; then
1767
+ echo "python3 is required to reconcile supabase/config.toml before local Supabase startup."
1768
+ exit 1
1769
+ fi
1770
+
1654
1771
  local version
1655
1772
  version="$(supabase --version | awk '{print $NF}')"
1656
1773
  if ! version_ge "\${version}" "2.106.0"; then
@@ -1712,6 +1829,8 @@ run_checked_sql_file() {
1712
1829
  psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -q -f "\${sql_file}"
1713
1830
  }
1714
1831
 
1832
+ require_expected_supabase_project_identity
1833
+ reject_supabase_project_id_override
1715
1834
  require_supabase_cli_capabilities
1716
1835
 
1717
1836
  if [[ ! -d "\${SUPABASE_PROJECT_DIR}" ]]; then
@@ -1722,18 +1841,6 @@ fi
1722
1841
  configure_supabase_local_ports() {
1723
1842
  local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1724
1843
 
1725
- if ! command -v python3 >/dev/null 2>&1; then
1726
- echo "python3 is required to patch supabase/config.toml with generated local port defaults."
1727
- echo "Install python3 (recommended) or manually edit \${config_file} ports:"
1728
- echo "- [api] port = \${SUPABASE_LOCAL_API_PORT}"
1729
- echo "- [db] port = \${SUPABASE_LOCAL_DB_PORT}"
1730
- echo "- [db] shadow_port = \${SUPABASE_LOCAL_SHADOW_PORT}"
1731
- echo "- [studio] port = \${SUPABASE_LOCAL_STUDIO_PORT}"
1732
- echo "- [inbucket] port = \${SUPABASE_LOCAL_INBUCKET_PORT}"
1733
- echo "- [analytics] port = \${SUPABASE_LOCAL_ANALYTICS_PORT}"
1734
- exit 1
1735
- fi
1736
-
1737
1844
  python3 - "\${config_file}" <<'PY'
1738
1845
  import os
1739
1846
  import re
@@ -1837,8 +1944,114 @@ if __name__ == "__main__":
1837
1944
  PY
1838
1945
  }
1839
1946
 
1947
+ write_supabase_project_identity_for_new_config() {
1948
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1949
+
1950
+ require_expected_supabase_project_identity
1951
+
1952
+ if ! command -v python3 >/dev/null 2>&1; then
1953
+ echo "python3 is required to write Supabase project_id in \${config_file}."
1954
+ exit 1
1955
+ fi
1956
+
1957
+ python3 - "\${config_file}" <<'PY'
1958
+ import os
1959
+ import re
1960
+ import sys
1961
+
1962
+
1963
+ def write_top_level_project_id(config_path, expected):
1964
+ key_re = re.compile(r'^(\\s*)project_id\\s*=\\s*(.*?)(\\s*(#.*)?)?$')
1965
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
1966
+
1967
+ with open(config_path, 'r', encoding='utf-8') as f:
1968
+ lines = f.readlines()
1969
+
1970
+ section_start = len(lines)
1971
+ for i, line in enumerate(lines):
1972
+ if section_re.match(line):
1973
+ section_start = i
1974
+ break
1975
+
1976
+ for i in range(section_start):
1977
+ match = key_re.match(lines[i])
1978
+ if not match:
1979
+ continue
1980
+ indent = match.group(1) or ''
1981
+ trailing = (match.group(3) or '').rstrip('\\n')
1982
+ lines[i] = f'{indent}project_id = "{expected}"{trailing}\\n'
1983
+ break
1984
+ else:
1985
+ insert_at = section_start
1986
+ lines.insert(insert_at, f'project_id = "{expected}"\\n')
1987
+
1988
+ with open(config_path, 'w', encoding='utf-8') as f:
1989
+ f.writelines(lines)
1990
+
1991
+
1992
+ write_top_level_project_id(sys.argv[1], os.environ['EXPECTED_SUPABASE_PROJECT_ID'])
1993
+ PY
1994
+ }
1995
+
1996
+ validate_supabase_project_identity() {
1997
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1998
+
1999
+ require_expected_supabase_project_identity
2000
+
2001
+ if [[ ! -f "\${config_file}" ]]; then
2002
+ echo "Supabase project identity mismatch."
2003
+ printf 'Expected "%s", found missing.\n' "\${EXPECTED_SUPABASE_PROJECT_ID}"
2004
+ echo "The existing local Supabase project belongs to a different identity."
2005
+ echo "Destroy the invalid local stack and its project-owned resources, then run Infra Up again."
2006
+ exit 1
2007
+ fi
2008
+
2009
+ if ! command -v python3 >/dev/null 2>&1; then
2010
+ echo "python3 is required to read Supabase project_id in \${config_file}."
2011
+ exit 1
2012
+ fi
2013
+
2014
+ python3 - "\${config_file}" <<'PY'
2015
+ import os
2016
+ import re
2017
+ import sys
2018
+
2019
+
2020
+ def read_top_level_project_id(config_path):
2021
+ key_re = re.compile(r'^\\s*project_id\\s*=\\s*(.*?)\\s*(?:#.*)?$')
2022
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
2023
+
2024
+ with open(config_path, 'r', encoding='utf-8') as f:
2025
+ for line in f:
2026
+ if section_re.match(line):
2027
+ return None
2028
+ match = key_re.match(line)
2029
+ if not match:
2030
+ continue
2031
+ raw = match.group(1).strip()
2032
+ quoted = re.match(r'^["\\']([^"\\']*)["\\']$', raw)
2033
+ return quoted.group(1) if quoted else raw
2034
+
2035
+ return None
2036
+
2037
+
2038
+ expected = os.environ['EXPECTED_SUPABASE_PROJECT_ID']
2039
+ actual = read_top_level_project_id(sys.argv[1])
2040
+
2041
+ if actual == expected:
2042
+ raise SystemExit(0)
2043
+
2044
+ found = 'missing' if actual is None else f'"{actual}"'
2045
+ print('Supabase project identity mismatch.', file=sys.stderr)
2046
+ print(f'Expected "{expected}", found {found}.', file=sys.stderr)
2047
+ print('The existing local Supabase project belongs to a different identity.', file=sys.stderr)
2048
+ print('Destroy the invalid local stack and its project-owned resources, then run Infra Up again.', file=sys.stderr)
2049
+ raise SystemExit(1)
2050
+ PY
2051
+ }
2052
+
1840
2053
  assert_supabase_local_ports_available() {
1841
- local project_label="\${ANKH_NAMESPACE:-$(basename "\${APP_SOURCE_DIR}")}"
2054
+ local project_label="\${EXPECTED_SUPABASE_PROJECT_ID}"
1842
2055
 
1843
2056
  ANKH_PROJECT_LABEL="\${project_label}" python3 - <<'PY'
1844
2057
  import os
@@ -1916,8 +2129,10 @@ PY
1916
2129
  if [[ ! -f "\${SUPABASE_PROJECT_DIR}/supabase/config.toml" ]]; then
1917
2130
  echo "No supabase/config.toml found in \${SUPABASE_PROJECT_DIR}. Initializing Supabase project..."
1918
2131
  supabase --workdir "\${SUPABASE_PROJECT_DIR}" init --yes >/dev/null
2132
+ write_supabase_project_identity_for_new_config
1919
2133
  fi
1920
2134
 
2135
+ validate_supabase_project_identity
1921
2136
  configure_supabase_local_ports
1922
2137
 
1923
2138
  STATUS_ENV="$(
@@ -1989,6 +2204,7 @@ start_supabase_local_stack() {
1989
2204
  }
1990
2205
 
1991
2206
  if [[ -z "\${STATUS_ENV}" ]]; then
2207
+ validate_supabase_project_identity
1992
2208
  assert_supabase_local_ports_available
1993
2209
  echo "Supabase local stack not detected. Starting with 'supabase start'..."
1994
2210
  start_supabase_local_stack
@@ -2002,6 +2218,7 @@ if [[ -z "\${SUPABASE_DB_URL}" ]]; then
2002
2218
  fi
2003
2219
 
2004
2220
  echo "Applying pending immutable Supabase migrations..."
2221
+ validate_supabase_project_identity
2005
2222
  run_checked_command \
2006
2223
  "Immutable Supabase migration application" \
2007
2224
  "" \