@ankhorage/infra 0.4.0 → 0.4.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @ankhorage/infra
2
2
 
3
+ ## 0.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 53c8e75: Make generated disabled Supabase profile verification safe when local generated profile state has
8
+ never existed.
9
+
10
+ ## 0.4.1
11
+
12
+ ### Patch Changes
13
+
14
+ - f48b53f: Preserve app-specific Supabase local project identity while keeping the canonical Minikube workdir.
15
+
3
16
  ## 0.4.0
4
17
 
5
18
  ### Minor 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.7](././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.1](././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
 
@@ -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,CA8HhC"}
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,13 +4,13 @@ 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';
@@ -35,6 +35,7 @@ export function generateMinikubeBaseArtifacts(args) {
35
35
  authzEngine,
36
36
  extraResources,
37
37
  supabaseLocalPorts,
38
+ supabaseProjectId,
38
39
  supabaseLocalEnabled,
39
40
  profileModel,
40
41
  }),
@@ -107,6 +108,7 @@ export function generateMinikubeBaseArtifacts(args) {
107
108
  path: `${scriptsRoot}/status.sh`,
108
109
  content: getStatusScript({
109
110
  defaultNamespace: namespace,
111
+ supabaseProjectId,
110
112
  supabaseLocalEnabled,
111
113
  profileModel,
112
114
  }),
@@ -115,6 +117,7 @@ export function generateMinikubeBaseArtifacts(args) {
115
117
  {
116
118
  path: `${scriptsRoot}/supabase-local-env.sh`,
117
119
  content: getSupabaseLocalEnvScript({
120
+ supabaseProjectId,
118
121
  supabaseLocalPorts,
119
122
  profileModel,
120
123
  }),
@@ -127,7 +130,7 @@ export function generateMinikubeBaseArtifacts(args) {
127
130
  ];
128
131
  }
129
132
  function getReadmeMarkdown(args) {
130
- const { namespace, defaultAppImage, monitoringEnabled, authProvider, authzEngine, extraResources, supabaseLocalPorts, supabaseLocalEnabled, profileModel, } = args;
133
+ const { namespace, defaultAppImage, monitoringEnabled, authProvider, authzEngine, extraResources, supabaseLocalPorts, supabaseProjectId, supabaseLocalEnabled, profileModel, } = args;
131
134
  const resourceLines = [
132
135
  'namespace.yaml',
133
136
  'app.configmap.yaml',
@@ -190,6 +193,7 @@ ${resourceLines}
190
193
  ## Runtime Conventions
191
194
 
192
195
  - Namespace: \`${namespace}\`
196
+ - Supabase local project identity: \`${supabaseProjectId ?? 'unused'}\`
193
197
  - Monitoring requested: \`${monitoringEnabled ? 'true' : 'false'}\`
194
198
  - Auth provider: \`${authProvider}\`
195
199
  - Authorization engine: \`${authzEngine}\`
@@ -277,6 +281,7 @@ Build flow:
277
281
 
278
282
  - \`./scripts/up.sh\` calls \`./scripts/build-app-image.sh\` when \`APP_BUILD_ENABLED=true\`.
279
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.
280
285
  - Immutable migrations live in \`supabase/migrations/\` and are applied with \`supabase migration up --local\`.
281
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\`.
282
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.
@@ -943,13 +948,21 @@ $$;`;
943
948
  }
944
949
  function getSupabaseProfileDisabledGuardSql() {
945
950
  return `do $$
951
+ declare
952
+ has_profile_state boolean := false;
946
953
  begin
947
- if to_regclass('ankhorage_internal.generated_schema_state') is not null
948
- and exists (
949
- select 1
950
- from ankhorage_internal.generated_schema_state
951
- where artifact_key = 'auth.profile'
952
- ) then
954
+ if to_regclass('ankhorage_internal.generated_schema_state') is not null then
955
+ execute
956
+ 'select exists (
957
+ select 1
958
+ from ankhorage_internal.generated_schema_state
959
+ where artifact_key = $1
960
+ )'
961
+ into has_profile_state
962
+ using 'auth.profile';
963
+ end if;
964
+
965
+ if has_profile_state then
953
966
  raise exception 'manifest auth.profile.table was removed but local generated auth.profile state still exists; reset local Supabase or add an explicit cleanup migration';
954
967
  end if;
955
968
  end;
@@ -1293,7 +1306,7 @@ end;
1293
1306
  $$;`;
1294
1307
  }
1295
1308
  function getStatusScript(args) {
1296
- const { defaultNamespace, supabaseLocalEnabled, profileModel } = args;
1309
+ const { defaultNamespace, supabaseProjectId, supabaseLocalEnabled, profileModel } = args;
1297
1310
  const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1298
1311
  const profileVerificationSql = profileModel.enabled
1299
1312
  ? getSupabaseProfileVerificationSql(profileModel)
@@ -1316,8 +1329,28 @@ AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
1316
1329
  SUPABASE_LOCAL_ENABLED="\${SUPABASE_LOCAL_ENABLED:-${supabaseLocalEnabled ? 'true' : 'false'}}"
1317
1330
  SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
1318
1331
  SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1332
+ EXPECTED_SUPABASE_PROJECT_ID="${supabaseProjectId ?? ''}"
1319
1333
  SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1320
1334
  SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1335
+ export EXPECTED_SUPABASE_PROJECT_ID
1336
+
1337
+ reject_supabase_project_id_override() {
1338
+ if [[ -n "\${SUPABASE_PROJECT_ID:-}" ]]; then
1339
+ echo "SUPABASE_PROJECT_ID must not be set for generated local Infra scripts."
1340
+ echo "Unset SUPABASE_PROJECT_ID and use supabase/config.toml project_id instead."
1341
+ return 1
1342
+ fi
1343
+
1344
+ unset SUPABASE_PROJECT_ID
1345
+ }
1346
+
1347
+ require_expected_supabase_project_identity() {
1348
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1349
+ echo "Cannot run local Supabase infrastructure: expected Supabase project identity is empty."
1350
+ echo "Regenerate Infra with appManifest.metadata.slug for Supabase-backed local services."
1351
+ return 1
1352
+ fi
1353
+ }
1321
1354
 
1322
1355
  if ! command -v kubectl >/dev/null 2>&1; then
1323
1356
  echo "kubectl is required but not installed."
@@ -1481,6 +1514,61 @@ run_supabase_sql() {
1481
1514
  return 1
1482
1515
  }
1483
1516
 
1517
+ validate_supabase_project_identity() {
1518
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1519
+
1520
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1521
+ return 0
1522
+ fi
1523
+
1524
+ if [[ ! -f "\${config_file}" ]]; then
1525
+ echo "- local Supabase project identity: config missing at \${config_file}"
1526
+ return 1
1527
+ fi
1528
+
1529
+ if ! command -v python3 >/dev/null 2>&1; then
1530
+ echo "- local Supabase project identity: python3 is required to read \${config_file}"
1531
+ return 1
1532
+ fi
1533
+
1534
+ python3 - "\${config_file}" <<'PY'
1535
+ import os
1536
+ import re
1537
+ import sys
1538
+
1539
+
1540
+ def read_top_level_project_id(config_path):
1541
+ key_re = re.compile(r'^\\s*project_id\\s*=\\s*(.*?)\\s*(?:#.*)?$')
1542
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
1543
+
1544
+ with open(config_path, 'r', encoding='utf-8') as f:
1545
+ for line in f:
1546
+ if section_re.match(line):
1547
+ return None
1548
+ match = key_re.match(line)
1549
+ if not match:
1550
+ continue
1551
+ raw = match.group(1).strip()
1552
+ quoted = re.match(r'^["\\']([^"\\']*)["\\']$', raw)
1553
+ return quoted.group(1) if quoted else raw
1554
+
1555
+ return None
1556
+
1557
+
1558
+ expected = os.environ['EXPECTED_SUPABASE_PROJECT_ID']
1559
+ actual = read_top_level_project_id(sys.argv[1])
1560
+
1561
+ if actual == expected:
1562
+ raise SystemExit(0)
1563
+
1564
+ found = 'missing' if actual is None else f'"{actual}"'
1565
+ print('Supabase project identity mismatch.', file=sys.stderr)
1566
+ print(f'Expected "{expected}", found {found}.', file=sys.stderr)
1567
+ print('Destroy the invalid local stack and run Infra Up again.', file=sys.stderr)
1568
+ raise SystemExit(1)
1569
+ PY
1570
+ }
1571
+
1484
1572
  check_immutable_migrations_applied() {
1485
1573
  local migrations_dir="\${SUPABASE_PROJECT_DIR}/supabase/migrations"
1486
1574
  local applied_versions
@@ -1515,8 +1603,14 @@ if [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "tru
1515
1603
  echo "Local Supabase database checks:"
1516
1604
  status_failed=0
1517
1605
 
1518
- if require_supabase_cli_capabilities; then
1519
- if supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1606
+ if ! require_expected_supabase_project_identity; then
1607
+ status_failed=1
1608
+ elif ! reject_supabase_project_id_override; then
1609
+ status_failed=1
1610
+ elif require_supabase_cli_capabilities; then
1611
+ if ! validate_supabase_project_identity; then
1612
+ status_failed=1
1613
+ elif supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1520
1614
  STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1521
1615
  SUPABASE_DB_URL="$(read_env_value "\${STATUS_ENV}" DB_URL)"
1522
1616
  if [[ -z "\${SUPABASE_DB_URL}" ]]; then
@@ -1587,7 +1681,7 @@ fi
1587
1681
  `;
1588
1682
  }
1589
1683
  function getSupabaseLocalEnvScript(args) {
1590
- const { supabaseLocalPorts, profileModel } = args;
1684
+ const { supabaseProjectId, supabaseLocalPorts, profileModel } = args;
1591
1685
  const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1592
1686
  const profileVerificationSql = profileModel.enabled
1593
1687
  ? getSupabaseProfileVerificationSql(profileModel)
@@ -1609,9 +1703,29 @@ fi
1609
1703
 
1610
1704
  APP_SOURCE_DIR="\${APP_SOURCE_DIR:-$(cd "\${ROOT_DIR}/../.." && pwd)}"
1611
1705
  SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1706
+ EXPECTED_SUPABASE_PROJECT_ID="${supabaseProjectId ?? ''}"
1612
1707
  APP_SUPABASE_ENV_FILE="\${APP_SUPABASE_ENV_FILE:-\${APP_SOURCE_DIR}/.env.local}"
1613
1708
  SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1614
1709
  SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1710
+ export EXPECTED_SUPABASE_PROJECT_ID
1711
+
1712
+ reject_supabase_project_id_override() {
1713
+ if [[ -n "\${SUPABASE_PROJECT_ID:-}" ]]; then
1714
+ echo "SUPABASE_PROJECT_ID must not be set for generated local Infra scripts."
1715
+ echo "Unset SUPABASE_PROJECT_ID and use supabase/config.toml project_id instead."
1716
+ exit 1
1717
+ fi
1718
+
1719
+ unset SUPABASE_PROJECT_ID
1720
+ }
1721
+
1722
+ require_expected_supabase_project_identity() {
1723
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1724
+ echo "Cannot run local Supabase infrastructure: expected Supabase project identity is empty."
1725
+ echo "Regenerate Infra with appManifest.metadata.slug for Supabase-backed local services."
1726
+ exit 1
1727
+ fi
1728
+ }
1615
1729
 
1616
1730
  SUPABASE_LOCAL_PORT_BASE="\${SUPABASE_LOCAL_PORT_BASE:-${supabaseLocalPorts.base}}"
1617
1731
  SUPABASE_LOCAL_SHADOW_PORT="\${SUPABASE_LOCAL_SHADOW_PORT:-\${SUPABASE_LOCAL_PORT_BASE}}"
@@ -1657,6 +1771,11 @@ require_supabase_cli_capabilities() {
1657
1771
  exit 1
1658
1772
  fi
1659
1773
 
1774
+ if ! command -v python3 >/dev/null 2>&1; then
1775
+ echo "python3 is required to reconcile supabase/config.toml before local Supabase startup."
1776
+ exit 1
1777
+ fi
1778
+
1660
1779
  local version
1661
1780
  version="$(supabase --version | awk '{print $NF}')"
1662
1781
  if ! version_ge "\${version}" "2.106.0"; then
@@ -1718,6 +1837,8 @@ run_checked_sql_file() {
1718
1837
  psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -q -f "\${sql_file}"
1719
1838
  }
1720
1839
 
1840
+ require_expected_supabase_project_identity
1841
+ reject_supabase_project_id_override
1721
1842
  require_supabase_cli_capabilities
1722
1843
 
1723
1844
  if [[ ! -d "\${SUPABASE_PROJECT_DIR}" ]]; then
@@ -1728,18 +1849,6 @@ fi
1728
1849
  configure_supabase_local_ports() {
1729
1850
  local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1730
1851
 
1731
- if ! command -v python3 >/dev/null 2>&1; then
1732
- echo "python3 is required to patch supabase/config.toml with generated local port defaults."
1733
- echo "Install python3 (recommended) or manually edit \${config_file} ports:"
1734
- echo "- [api] port = \${SUPABASE_LOCAL_API_PORT}"
1735
- echo "- [db] port = \${SUPABASE_LOCAL_DB_PORT}"
1736
- echo "- [db] shadow_port = \${SUPABASE_LOCAL_SHADOW_PORT}"
1737
- echo "- [studio] port = \${SUPABASE_LOCAL_STUDIO_PORT}"
1738
- echo "- [inbucket] port = \${SUPABASE_LOCAL_INBUCKET_PORT}"
1739
- echo "- [analytics] port = \${SUPABASE_LOCAL_ANALYTICS_PORT}"
1740
- exit 1
1741
- fi
1742
-
1743
1852
  python3 - "\${config_file}" <<'PY'
1744
1853
  import os
1745
1854
  import re
@@ -1843,8 +1952,114 @@ if __name__ == "__main__":
1843
1952
  PY
1844
1953
  }
1845
1954
 
1955
+ write_supabase_project_identity_for_new_config() {
1956
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1957
+
1958
+ require_expected_supabase_project_identity
1959
+
1960
+ if ! command -v python3 >/dev/null 2>&1; then
1961
+ echo "python3 is required to write Supabase project_id in \${config_file}."
1962
+ exit 1
1963
+ fi
1964
+
1965
+ python3 - "\${config_file}" <<'PY'
1966
+ import os
1967
+ import re
1968
+ import sys
1969
+
1970
+
1971
+ def write_top_level_project_id(config_path, expected):
1972
+ key_re = re.compile(r'^(\\s*)project_id\\s*=\\s*(.*?)(\\s*(#.*)?)?$')
1973
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
1974
+
1975
+ with open(config_path, 'r', encoding='utf-8') as f:
1976
+ lines = f.readlines()
1977
+
1978
+ section_start = len(lines)
1979
+ for i, line in enumerate(lines):
1980
+ if section_re.match(line):
1981
+ section_start = i
1982
+ break
1983
+
1984
+ for i in range(section_start):
1985
+ match = key_re.match(lines[i])
1986
+ if not match:
1987
+ continue
1988
+ indent = match.group(1) or ''
1989
+ trailing = (match.group(3) or '').rstrip('\\n')
1990
+ lines[i] = f'{indent}project_id = "{expected}"{trailing}\\n'
1991
+ break
1992
+ else:
1993
+ insert_at = section_start
1994
+ lines.insert(insert_at, f'project_id = "{expected}"\\n')
1995
+
1996
+ with open(config_path, 'w', encoding='utf-8') as f:
1997
+ f.writelines(lines)
1998
+
1999
+
2000
+ write_top_level_project_id(sys.argv[1], os.environ['EXPECTED_SUPABASE_PROJECT_ID'])
2001
+ PY
2002
+ }
2003
+
2004
+ validate_supabase_project_identity() {
2005
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
2006
+
2007
+ require_expected_supabase_project_identity
2008
+
2009
+ if [[ ! -f "\${config_file}" ]]; then
2010
+ echo "Supabase project identity mismatch."
2011
+ printf 'Expected "%s", found missing.\n' "\${EXPECTED_SUPABASE_PROJECT_ID}"
2012
+ echo "The existing local Supabase project belongs to a different identity."
2013
+ echo "Destroy the invalid local stack and its project-owned resources, then run Infra Up again."
2014
+ exit 1
2015
+ fi
2016
+
2017
+ if ! command -v python3 >/dev/null 2>&1; then
2018
+ echo "python3 is required to read Supabase project_id in \${config_file}."
2019
+ exit 1
2020
+ fi
2021
+
2022
+ python3 - "\${config_file}" <<'PY'
2023
+ import os
2024
+ import re
2025
+ import sys
2026
+
2027
+
2028
+ def read_top_level_project_id(config_path):
2029
+ key_re = re.compile(r'^\\s*project_id\\s*=\\s*(.*?)\\s*(?:#.*)?$')
2030
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
2031
+
2032
+ with open(config_path, 'r', encoding='utf-8') as f:
2033
+ for line in f:
2034
+ if section_re.match(line):
2035
+ return None
2036
+ match = key_re.match(line)
2037
+ if not match:
2038
+ continue
2039
+ raw = match.group(1).strip()
2040
+ quoted = re.match(r'^["\\']([^"\\']*)["\\']$', raw)
2041
+ return quoted.group(1) if quoted else raw
2042
+
2043
+ return None
2044
+
2045
+
2046
+ expected = os.environ['EXPECTED_SUPABASE_PROJECT_ID']
2047
+ actual = read_top_level_project_id(sys.argv[1])
2048
+
2049
+ if actual == expected:
2050
+ raise SystemExit(0)
2051
+
2052
+ found = 'missing' if actual is None else f'"{actual}"'
2053
+ print('Supabase project identity mismatch.', file=sys.stderr)
2054
+ print(f'Expected "{expected}", found {found}.', file=sys.stderr)
2055
+ print('The existing local Supabase project belongs to a different identity.', file=sys.stderr)
2056
+ print('Destroy the invalid local stack and its project-owned resources, then run Infra Up again.', file=sys.stderr)
2057
+ raise SystemExit(1)
2058
+ PY
2059
+ }
2060
+
1846
2061
  assert_supabase_local_ports_available() {
1847
- local project_label="\${ANKH_NAMESPACE:-$(basename "\${APP_SOURCE_DIR}")}"
2062
+ local project_label="\${EXPECTED_SUPABASE_PROJECT_ID}"
1848
2063
 
1849
2064
  ANKH_PROJECT_LABEL="\${project_label}" python3 - <<'PY'
1850
2065
  import os
@@ -1922,8 +2137,10 @@ PY
1922
2137
  if [[ ! -f "\${SUPABASE_PROJECT_DIR}/supabase/config.toml" ]]; then
1923
2138
  echo "No supabase/config.toml found in \${SUPABASE_PROJECT_DIR}. Initializing Supabase project..."
1924
2139
  supabase --workdir "\${SUPABASE_PROJECT_DIR}" init --yes >/dev/null
2140
+ write_supabase_project_identity_for_new_config
1925
2141
  fi
1926
2142
 
2143
+ validate_supabase_project_identity
1927
2144
  configure_supabase_local_ports
1928
2145
 
1929
2146
  STATUS_ENV="$(
@@ -1995,6 +2212,7 @@ start_supabase_local_stack() {
1995
2212
  }
1996
2213
 
1997
2214
  if [[ -z "\${STATUS_ENV}" ]]; then
2215
+ validate_supabase_project_identity
1998
2216
  assert_supabase_local_ports_available
1999
2217
  echo "Supabase local stack not detected. Starting with 'supabase start'..."
2000
2218
  start_supabase_local_stack
@@ -2008,6 +2226,7 @@ if [[ -z "\${SUPABASE_DB_URL}" ]]; then
2008
2226
  fi
2009
2227
 
2010
2228
  echo "Applying pending immutable Supabase migrations..."
2229
+ validate_supabase_project_identity
2011
2230
  run_checked_command \
2012
2231
  "Immutable Supabase migration application" \
2013
2232
  "" \