@ankhorage/infra 4.1.2 → 4.1.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/infra",
3
3
  "type": "module",
4
- "version": "4.1.2",
4
+ "version": "4.1.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": {
@@ -35,8 +35,8 @@
35
35
  "openapi"
36
36
  ],
37
37
  "dependencies": {
38
- "@ankhorage/contracts": "^8.0.0",
39
- "@ankhorage/supabase-vault": "^0.2.4"
38
+ "@ankhorage/contracts": "^8.0.1",
39
+ "@ankhorage/supabase-vault": "^0.2.5"
40
40
  },
41
41
  "files": [
42
42
  "dist",
@@ -89,10 +89,9 @@
89
89
  },
90
90
  "scripts": {
91
91
  "build": "rm -rf dist tsconfig.tsbuildinfo && bun x tsc -p tsconfig.json",
92
- "changeset": "changeset",
93
- "changeset:status": "changeset status --since=origin/main",
92
+ "changeset": "ankhorage-changeset",
93
+ "changeset:status": "ankhorage-changeset status --since=origin/main",
94
94
  "docs": "paradox",
95
- "knip": "ankhorage-knip",
96
95
  "lint": "ankhorage-eslint . --max-warnings=0",
97
96
  "lint:fix": "ankhorage-eslint . --fix --max-warnings=0",
98
97
  "format": "ankhorage-prettier --write .",
@@ -103,16 +102,16 @@
103
102
  "test:e2e:minikube": "ANKH_MINIKUBE_ISOLATION=1 bun test src/adapters/minikube/base/twoAppIsolation.integration.test.ts",
104
103
  "test:e2e:supabase": "ANKH_MINIKUBE_SUPABASE_ISOLATION=1 bun test src/adapters/minikube/base/twoAppIsolation.integration.test.ts",
105
104
  "typecheck": "bun x tsc --noEmit -p tsconfig.json",
106
- "version-packages": "changeset version"
105
+ "version-packages": "ankhorage-changeset version",
106
+ "knip:check": "ankhorage-knip"
107
107
  },
108
108
  "devDependencies": {
109
- "@ankhorage/ankh": "^0.8.1",
110
- "@ankhorage/devtools": "^1.2.1",
111
- "@ankhorage/paradox": "^0.1.21",
112
- "@changesets/cli": "^2.31.0",
113
- "@types/bun": "^1.3.14",
109
+ "@ankhorage/ankh": "^0.8.4",
110
+ "@ankhorage/devtools": "^1.8.3",
111
+ "@ankhorage/paradox": "^0.1.24",
112
+ "@types/bun": "^1.4.0",
114
113
  "@types/node": "^25.6.0",
115
114
  "typescript": "^5.9.3"
116
115
  },
117
- "packageManager": "bun@1.3.14"
116
+ "packageManager": "bun@1.4.0"
118
117
  }
@@ -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}`,
@@ -398,12 +398,16 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
398
398
  ),
399
399
  );
400
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"');
401
402
  expect(buildScript).toContain(
402
- '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}"',
403
404
  );
405
+ expect(buildScript).not.toContain('bunx');
404
406
  expect(readme).toContain(
405
- '`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',
406
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');
407
411
  });
408
412
 
409
413
  test('starts Supabase schema owners before app migrations and profile reconciliation', () => {
@@ -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
 
@@ -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
@@ -22,8 +22,7 @@ const DIAGNOSTIC_OUTPUT_LIMIT = 20_000;
22
22
  const APP_ONLY_TEST_PROFILES = ['ankh-isolation-a', 'ankh-isolation-b'] as const;
23
23
  const SUPABASE_TEST_PROFILES = ['ankh-isolation-supa-a', 'ankh-isolation-supa-b'] as const;
24
24
  type TestOwnedMinikubeProfile =
25
- | (typeof APP_ONLY_TEST_PROFILES)[number]
26
- | (typeof SUPABASE_TEST_PROFILES)[number];
25
+ (typeof APP_ONLY_TEST_PROFILES)[number] | (typeof SUPABASE_TEST_PROFILES)[number];
27
26
 
28
27
  describe('generated Minikube two-app isolation', () => {
29
28
  isolationTest(