@ankhorage/infra 0.4.0 → 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.
@@ -30,17 +30,18 @@ const SUPABASE_LOCAL_PORT_REFERENCE_PROJECT = 'my-app';
30
30
  export function generateMinikubeBaseArtifacts(args: {
31
31
  manifest: InfraManifestInput;
32
32
  namespace: string;
33
+ supabaseProjectId: string | null;
33
34
  extraResources: string[];
34
35
  extraEnvEntries: string[];
35
36
  }): GeneratedInfrastructureFile[] {
36
- const { manifest, namespace, extraResources, extraEnvEntries } = args;
37
+ const { manifest, namespace, supabaseProjectId, extraResources, extraEnvEntries } = args;
37
38
 
38
39
  const root = 'infra/minikube';
39
40
  const k8sRoot = `${root}/k8s`;
40
41
  const scriptsRoot = `${root}/scripts`;
41
42
  const appImageRoot = `${root}/app-image`;
42
43
  const defaultAppImage = getDefaultAppImage(namespace);
43
- const supabaseLocalPorts = resolveSupabaseLocalPorts(namespace);
44
+ const supabaseLocalPorts = resolveSupabaseLocalPorts(supabaseProjectId ?? namespace);
44
45
 
45
46
  const authScope = manifest.auth?.scope ?? 'none';
46
47
  const authProvider = manifest.auth?.provider ?? 'none';
@@ -68,6 +69,7 @@ export function generateMinikubeBaseArtifacts(args: {
68
69
  authzEngine,
69
70
  extraResources,
70
71
  supabaseLocalPorts,
72
+ supabaseProjectId,
71
73
  supabaseLocalEnabled,
72
74
  profileModel,
73
75
  }),
@@ -140,6 +142,7 @@ export function generateMinikubeBaseArtifacts(args: {
140
142
  path: `${scriptsRoot}/status.sh`,
141
143
  content: getStatusScript({
142
144
  defaultNamespace: namespace,
145
+ supabaseProjectId,
143
146
  supabaseLocalEnabled,
144
147
  profileModel,
145
148
  }),
@@ -148,6 +151,7 @@ export function generateMinikubeBaseArtifacts(args: {
148
151
  {
149
152
  path: `${scriptsRoot}/supabase-local-env.sh`,
150
153
  content: getSupabaseLocalEnvScript({
154
+ supabaseProjectId,
151
155
  supabaseLocalPorts,
152
156
  profileModel,
153
157
  }),
@@ -168,6 +172,7 @@ function getReadmeMarkdown(args: {
168
172
  authzEngine: string;
169
173
  extraResources: string[];
170
174
  supabaseLocalPorts: SupabaseLocalPorts;
175
+ supabaseProjectId: string | null;
171
176
  supabaseLocalEnabled: boolean;
172
177
  profileModel: ResolvedProfileModel;
173
178
  }): string {
@@ -179,6 +184,7 @@ function getReadmeMarkdown(args: {
179
184
  authzEngine,
180
185
  extraResources,
181
186
  supabaseLocalPorts,
187
+ supabaseProjectId,
182
188
  supabaseLocalEnabled,
183
189
  profileModel,
184
190
  } = args;
@@ -245,6 +251,7 @@ ${resourceLines}
245
251
  ## Runtime Conventions
246
252
 
247
253
  - Namespace: \`${namespace}\`
254
+ - Supabase local project identity: \`${supabaseProjectId ?? 'unused'}\`
248
255
  - Monitoring requested: \`${monitoringEnabled ? 'true' : 'false'}\`
249
256
  - Auth provider: \`${authProvider}\`
250
257
  - Authorization engine: \`${authzEngine}\`
@@ -332,6 +339,7 @@ Build flow:
332
339
 
333
340
  - \`./scripts/up.sh\` calls \`./scripts/build-app-image.sh\` when \`APP_BUILD_ENABLED=true\`.
334
341
  - Supabase local project root: \`infra/minikube\`${supabaseLocalEnabled ? '' : ' (unused unless Supabase-backed auth/database/storage is enabled)'}.
342
+ - Supabase local project identity: \`${supabaseProjectId ?? 'unused'}\`; existing local configs with another \`project_id\` must be destroyed and recreated manually.
335
343
  - Immutable migrations live in \`supabase/migrations/\` and are applied with \`supabase migration up --local\`.
336
344
  ${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\`.
337
345
  - \`./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.
@@ -1414,10 +1422,11 @@ $$;`;
1414
1422
 
1415
1423
  function getStatusScript(args: {
1416
1424
  defaultNamespace: string;
1425
+ supabaseProjectId: string | null;
1417
1426
  supabaseLocalEnabled: boolean;
1418
1427
  profileModel: ResolvedProfileModel;
1419
1428
  }): string {
1420
- const { defaultNamespace, supabaseLocalEnabled, profileModel } = args;
1429
+ const { defaultNamespace, supabaseProjectId, supabaseLocalEnabled, profileModel } = args;
1421
1430
  const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1422
1431
  const profileVerificationSql = profileModel.enabled
1423
1432
  ? getSupabaseProfileVerificationSql(profileModel)
@@ -1441,8 +1450,28 @@ AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
1441
1450
  SUPABASE_LOCAL_ENABLED="\${SUPABASE_LOCAL_ENABLED:-${supabaseLocalEnabled ? 'true' : 'false'}}"
1442
1451
  SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
1443
1452
  SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1453
+ EXPECTED_SUPABASE_PROJECT_ID="${supabaseProjectId ?? ''}"
1444
1454
  SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1445
1455
  SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1456
+ export EXPECTED_SUPABASE_PROJECT_ID
1457
+
1458
+ reject_supabase_project_id_override() {
1459
+ if [[ -n "\${SUPABASE_PROJECT_ID:-}" ]]; then
1460
+ echo "SUPABASE_PROJECT_ID must not be set for generated local Infra scripts."
1461
+ echo "Unset SUPABASE_PROJECT_ID and use supabase/config.toml project_id instead."
1462
+ return 1
1463
+ fi
1464
+
1465
+ unset SUPABASE_PROJECT_ID
1466
+ }
1467
+
1468
+ require_expected_supabase_project_identity() {
1469
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1470
+ echo "Cannot run local Supabase infrastructure: expected Supabase project identity is empty."
1471
+ echo "Regenerate Infra with appManifest.metadata.slug for Supabase-backed local services."
1472
+ return 1
1473
+ fi
1474
+ }
1446
1475
 
1447
1476
  if ! command -v kubectl >/dev/null 2>&1; then
1448
1477
  echo "kubectl is required but not installed."
@@ -1606,6 +1635,61 @@ run_supabase_sql() {
1606
1635
  return 1
1607
1636
  }
1608
1637
 
1638
+ validate_supabase_project_identity() {
1639
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1640
+
1641
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1642
+ return 0
1643
+ fi
1644
+
1645
+ if [[ ! -f "\${config_file}" ]]; then
1646
+ echo "- local Supabase project identity: config missing at \${config_file}"
1647
+ return 1
1648
+ fi
1649
+
1650
+ if ! command -v python3 >/dev/null 2>&1; then
1651
+ echo "- local Supabase project identity: python3 is required to read \${config_file}"
1652
+ return 1
1653
+ fi
1654
+
1655
+ python3 - "\${config_file}" <<'PY'
1656
+ import os
1657
+ import re
1658
+ import sys
1659
+
1660
+
1661
+ def read_top_level_project_id(config_path):
1662
+ key_re = re.compile(r'^\\s*project_id\\s*=\\s*(.*?)\\s*(?:#.*)?$')
1663
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
1664
+
1665
+ with open(config_path, 'r', encoding='utf-8') as f:
1666
+ for line in f:
1667
+ if section_re.match(line):
1668
+ return None
1669
+ match = key_re.match(line)
1670
+ if not match:
1671
+ continue
1672
+ raw = match.group(1).strip()
1673
+ quoted = re.match(r'^["\\']([^"\\']*)["\\']$', raw)
1674
+ return quoted.group(1) if quoted else raw
1675
+
1676
+ return None
1677
+
1678
+
1679
+ expected = os.environ['EXPECTED_SUPABASE_PROJECT_ID']
1680
+ actual = read_top_level_project_id(sys.argv[1])
1681
+
1682
+ if actual == expected:
1683
+ raise SystemExit(0)
1684
+
1685
+ found = 'missing' if actual is None else f'"{actual}"'
1686
+ print('Supabase project identity mismatch.', file=sys.stderr)
1687
+ print(f'Expected "{expected}", found {found}.', file=sys.stderr)
1688
+ print('Destroy the invalid local stack and run Infra Up again.', file=sys.stderr)
1689
+ raise SystemExit(1)
1690
+ PY
1691
+ }
1692
+
1609
1693
  check_immutable_migrations_applied() {
1610
1694
  local migrations_dir="\${SUPABASE_PROJECT_DIR}/supabase/migrations"
1611
1695
  local applied_versions
@@ -1640,8 +1724,14 @@ if [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "tru
1640
1724
  echo "Local Supabase database checks:"
1641
1725
  status_failed=0
1642
1726
 
1643
- if require_supabase_cli_capabilities; then
1644
- if supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1727
+ if ! require_expected_supabase_project_identity; then
1728
+ status_failed=1
1729
+ elif ! reject_supabase_project_id_override; then
1730
+ status_failed=1
1731
+ elif require_supabase_cli_capabilities; then
1732
+ if ! validate_supabase_project_identity; then
1733
+ status_failed=1
1734
+ elif supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1645
1735
  STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1646
1736
  SUPABASE_DB_URL="$(read_env_value "\${STATUS_ENV}" DB_URL)"
1647
1737
  if [[ -z "\${SUPABASE_DB_URL}" ]]; then
@@ -1713,10 +1803,11 @@ fi
1713
1803
  }
1714
1804
 
1715
1805
  function getSupabaseLocalEnvScript(args: {
1806
+ supabaseProjectId: string | null;
1716
1807
  supabaseLocalPorts: SupabaseLocalPorts;
1717
1808
  profileModel: ResolvedProfileModel;
1718
1809
  }): string {
1719
- const { supabaseLocalPorts, profileModel } = args;
1810
+ const { supabaseProjectId, supabaseLocalPorts, profileModel } = args;
1720
1811
  const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1721
1812
  const profileVerificationSql = profileModel.enabled
1722
1813
  ? getSupabaseProfileVerificationSql(profileModel)
@@ -1739,9 +1830,29 @@ fi
1739
1830
 
1740
1831
  APP_SOURCE_DIR="\${APP_SOURCE_DIR:-$(cd "\${ROOT_DIR}/../.." && pwd)}"
1741
1832
  SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1833
+ EXPECTED_SUPABASE_PROJECT_ID="${supabaseProjectId ?? ''}"
1742
1834
  APP_SUPABASE_ENV_FILE="\${APP_SUPABASE_ENV_FILE:-\${APP_SOURCE_DIR}/.env.local}"
1743
1835
  SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1744
1836
  SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1837
+ export EXPECTED_SUPABASE_PROJECT_ID
1838
+
1839
+ reject_supabase_project_id_override() {
1840
+ if [[ -n "\${SUPABASE_PROJECT_ID:-}" ]]; then
1841
+ echo "SUPABASE_PROJECT_ID must not be set for generated local Infra scripts."
1842
+ echo "Unset SUPABASE_PROJECT_ID and use supabase/config.toml project_id instead."
1843
+ exit 1
1844
+ fi
1845
+
1846
+ unset SUPABASE_PROJECT_ID
1847
+ }
1848
+
1849
+ require_expected_supabase_project_identity() {
1850
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1851
+ echo "Cannot run local Supabase infrastructure: expected Supabase project identity is empty."
1852
+ echo "Regenerate Infra with appManifest.metadata.slug for Supabase-backed local services."
1853
+ exit 1
1854
+ fi
1855
+ }
1745
1856
 
1746
1857
  SUPABASE_LOCAL_PORT_BASE="\${SUPABASE_LOCAL_PORT_BASE:-${supabaseLocalPorts.base}}"
1747
1858
  SUPABASE_LOCAL_SHADOW_PORT="\${SUPABASE_LOCAL_SHADOW_PORT:-\${SUPABASE_LOCAL_PORT_BASE}}"
@@ -1787,6 +1898,11 @@ require_supabase_cli_capabilities() {
1787
1898
  exit 1
1788
1899
  fi
1789
1900
 
1901
+ if ! command -v python3 >/dev/null 2>&1; then
1902
+ echo "python3 is required to reconcile supabase/config.toml before local Supabase startup."
1903
+ exit 1
1904
+ fi
1905
+
1790
1906
  local version
1791
1907
  version="$(supabase --version | awk '{print $NF}')"
1792
1908
  if ! version_ge "\${version}" "2.106.0"; then
@@ -1848,6 +1964,8 @@ run_checked_sql_file() {
1848
1964
  psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -q -f "\${sql_file}"
1849
1965
  }
1850
1966
 
1967
+ require_expected_supabase_project_identity
1968
+ reject_supabase_project_id_override
1851
1969
  require_supabase_cli_capabilities
1852
1970
 
1853
1971
  if [[ ! -d "\${SUPABASE_PROJECT_DIR}" ]]; then
@@ -1858,18 +1976,6 @@ fi
1858
1976
  configure_supabase_local_ports() {
1859
1977
  local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1860
1978
 
1861
- if ! command -v python3 >/dev/null 2>&1; then
1862
- echo "python3 is required to patch supabase/config.toml with generated local port defaults."
1863
- echo "Install python3 (recommended) or manually edit \${config_file} ports:"
1864
- echo "- [api] port = \${SUPABASE_LOCAL_API_PORT}"
1865
- echo "- [db] port = \${SUPABASE_LOCAL_DB_PORT}"
1866
- echo "- [db] shadow_port = \${SUPABASE_LOCAL_SHADOW_PORT}"
1867
- echo "- [studio] port = \${SUPABASE_LOCAL_STUDIO_PORT}"
1868
- echo "- [inbucket] port = \${SUPABASE_LOCAL_INBUCKET_PORT}"
1869
- echo "- [analytics] port = \${SUPABASE_LOCAL_ANALYTICS_PORT}"
1870
- exit 1
1871
- fi
1872
-
1873
1979
  python3 - "\${config_file}" <<'PY'
1874
1980
  import os
1875
1981
  import re
@@ -1973,8 +2079,114 @@ if __name__ == "__main__":
1973
2079
  PY
1974
2080
  }
1975
2081
 
2082
+ write_supabase_project_identity_for_new_config() {
2083
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
2084
+
2085
+ require_expected_supabase_project_identity
2086
+
2087
+ if ! command -v python3 >/dev/null 2>&1; then
2088
+ echo "python3 is required to write Supabase project_id in \${config_file}."
2089
+ exit 1
2090
+ fi
2091
+
2092
+ python3 - "\${config_file}" <<'PY'
2093
+ import os
2094
+ import re
2095
+ import sys
2096
+
2097
+
2098
+ def write_top_level_project_id(config_path, expected):
2099
+ key_re = re.compile(r'^(\\s*)project_id\\s*=\\s*(.*?)(\\s*(#.*)?)?$')
2100
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
2101
+
2102
+ with open(config_path, 'r', encoding='utf-8') as f:
2103
+ lines = f.readlines()
2104
+
2105
+ section_start = len(lines)
2106
+ for i, line in enumerate(lines):
2107
+ if section_re.match(line):
2108
+ section_start = i
2109
+ break
2110
+
2111
+ for i in range(section_start):
2112
+ match = key_re.match(lines[i])
2113
+ if not match:
2114
+ continue
2115
+ indent = match.group(1) or ''
2116
+ trailing = (match.group(3) or '').rstrip('\\n')
2117
+ lines[i] = f'{indent}project_id = "{expected}"{trailing}\\n'
2118
+ break
2119
+ else:
2120
+ insert_at = section_start
2121
+ lines.insert(insert_at, f'project_id = "{expected}"\\n')
2122
+
2123
+ with open(config_path, 'w', encoding='utf-8') as f:
2124
+ f.writelines(lines)
2125
+
2126
+
2127
+ write_top_level_project_id(sys.argv[1], os.environ['EXPECTED_SUPABASE_PROJECT_ID'])
2128
+ PY
2129
+ }
2130
+
2131
+ validate_supabase_project_identity() {
2132
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
2133
+
2134
+ require_expected_supabase_project_identity
2135
+
2136
+ if [[ ! -f "\${config_file}" ]]; then
2137
+ echo "Supabase project identity mismatch."
2138
+ printf 'Expected "%s", found missing.\n' "\${EXPECTED_SUPABASE_PROJECT_ID}"
2139
+ echo "The existing local Supabase project belongs to a different identity."
2140
+ echo "Destroy the invalid local stack and its project-owned resources, then run Infra Up again."
2141
+ exit 1
2142
+ fi
2143
+
2144
+ if ! command -v python3 >/dev/null 2>&1; then
2145
+ echo "python3 is required to read Supabase project_id in \${config_file}."
2146
+ exit 1
2147
+ fi
2148
+
2149
+ python3 - "\${config_file}" <<'PY'
2150
+ import os
2151
+ import re
2152
+ import sys
2153
+
2154
+
2155
+ def read_top_level_project_id(config_path):
2156
+ key_re = re.compile(r'^\\s*project_id\\s*=\\s*(.*?)\\s*(?:#.*)?$')
2157
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
2158
+
2159
+ with open(config_path, 'r', encoding='utf-8') as f:
2160
+ for line in f:
2161
+ if section_re.match(line):
2162
+ return None
2163
+ match = key_re.match(line)
2164
+ if not match:
2165
+ continue
2166
+ raw = match.group(1).strip()
2167
+ quoted = re.match(r'^["\\']([^"\\']*)["\\']$', raw)
2168
+ return quoted.group(1) if quoted else raw
2169
+
2170
+ return None
2171
+
2172
+
2173
+ expected = os.environ['EXPECTED_SUPABASE_PROJECT_ID']
2174
+ actual = read_top_level_project_id(sys.argv[1])
2175
+
2176
+ if actual == expected:
2177
+ raise SystemExit(0)
2178
+
2179
+ found = 'missing' if actual is None else f'"{actual}"'
2180
+ print('Supabase project identity mismatch.', file=sys.stderr)
2181
+ print(f'Expected "{expected}", found {found}.', file=sys.stderr)
2182
+ print('The existing local Supabase project belongs to a different identity.', file=sys.stderr)
2183
+ print('Destroy the invalid local stack and its project-owned resources, then run Infra Up again.', file=sys.stderr)
2184
+ raise SystemExit(1)
2185
+ PY
2186
+ }
2187
+
1976
2188
  assert_supabase_local_ports_available() {
1977
- local project_label="\${ANKH_NAMESPACE:-$(basename "\${APP_SOURCE_DIR}")}"
2189
+ local project_label="\${EXPECTED_SUPABASE_PROJECT_ID}"
1978
2190
 
1979
2191
  ANKH_PROJECT_LABEL="\${project_label}" python3 - <<'PY'
1980
2192
  import os
@@ -2052,8 +2264,10 @@ PY
2052
2264
  if [[ ! -f "\${SUPABASE_PROJECT_DIR}/supabase/config.toml" ]]; then
2053
2265
  echo "No supabase/config.toml found in \${SUPABASE_PROJECT_DIR}. Initializing Supabase project..."
2054
2266
  supabase --workdir "\${SUPABASE_PROJECT_DIR}" init --yes >/dev/null
2267
+ write_supabase_project_identity_for_new_config
2055
2268
  fi
2056
2269
 
2270
+ validate_supabase_project_identity
2057
2271
  configure_supabase_local_ports
2058
2272
 
2059
2273
  STATUS_ENV="$(
@@ -2125,6 +2339,7 @@ start_supabase_local_stack() {
2125
2339
  }
2126
2340
 
2127
2341
  if [[ -z "\${STATUS_ENV}" ]]; then
2342
+ validate_supabase_project_identity
2128
2343
  assert_supabase_local_ports_available
2129
2344
  echo "Supabase local stack not detected. Starting with 'supabase start'..."
2130
2345
  start_supabase_local_stack
@@ -2138,6 +2353,7 @@ if [[ -z "\${SUPABASE_DB_URL}" ]]; then
2138
2353
  fi
2139
2354
 
2140
2355
  echo "Applying pending immutable Supabase migrations..."
2356
+ validate_supabase_project_identity
2141
2357
  run_checked_command \
2142
2358
  "Immutable Supabase migration application" \
2143
2359
  "" \
@@ -12,6 +12,7 @@ import { generateSecretStoreArtifacts } from './secrets';
12
12
  import { generateStorageArtifacts } from './storage';
13
13
 
14
14
  const DEFAULT_NAMESPACE = 'ankh-app';
15
+ const CANONICAL_PROJECT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
15
16
 
16
17
  export function generateMinikubeInfra(
17
18
  manifest: InfraManifestInput,
@@ -30,6 +31,9 @@ export function generateMinikubeInfra(
30
31
  }
31
32
 
32
33
  const namespace = resolveNamespace(manifest, options);
34
+ const supabaseProjectId = isSupabaseLocalEnabled(manifest)
35
+ ? validateCanonicalProjectSlug(options.appManifest?.metadata.slug)
36
+ : null;
33
37
 
34
38
  const authArtifacts = generateAuthProviderArtifacts({ manifest, namespace });
35
39
  const authzArtifacts = generateAuthorizationArtifacts({
@@ -60,6 +64,7 @@ export function generateMinikubeInfra(
60
64
  const baseFiles = generateMinikubeBaseArtifacts({
61
65
  manifest,
62
66
  namespace,
67
+ supabaseProjectId,
63
68
  extraResources,
64
69
  extraEnvEntries,
65
70
  });
@@ -136,6 +141,38 @@ function collectWarnings(manifest: InfraManifestInput): string[] {
136
141
  return warnings;
137
142
  }
138
143
 
144
+ function isSupabaseLocalEnabled(manifest: InfraManifestInput): boolean {
145
+ const authProvider = manifest.auth?.provider;
146
+ const databaseProvider = manifest.database?.provider;
147
+ const storageProvider = manifest.storage?.provider;
148
+ const secretStoreProvider = manifest.secretStore?.provider;
149
+
150
+ return (
151
+ authProvider === 'supabase' ||
152
+ databaseProvider === 'supabase' ||
153
+ storageProvider === 'supabase' ||
154
+ (storageProvider === 'auto' &&
155
+ (authProvider === 'supabase' || databaseProvider === 'supabase')) ||
156
+ secretStoreProvider === 'supabase-vault'
157
+ );
158
+ }
159
+
160
+ function validateCanonicalProjectSlug(slug: string | undefined): string {
161
+ if (!slug) {
162
+ throw new Error(
163
+ 'Cannot generate local Supabase infrastructure: appManifest.metadata.slug is required.',
164
+ );
165
+ }
166
+
167
+ if (slug.trim() !== slug || !CANONICAL_PROJECT_SLUG_RE.test(slug)) {
168
+ throw new Error(
169
+ 'Cannot generate local Supabase infrastructure: appManifest.metadata.slug must be a canonical lowercase slug up to 40 characters using only a-z, 0-9, and hyphens, without leading or trailing hyphens.',
170
+ );
171
+ }
172
+
173
+ return slug;
174
+ }
175
+
139
176
  function resolveNamespace(
140
177
  manifest: InfraManifestInput,
141
178
  options: InfrastructureGenerationOptions,
@@ -1,3 +1,4 @@
1
+ import type { AppManifest } from '@ankhorage/contracts';
1
2
  import { SUPABASE_VAULT_MIGRATION_SQL } from '@ankhorage/supabase-vault';
2
3
  import { describe, expect, test } from 'bun:test';
3
4
 
@@ -21,7 +22,9 @@ function createManifest(overrides: Partial<InfraManifestInput> = {}): InfraManif
21
22
 
22
23
  describe('minikube secret-store generation', () => {
23
24
  test('generates the released Supabase Vault migration through the existing lifecycle', () => {
24
- const result = generateInfrastructure(createManifest());
25
+ const result = generateInfrastructure(createManifest(), {
26
+ appManifest: createAppManifest('vault-app'),
27
+ });
25
28
  const migration = result.files.find((file) => file.path === SUPABASE_VAULT_MIGRATION_PATH);
26
29
  const envExample = result.files.find((file) => file.path === 'infra/minikube/.env.example');
27
30
  const upScript = result.files.find((file) => file.path === 'infra/minikube/scripts/up.sh');
@@ -55,6 +58,7 @@ describe('minikube secret-store generation', () => {
55
58
  },
56
59
  },
57
60
  }),
61
+ { appManifest: createAppManifest('vault-app') },
58
62
  ),
59
63
  ).toThrow('does not define credentialsRef');
60
64
  });
@@ -81,6 +85,7 @@ describe('minikube secret-store generation', () => {
81
85
  },
82
86
  },
83
87
  }),
88
+ { appManifest: createAppManifest('vault-app') },
84
89
  );
85
90
 
86
91
  const serialized = JSON.stringify(result.files);
@@ -101,3 +106,34 @@ describe('minikube secret-store generation', () => {
101
106
  ).toThrow('Unsupported secret-store provider for minikube adapter');
102
107
  });
103
108
  });
109
+
110
+ function createAppManifest(
111
+ slug: string,
112
+ ): Pick<AppManifest, 'metadata' | 'navigator' | 'screens' | 'settings'> {
113
+ return {
114
+ metadata: {
115
+ name: slug,
116
+ slug,
117
+ version: '1.0.0',
118
+ themeId: 'default',
119
+ },
120
+ navigator: {
121
+ type: 'stack',
122
+ routes: [],
123
+ },
124
+ screens: {},
125
+ settings: {
126
+ localization: {
127
+ defaultLocale: 'en',
128
+ locales: ['en'],
129
+ },
130
+ authFlow: {
131
+ signInRoute: '/sign-in',
132
+ signUpRoute: '/sign-up',
133
+ signOutRoute: '/sign-out',
134
+ unauthorizedRoute: '/sign-in',
135
+ postSignInRoute: '/',
136
+ },
137
+ },
138
+ };
139
+ }