@ankhorage/infra 4.1.1 → 4.1.3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/infra",
3
3
  "type": "module",
4
- "version": "4.1.1",
4
+ "version": "4.1.3",
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": {
@@ -73,9 +73,12 @@ official Supabase Kubernetes distribution.
73
73
 
74
74
  - Default app image: `ankh/<slug>:dev`.
75
75
  - By default `up.sh` triggers `build-app-image.sh` (`APP_BUILD_ENABLED=true`) before apply.
76
- - `build-app-image.sh` runs `bunx expo export --platform web --clear`, then builds via
77
- `app-image/Dockerfile`. Clearing the Expo/Metro cache ensures rotated `EXPO_PUBLIC_*`
78
- values replace cached transforms before the browser bundle and Docker image are created.
76
+ - `build-app-image.sh` requires the app's installed `node_modules/.bin/expo`, runs
77
+ `expo export --platform web --clear` through that pinned local CLI, then builds via
78
+ `app-image/Dockerfile`. Missing app dependencies fail with an explicit frozen-install
79
+ instruction; the script never resolves Expo through `bunx` or the network. Clearing the
80
+ Expo/Metro cache ensures rotated `EXPO_PUBLIC_*` values replace cached transforms before the
81
+ browser bundle and Docker image are created.
79
82
  - `up.sh` syncs runtime image using `APP_IMAGE_SYNC_STRATEGY`:
80
83
  - `docker-load` (default): loads existing local Docker image into the app profile.
81
84
  - `minikube-build`: builds exported web artifacts directly into the profile image store.
