@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/CHANGELOG.md +12 -0
- package/dist/adapters/minikube/base/index.js +23 -19
- package/dist/adapters/minikube/base/index.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/minikube/README.md +6 -3
- package/src/adapters/minikube/base/appImageBuild.test.ts +103 -14
- package/src/adapters/minikube/base/index.test.ts +12 -2
- package/src/adapters/minikube/base/index.ts +23 -19
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/infra",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "4.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`
|
|
77
|
-
`
|
|
78
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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' "$@" > "\${
|
|
189
|
+
printf '%s\\n' "$@" > "\${FAKE_EXPO_ARGS_PATH:?}"
|
|
132
190
|
|
|
133
|
-
if [[ "\${1:-}" != "
|
|
191
|
+
if [[ "\${1:-}" != "export" ]]; then
|
|
134
192
|
echo "Expected expo export invocation." >&2
|
|
135
193
|
exit 64
|
|
136
194
|
fi
|
|
137
|
-
shift
|
|
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
|
|
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
|
-
|
|
286
|
+
expoArgsPath: string;
|
|
215
287
|
dockerArgsPath: string;
|
|
216
288
|
fakeBin: string;
|
|
289
|
+
fallbackMarkerPath?: string;
|
|
217
290
|
metroCachePath: string;
|
|
218
|
-
}): Promise<
|
|
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
|
-
'
|
|
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`
|
|
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
|
-
?
|
|
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')}
|
|
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
|
|
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\`
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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:
|
|
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:
|
|
1861
|
-
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 !
|
|
2962
|
-
echo "
|
|
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 !
|
|
2967
|
-
echo "
|
|
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
|
-
|
|
2972
|
-
|
|
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
|
|
2977
|
-
echo "
|
|
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
|
-
|
|
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
|