@@ -25,7 +25,7 @@ test('re-exports rotated public environment values before building the app image
25
25
  const buildScriptPath = path.join(scriptsRoot, 'build-app-image.sh');
26
26
  const dockerfilePath = path.join(infraRoot, 'app-image', 'Dockerfile');
27
27
  const metroCachePath = path.join(workspaceRoot, 'fake-metro-cache');
28
- const bunxArgsPath = path.join(workspaceRoot, 'bunx-args');
28
+ const expoArgsPath = path.join(workspaceRoot, 'expo-args');
29
29
  const dockerArgsPath = path.join(workspaceRoot, 'docker-args');
30
30
  const exportDir = path.join(appRoot, '.ankh', 'web-export');
31
31
  const bundlePath = path.join(exportDir, '_expo', 'static', 'js', 'web', 'index-fixture.js');
@@ -49,14 +49,14 @@ test('re-exports rotated public environment values before building the app image
49
49
  await writeFile(dockerfilePath, 'FROM scratch\n', 'utf8');
50
50
  await writeFile(buildScriptPath, buildScript, 'utf8');
51
51
  await chmod(buildScriptPath, 0o755);
52
- await writeFakeBunx(path.join(fakeBin, 'bunx'));
52
+ await writeFakeExpo(path.join(appRoot, 'node_modules', '.bin', 'expo'));
53
53
  await writeFakeDocker(path.join(fakeBin, 'docker'));
54
54
 
55
55
  await writePublicEnvironment(appRoot, infraRoot, PUBLIC_KEY_A);
56
56
  await runBuildScript({
57
57
  appRoot,
58
58
  buildScriptPath,
59
- bunxArgsPath,
59
+ expoArgsPath,
60
60
  dockerArgsPath,
61
61
  fakeBin,
62
62
  metroCachePath,
@@ -70,7 +70,7 @@ test('re-exports rotated public environment values before building the app image
70
70
  await runBuildScript({
71
71
  appRoot,
72
72
  buildScriptPath,
73
- bunxArgsPath,
73
+ expoArgsPath,
74
74
  dockerArgsPath,
75
75
  fakeBin,
76
76
  metroCachePath,
@@ -80,8 +80,7 @@ test('re-exports rotated public environment values before building the app image
80
80
  expect(secondBundle).toContain(PUBLIC_KEY_B);
81
81
  expect(secondBundle).not.toContain(PUBLIC_KEY_A);
82
82
  expect(secondBundle).not.toContain(PRIVATE_SERVICE_ROLE_KEY);
83
- expect((await readFile(bunxArgsPath, 'utf8')).trim().split('\n')).toEqual([
84
- 'expo',
83
+ expect((await readFile(expoArgsPath, 'utf8')).trim().split('\n')).toEqual([
85
84
  'export',
86
85
  '--platform',
87
86
  'web',
@@ -98,6 +97,64 @@ test('re-exports rotated public environment values before building the app image
98
97
  }
99
98
  });
100
99
 
100
+ test('fails before global or network Expo resolution when app dependencies are missing', async () => {
101
+ const workspaceRoot = await mkdtemp(path.join(tmpdir(), 'ankh-infra-app-image-missing-expo-'));
102
+
103
+ try {
104
+ const appRoot = path.join(workspaceRoot, 'apps', APP_SLUG);
105
+ const infraRoot = path.join(appRoot, 'infra', 'minikube');
106
+ const scriptsRoot = path.join(infraRoot, 'scripts');
107
+ const fakeBin = path.join(workspaceRoot, 'bin');
108
+ const buildScriptPath = path.join(scriptsRoot, 'build-app-image.sh');
109
+ const dockerfilePath = path.join(infraRoot, 'app-image', 'Dockerfile');
110
+ const fallbackMarkerPath = path.join(workspaceRoot, 'forbidden-expo-fallback');
111
+ const generated = generateInfrastructure(
112
+ {
113
+ deployment: { target: 'minikube', monitoring: false },
114
+ modules: [],
115
+ },
116
+ { appManifest: createAppManifest(APP_SLUG) },
117
+ );
118
+ const buildScript = getGeneratedFile(
119
+ generated.files,
120
+ 'infra/minikube/scripts/build-app-image.sh',
121
+ );
122
+
123
+ await mkdir(path.dirname(dockerfilePath), { recursive: true });
124
+ await mkdir(fakeBin, { recursive: true });
125
+ await mkdir(scriptsRoot, { recursive: true });
126
+ await writeFile(path.join(appRoot, 'package.json'), '{}\n', 'utf8');
127
+ await writeFile(dockerfilePath, 'FROM scratch\n', 'utf8');
128
+ await writeFile(buildScriptPath, buildScript, 'utf8');
129
+ await chmod(buildScriptPath, 0o755);
130
+ await writeFakeDocker(path.join(fakeBin, 'docker'));
131
+ await writeForbiddenExpoFallback(path.join(fakeBin, 'bunx'));
132
+ await writeForbiddenExpoFallback(path.join(fakeBin, 'expo'));
133
+
134
+ const result = await executeBuildScript({
135
+ appRoot,
136
+ buildScriptPath,
137
+ dockerArgsPath: path.join(workspaceRoot, 'docker-args'),
138
+ expoArgsPath: path.join(workspaceRoot, 'expo-args'),
139
+ fakeBin,
140
+ fallbackMarkerPath,
141
+ metroCachePath: path.join(workspaceRoot, 'fake-metro-cache'),
142
+ });
143
+
144
+ expect(result.exitCode).toBe(1);
145
+ expect(result.stderr).toContain(
146
+ `App-owned Expo CLI is missing: ${appRoot}/node_modules/.bin/expo`,
147
+ );
148
+ expect(result.stderr).toContain(
149
+ `Run 'bun install --frozen-lockfile' in ${appRoot} before building the app image.`,
150
+ );
151
+ expect(buildScript).not.toContain('bunx');
152
+ expect(await Bun.file(fallbackMarkerPath).exists()).toBe(false);
153
+ } finally {
154
+ await rm(workspaceRoot, { recursive: true, force: true });
155
+ }
156
+ });
157
+
101
158
  async function writePublicEnvironment(
102
159
  appRoot: string,
103
160
  infraRoot: string,
@@ -122,19 +179,20 @@ SUPABASE_SERVICE_ROLE_KEY=${PRIVATE_SERVICE_ROLE_KEY}
122
179
  );
123
180
  }
124
181
 
125
- async function writeFakeBunx(filePath: string): Promise<void> {
182
+ async function writeFakeExpo(filePath: string): Promise<void> {
183
+ await mkdir(path.dirname(filePath), { recursive: true });
126
184
  await writeFile(
127
185
  filePath,
128
186
  `#!/usr/bin/env bash
129
187
  set -euo pipefail
130
188
 
131
- printf '%s\\n' "$@" > "\${FAKE_BUNX_ARGS_PATH:?}"
189
+ printf '%s\\n' "$@" > "\${FAKE_EXPO_ARGS_PATH:?}"
132
190
 
133
- if [[ "\${1:-}" != "expo" || "\${2:-}" != "export" ]]; then
191
+ if [[ "\${1:-}" != "export" ]]; then
134
192
  echo "Expected expo export invocation." >&2
135
193
  exit 64
136
194
  fi
137
- shift 2
195
+ shift
138
196
 
139
197
  clear_cache=false
140
198
  output_dir=""
@@ -208,20 +266,38 @@ fi
208
266
  await chmod(filePath, 0o755);
209
267
  }
210
268
 
211
- async function runBuildScript(args: {
269
+ async function writeForbiddenExpoFallback(filePath: string): Promise<void> {
270
+ await writeFile(
271
+ filePath,
272
+ `#!/usr/bin/env bash
273
+ set -euo pipefail
274
+
275
+ : > "\${FAKE_FALLBACK_MARKER_PATH:?}"
276
+ exit 86
277
+ `,
278
+ 'utf8',
279
+ );
280
+ await chmod(filePath, 0o755);
281
+ }
282
+
283
+ async function executeBuildScript(args: {
212
284
  appRoot: string;
213
285
  buildScriptPath: string;
214
- bunxArgsPath: string;
286
+ expoArgsPath: string;
215
287
  dockerArgsPath: string;
216
288
  fakeBin: string;
289
+ fallbackMarkerPath?: string;
217
290
  metroCachePath: string;
218
- }): Promise<void> {
291
+ }): Promise<{ exitCode: number | null; stderr: string; stdout: string }> {
219
292
  const child = spawn('bash', [args.buildScriptPath], {
220
293
  cwd: args.appRoot,
221
294
  env: {
222
295
  ...process.env,
223
- FAKE_BUNX_ARGS_PATH: args.bunxArgsPath,
224
296
  FAKE_DOCKER_ARGS_PATH: args.dockerArgsPath,
297
+ FAKE_EXPO_ARGS_PATH: args.expoArgsPath,
298
+ ...(args.fallbackMarkerPath === undefined
299
+ ? {}
300
+ : { FAKE_FALLBACK_MARKER_PATH: args.fallbackMarkerPath }),
225
301
  FAKE_METRO_CACHE_PATH: args.metroCachePath,
226
302
  PATH: `${args.fakeBin}:${process.env.PATH ?? ''}`,
227
303
  },
@@ -237,6 +313,19 @@ async function runBuildScript(args: {
237
313
  exitCodePromise,
238
314
  ]);
239
315
 
316
+ return { exitCode, stderr, stdout };
317
+ }
318
+
319
+ async function runBuildScript(args: {
320
+ appRoot: string;
321
+ buildScriptPath: string;
322
+ expoArgsPath: string;
323
+ dockerArgsPath: string;
324
+ fakeBin: string;
325
+ metroCachePath: string;
326
+ }): Promise<void> {
327
+ const { exitCode, stderr, stdout } = await executeBuildScript(args);
328
+
240
329
  if (exitCode !== 0) {
241
330
  throw new Error(
242
331
  `Generated build script exited with ${String(exitCode)}. stdout=${stdout} stderr=${stderr}`,
@@ -169,6 +169,9 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
169
169
  expect(postgres).toContain('config_file=/etc/postgresql/postgresql.conf');
170
170
  expect(gatewayTemplate).toContain('keyauth_credentials');
171
171
  expect(gatewayTemplate).toContain('request-transformer');
172
+ expect(gatewayTemplate).toContain('_format_version: "2.1"');
173
+ expect(gatewayTemplate).toContain('username: "$DASHBOARD_USERNAME"');
174
+ expect(gatewayTemplate).toContain('password: "$DASHBOARD_PASSWORD"');
172
175
  expect(kongEntrypoint).toContain('LUA_AUTH_EXPR');
173
176
  expect(auth).toContain('name: GOTRUE_JWT_EXP');
174
177
  expect(auth).toContain('key: JWT_EXPIRY');
@@ -268,6 +271,7 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
268
271
  expect(kustomization).toContain('namespaces/cerbos.yaml');
269
272
  expect(readme).toContain('- Provider namespaces: `cerbos`');
270
273
  expect(readme).toContain('## Provider Lifecycle Contributions');
274
+ expect(readme).not.toContain('\n\n\n## Provider Lifecycle Contributions');
271
275
  expect(readme).toContain('`cerbos`: namespace `cerbos`');
272
276
  expect(readme).toContain('`deployment/cerbos` in namespace `cerbos`');
273
277
  expect(readme).toContain('`http` = `http://cerbos.cerbos.svc.cluster.local:3592`');
@@ -329,9 +333,11 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
329
333
  },
330
334
  );
331
335
  const envExample = getFile(result.files, 'infra/minikube/.env.example');
336
+ const readme = getFile(result.files, 'infra/minikube/README.md');
332
337
  const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
333
338
 
334
339
  expect(envExample).toContain('# google: auth/oauth/google');
340
+ expect(readme).not.toContain('credentialsRef` entries.\n\n\nPinned images:');
335
341
  expect(envExample).toContain('GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=');
336
342
  expect(envExample).toContain('GOTRUE_EXTERNAL_GOOGLE_SECRET=');
337
343
  expect(upScript).toContain('GOTRUE_EXTERNAL_GOOGLE_ENABLED=true');
@@ -392,12 +398,16 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
392
398
  ),
393
399
  );
394
400
  expect(upScript).not.toContain('SUPABASE_SERVICE_ROLE_KEY" "${SUPABASE_SERVICE_ROLE_KEY}"');
401
+ expect(buildScript).toContain('EXPO_CLI="${APP_SOURCE_DIR}/node_modules/.bin/expo"');
395
402
  expect(buildScript).toContain(
396
- 'bunx expo export --platform web --clear --output-dir "${APP_WEB_EXPORT_DIR}"',
403
+ '"${EXPO_CLI}" export --platform web --clear --output-dir "${APP_WEB_EXPORT_DIR}"',
397
404
  );
405
+ expect(buildScript).not.toContain('bunx');
398
406
  expect(readme).toContain(
399
- '`build-app-image.sh` clears the Expo/Metro bundler cache before every web export.',
407
+ '`build-app-image.sh` requires the app-installed `node_modules/.bin/expo` and never resolves',
400
408
  );
409
+ expect(readme).toContain('Expo globally or through the network.');
410
+ expect(readme).toContain('It clears the Expo/Metro bundler cache before every web');
401
411
  });
402
412
 
403
413
  test('starts Supabase schema owners before app migrations and profile reconciliation', () => {
@@ -331,7 +331,7 @@ function getReadmeMarkdown(args: {
331
331
  : '';
332
332
  const providerLifecycleLines =
333
333
  providerLifecycle.length > 0
334
- ? `\n## Provider Lifecycle Contributions\n\n${providerLifecycle
334
+ ? `## Provider Lifecycle Contributions\n\n${providerLifecycle
335
335
  .map((provider) => {
336
336
  const readiness =
337
337
  provider.readinessChecks.length > 0
@@ -347,11 +347,11 @@ function getReadmeMarkdown(args: {
347
347
  : 'none';
348
348
  return `- \`${provider.id}\`: namespace \`${provider.namespace}\`; readiness ${readiness}; endpoints ${endpoints}`;
349
349
  })
350
- .join('\n')}\n`
350
+ .join('\n')}`
351
351
  : '';
352
352
  const oauthDescription =
353
353
  oauthProviders.length > 0
354
- ? `\nConfigured OAuth providers are wired into GoTrue through generated \`GOTRUE_EXTERNAL_*\` runtime keys. The keys are named in \`.env.example\`; values must come from trusted secret resolution for the configured \`credentialsRef\` entries.\n`
354
+ ? `\nConfigured OAuth providers are wired into GoTrue through generated \`GOTRUE_EXTERNAL_*\` runtime keys. The keys are named in \`.env.example\`; values must come from trusted secret resolution for the configured \`credentialsRef\` entries.`
355
355
  : '';
356
356
 
357
357
  const runtimeForwardDescription = appRuntimeEnabled
@@ -451,10 +451,12 @@ ${appRuntimeEnabled ? `- App: \`http://127.0.0.1:${supabaseHostPorts.app}\`\n` :
451
451
 
452
452
  ## App Image Export
453
453
 
454
- \`build-app-image.sh\` clears the Expo/Metro bundler cache before every web export. Expo
455
- inlines \`EXPO_PUBLIC_*\` values into the browser bundle, so cache invalidation ensures a
456
- rotated browser-safe Supabase URL or anon key replaces the previous value before the Docker
457
- image is built. Privileged Supabase credentials are not written to the app's public env file.
454
+ \`build-app-image.sh\` requires the app-installed \`node_modules/.bin/expo\` and never resolves
455
+ Expo globally or through the network. It clears the Expo/Metro bundler cache before every web
456
+ export. Expo inlines \`EXPO_PUBLIC_*\` values into the browser bundle, so cache invalidation
457
+ ensures a rotated browser-safe Supabase URL or anon key replaces the previous value before the
458
+ Docker image is built. Privileged Supabase credentials are not written to the app's public env
459
+ file.
458
460
 
459
461
  ## Runtime Conventions
460
462
 
@@ -1835,7 +1837,7 @@ spec:
1835
1837
  }
1836
1838
 
1837
1839
  function getSupabaseGatewayTemplate(): string {
1838
- return `_format_version: '2.1'
1840
+ return `_format_version: "2.1"
1839
1841
  _transform: true
1840
1842
 
1841
1843
  consumers:
@@ -1857,8 +1859,8 @@ acls:
1857
1859
 
1858
1860
  basicauth_credentials:
1859
1861
  - consumer: DASHBOARD
1860
- username: '$DASHBOARD_USERNAME'
1861
- password: '$DASHBOARD_PASSWORD'
1862
+ username: "$DASHBOARD_USERNAME"
1863
+ password: "$DASHBOARD_PASSWORD"
1862
1864
 
1863
1865
  services:
1864
1866
  - name: auth-v1-open
@@ -2958,29 +2960,31 @@ if [[ "\${APP_BUILD_ENABLED}" != "true" ]]; then
2958
2960
  exit 0
2959
2961
  fi
2960
2962
 
2961
- if ! command -v docker >/dev/null 2>&1; then
2962
- echo "docker is required to build app image."
2963
+ if [[ ! -f "\${APP_SOURCE_DIR}/package.json" ]]; then
2964
+ echo "APP_SOURCE_DIR does not look like an app root: \${APP_SOURCE_DIR}"
2963
2965
  exit 1
2964
2966
  fi
2965
2967
 
2966
- if ! command -v bunx >/dev/null 2>&1; then
2967
- echo "bunx is required to export the app for container image build."
2968
+ if [[ ! -f "\${DOCKERFILE_PATH}" ]]; then
2969
+ echo "Dockerfile for app image build is missing: \${DOCKERFILE_PATH}"
2968
2970
  exit 1
2969
2971
  fi
2970
2972
 
2971
- if [[ ! -f "\${APP_SOURCE_DIR}/package.json" ]]; then
2972
- echo "APP_SOURCE_DIR does not look like an app root: \${APP_SOURCE_DIR}"
2973
+ EXPO_CLI="\${APP_SOURCE_DIR}/node_modules/.bin/expo"
2974
+ if [[ ! -x "\${EXPO_CLI}" ]]; then
2975
+ echo "App-owned Expo CLI is missing: \${EXPO_CLI}" >&2
2976
+ echo "Run 'bun install --frozen-lockfile' in \${APP_SOURCE_DIR} before building the app image." >&2
2973
2977
  exit 1
2974
2978
  fi
2975
2979
 
2976
- if [[ ! -f "\${DOCKERFILE_PATH}" ]]; then
2977
- echo "Dockerfile for app image build is missing: \${DOCKERFILE_PATH}"
2980
+ if ! command -v docker >/dev/null 2>&1; then
2981
+ echo "docker is required to build app image."
2978
2982
  exit 1
2979
2983
  fi
2980
2984
 
2981
2985
  (
2982
2986
  cd "\${APP_SOURCE_DIR}"
2983
- bunx expo export --platform web --clear --output-dir "\${APP_WEB_EXPORT_DIR}"
2987
+ "\${EXPO_CLI}" export --platform web --clear --output-dir "\${APP_WEB_EXPORT_DIR}"
2984
2988
  )
2985
2989
 
2986
2990
  if [[ "\${APP_WEB_EXPORT_DIR}" = /* ]]; then