@vrdmr/fnx-test 0.3.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.
- package/lib/azurite-manager.js +5 -2
- package/lib/cli.js +127 -33
- package/lib/config-schema.js +167 -0
- package/lib/config.js +468 -0
- package/lib/doctor.js +241 -0
- package/lib/funcignore.js +117 -0
- package/lib/help-art.js +134 -0
- package/lib/host-launcher.js +3 -3
- package/lib/live-mcp-server.js +1 -1
- package/lib/pack.js +66 -16
- package/lib/secret-patterns.js +108 -0
- package/package.json +3 -1
package/lib/azurite-manager.js
CHANGED
|
@@ -11,6 +11,7 @@ const TABLE_PORT = 10002;
|
|
|
11
11
|
const AZURITE_INSTALL_DIR = join(homedir(), '.fnx', 'tools', 'azurite');
|
|
12
12
|
|
|
13
13
|
let azuriteProcess = null;
|
|
14
|
+
let weStartedAzurite = false;
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Determine whether Azurite is needed based on AzureWebJobsStorage value.
|
|
@@ -160,6 +161,7 @@ export async function ensureAzurite(mergedValues, opts = {}) {
|
|
|
160
161
|
azuriteProcess = spawn(azuriteBin, azuriteArgs, {
|
|
161
162
|
stdio: 'ignore',
|
|
162
163
|
});
|
|
164
|
+
weStartedAzurite = true;
|
|
163
165
|
|
|
164
166
|
azuriteProcess.on('error', (err) => {
|
|
165
167
|
console.error(errorColor(`[fnx] Azurite failed to start: ${err.message}`));
|
|
@@ -188,11 +190,12 @@ export async function ensureAzurite(mergedValues, opts = {}) {
|
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
/**
|
|
191
|
-
* Stop the managed Azurite process.
|
|
193
|
+
* Stop the managed Azurite process (only if fnx started it).
|
|
192
194
|
*/
|
|
193
195
|
export function stopAzurite() {
|
|
194
|
-
if (azuriteProcess) {
|
|
196
|
+
if (azuriteProcess && weStartedAzurite) {
|
|
195
197
|
try { azuriteProcess.kill(); } catch { /* already dead */ }
|
|
196
198
|
azuriteProcess = null;
|
|
199
|
+
weStartedAzurite = false;
|
|
197
200
|
}
|
|
198
201
|
}
|
package/lib/cli.js
CHANGED
|
@@ -10,7 +10,9 @@ import { launchHost, createHostState } from './host-launcher.js';
|
|
|
10
10
|
import { startLiveMcpServer } from './live-mcp-server.js';
|
|
11
11
|
import { detectDotnetModel, printInProcessError } from './dotnet-detector.js';
|
|
12
12
|
import { detectRuntimeFromConfig, packFunctionApp } from './pack.js';
|
|
13
|
+
import { loadConfig, migrateConfig, validateConfig, showResolvedConfig } from './config.js';
|
|
13
14
|
import { title, info, funcName, url as urlColor, success, error as errorColor, warning, dim, bold, highlightUrls } from './colors.js';
|
|
15
|
+
import { renderAsciiFooter } from './help-art.js';
|
|
14
16
|
|
|
15
17
|
const FNX_HOME = join(homedir(), '.fnx');
|
|
16
18
|
const VERSION_CHECK_FILE = join(FNX_HOME, 'version-check.json');
|
|
@@ -42,12 +44,16 @@ function hasHelp(args) {
|
|
|
42
44
|
* 2. Otherwise check cwd for host.json.
|
|
43
45
|
* 3. Fall back to cwd/src if it contains host.json.
|
|
44
46
|
* 4. Error with actionable message if nothing found.
|
|
47
|
+
*
|
|
48
|
+
* opts.requireHostJson (default: true) — set to false for commands that
|
|
49
|
+
* work on config files only (e.g. fnx config migrate).
|
|
45
50
|
*/
|
|
46
|
-
function resolveAppPath(args) {
|
|
51
|
+
function resolveAppPath(args, opts = {}) {
|
|
52
|
+
const requireHostJson = opts.requireHostJson !== false;
|
|
47
53
|
const explicit = getFlag(args, '--app-path');
|
|
48
54
|
if (explicit) {
|
|
49
55
|
const resolved = resolvePath(explicit);
|
|
50
|
-
if (!existsSync(join(resolved, 'host.json'))) {
|
|
56
|
+
if (requireHostJson && !existsSync(join(resolved, 'host.json'))) {
|
|
51
57
|
console.error(errorColor(`Error: No host.json found in ${resolved}`));
|
|
52
58
|
console.error(` The --app-path must point to a directory containing host.json.`);
|
|
53
59
|
console.error(dim(` Example: fnx start --app-path ./my-function-app`));
|
|
@@ -67,6 +73,11 @@ function resolveAppPath(args) {
|
|
|
67
73
|
return srcDir;
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
// For config-only commands, fall back to cwd even without host.json
|
|
77
|
+
if (!requireHostJson) {
|
|
78
|
+
return cwd;
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
console.error(errorColor(`Error: No function app found.`));
|
|
71
82
|
console.error(` Could not find host.json in the current directory or ./src.`);
|
|
72
83
|
console.error(dim(` Use --app-path <dir> to specify the function app location.`));
|
|
@@ -77,7 +88,8 @@ export async function main(args) {
|
|
|
77
88
|
const cmd = args[0];
|
|
78
89
|
|
|
79
90
|
if (cmd === '-h' || cmd === '--help' || cmd === 'help' || !cmd) {
|
|
80
|
-
|
|
91
|
+
const showAscii = args.includes('--ascii');
|
|
92
|
+
await printHelpWithVersionInfo(showAscii);
|
|
81
93
|
process.exit(cmd ? 0 : 1);
|
|
82
94
|
}
|
|
83
95
|
|
|
@@ -100,6 +112,33 @@ export async function main(args) {
|
|
|
100
112
|
return;
|
|
101
113
|
}
|
|
102
114
|
|
|
115
|
+
if (cmd === 'doctor') {
|
|
116
|
+
if (hasHelp(args.slice(1))) { printDoctorHelp(); return; }
|
|
117
|
+
const { runDoctor } = await import('./doctor.js');
|
|
118
|
+
const appPath = resolveAppPath(args, { requireHostJson: false });
|
|
119
|
+
const exitCode = await runDoctor(appPath);
|
|
120
|
+
process.exit(exitCode);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (cmd === 'config') {
|
|
124
|
+
if (hasHelp(args.slice(1))) { printConfigHelp(); return; }
|
|
125
|
+
const subCmd = args[1];
|
|
126
|
+
const appPath = resolveAppPath(args, { requireHostJson: false });
|
|
127
|
+
if (subCmd === 'migrate') {
|
|
128
|
+
await migrateConfig(appPath);
|
|
129
|
+
} else if (subCmd === 'validate') {
|
|
130
|
+
const result = await validateConfig(appPath);
|
|
131
|
+
if (result.warnings.length) result.warnings.forEach(w => console.log(warning(` ⚠ ${w}`)));
|
|
132
|
+
if (result.secrets.length) result.secrets.forEach(s => console.log(errorColor(` ✗ ${s.path}: ${s.reason}`)));
|
|
133
|
+
if (result.errors.length) result.errors.forEach(e => console.log(errorColor(` ✗ ${e}`)));
|
|
134
|
+
if (result.valid) console.log(success(' ✓ app-config.yaml is valid.'));
|
|
135
|
+
else process.exit(1);
|
|
136
|
+
} else {
|
|
137
|
+
await showResolvedConfig(appPath);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
103
142
|
if (cmd === 'sync') {
|
|
104
143
|
if (hasHelp(args.slice(1))) { printSyncHelp(); return; }
|
|
105
144
|
await runSync(args.slice(1));
|
|
@@ -124,6 +163,12 @@ export async function main(args) {
|
|
|
124
163
|
|
|
125
164
|
if (hasHelp(args.slice(1))) { printStartHelp(); return; }
|
|
126
165
|
|
|
166
|
+
// Handle --sku list early (no config needed)
|
|
167
|
+
if (getFlag(args, '--sku') === 'list') {
|
|
168
|
+
await listProfiles();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
127
172
|
await maybeWarnForCliUpgrade();
|
|
128
173
|
|
|
129
174
|
const scriptRoot = resolveAppPath(args);
|
|
@@ -143,21 +188,22 @@ export async function main(args) {
|
|
|
143
188
|
setProfilesSource(profilesSource);
|
|
144
189
|
}
|
|
145
190
|
|
|
146
|
-
//
|
|
147
|
-
const
|
|
148
|
-
const
|
|
191
|
+
// Load and validate app configuration (app-config.yaml + local.settings.json)
|
|
192
|
+
const appCfg = await loadConfig(scriptRoot);
|
|
193
|
+
const { mergedValues, workerRuntime } = appCfg;
|
|
149
194
|
|
|
150
|
-
|
|
195
|
+
if (!workerRuntime) {
|
|
196
|
+
console.error(errorColor('Error: runtime.name not set in app-config.yaml and FUNCTIONS_WORKER_RUNTIME not in local.settings.json'));
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Resolve SKU: CLI flag > app-config.yaml > default "flex"
|
|
151
201
|
let sku = getFlag(args, '--sku');
|
|
152
202
|
let skuSource = 'CLI flag';
|
|
153
203
|
|
|
154
|
-
if (!sku &&
|
|
155
|
-
sku =
|
|
156
|
-
skuSource =
|
|
157
|
-
}
|
|
158
|
-
if (!sku && localSettings?.TargetSku) {
|
|
159
|
-
sku = localSettings.TargetSku;
|
|
160
|
-
skuSource = 'local.settings.json';
|
|
204
|
+
if (!sku && appCfg.sku) {
|
|
205
|
+
sku = appCfg.sku;
|
|
206
|
+
skuSource = appCfg.skuSource;
|
|
161
207
|
}
|
|
162
208
|
if (!sku) {
|
|
163
209
|
sku = 'flex';
|
|
@@ -166,11 +212,6 @@ export async function main(args) {
|
|
|
166
212
|
console.log(dim(`Tip: Use --sku <name> to target a specific SKU. Run --sku list to see options.\n`));
|
|
167
213
|
}
|
|
168
214
|
|
|
169
|
-
if (sku === 'list') {
|
|
170
|
-
await listProfiles();
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
215
|
// 1. Resolve profile
|
|
175
216
|
if (skuSource !== 'default') {
|
|
176
217
|
console.log(title(`Resolving SKU profile: ${sku} (from ${skuSource})...`));
|
|
@@ -195,19 +236,6 @@ export async function main(args) {
|
|
|
195
236
|
console.log(` ${dim('Profile Source:')} ${info(source)}`);
|
|
196
237
|
console.log();
|
|
197
238
|
|
|
198
|
-
// Early validation: merge config and check runtime before downloading anything
|
|
199
|
-
const mergedValues = {
|
|
200
|
-
...(appConfig?.Values || {}),
|
|
201
|
-
...(localSettings?.Values || {}),
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
const workerRuntime = mergedValues.FUNCTIONS_WORKER_RUNTIME;
|
|
205
|
-
|
|
206
|
-
if (!workerRuntime) {
|
|
207
|
-
console.error(errorColor('Error: FUNCTIONS_WORKER_RUNTIME not set in app.config.json or local.settings.json'));
|
|
208
|
-
process.exit(1);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
239
|
// F9: .NET isolated worker only — block in-process projects with guidance
|
|
212
240
|
const dotnetRuntimes = ['dotnet', 'dotnet-isolated'];
|
|
213
241
|
if (dotnetRuntimes.includes(workerRuntime)) {
|
|
@@ -392,7 +420,7 @@ export async function readJsonFile(filePath) {
|
|
|
392
420
|
}
|
|
393
421
|
}
|
|
394
422
|
|
|
395
|
-
async function printHelpWithVersionInfo() {
|
|
423
|
+
async function printHelpWithVersionInfo(showAscii = false) {
|
|
396
424
|
const pkg = await getFnxPackage();
|
|
397
425
|
const cachedHosts = getCachedHostVersions().sort(compareVersions);
|
|
398
426
|
const cachedBundles = getCachedBundleVersions().sort(compareVersions);
|
|
@@ -431,6 +459,10 @@ ${dim('fnx Version:')} ${title(pkg.version)}`);
|
|
|
431
459
|
|
|
432
460
|
console.log();
|
|
433
461
|
printHelp();
|
|
462
|
+
|
|
463
|
+
if (showAscii) {
|
|
464
|
+
console.log('\n' + renderAsciiFooter());
|
|
465
|
+
}
|
|
434
466
|
}
|
|
435
467
|
|
|
436
468
|
function printHelp() {
|
|
@@ -438,8 +470,10 @@ function printHelp() {
|
|
|
438
470
|
|
|
439
471
|
${title('Commands:')}
|
|
440
472
|
${funcName('start')} Launch the Azure Functions host runtime for a specific SKU.
|
|
473
|
+
${funcName('doctor')} Validate project setup and diagnose common issues.
|
|
441
474
|
${funcName('sync')} Sync cached host/extensions with current catalog profile.
|
|
442
475
|
${funcName('pack')} Package a Functions app into a deployment zip.
|
|
476
|
+
${funcName('config')} Show, validate, or migrate app configuration.
|
|
443
477
|
${funcName('warmup')} Pre-download host binaries and extension bundles.
|
|
444
478
|
${funcName('templates-mcp')} Start the Azure Functions templates MCP server (stdio).
|
|
445
479
|
|
|
@@ -451,6 +485,7 @@ ${title('Common Options:')}
|
|
|
451
485
|
${success('--verbose')} Show all host output (unfiltered).
|
|
452
486
|
${success('-v')}, ${success('--version')} Display the version of fnx.
|
|
453
487
|
${success('-h')}, ${success('--help')} Display this help information.
|
|
488
|
+
${success('--ascii')} Show ASCII art + QR code (use with -h).
|
|
454
489
|
|
|
455
490
|
${title('Start Options:')} ${dim('(fnx start)')}
|
|
456
491
|
${success('--app-path')} <dir> Path to the function app directory (default: cwd).
|
|
@@ -480,6 +515,7 @@ ${title('Examples:')}
|
|
|
480
515
|
fnx start Start with default SKU (flex)
|
|
481
516
|
fnx start --sku windows-consumption Emulate Windows Consumption
|
|
482
517
|
fnx start --sku flex --port 8080 Custom port
|
|
518
|
+
fnx doctor Validate project setup
|
|
483
519
|
fnx pack --app-path ./my-app Package function app as zip
|
|
484
520
|
fnx sync host --force Force re-download host binary
|
|
485
521
|
fnx warmup --all Pre-download all SKUs
|
|
@@ -604,3 +640,61 @@ ${title('VS Code Configuration:')}
|
|
|
604
640
|
}
|
|
605
641
|
}`.trim());
|
|
606
642
|
}
|
|
643
|
+
|
|
644
|
+
function printConfigHelp() {
|
|
645
|
+
console.log(`
|
|
646
|
+
${bold(title('fnx config'))} — Show, validate, or migrate app configuration.
|
|
647
|
+
|
|
648
|
+
${title('Usage:')} fnx config [subcommand] [options]
|
|
649
|
+
|
|
650
|
+
${title('Subcommands:')}
|
|
651
|
+
${funcName('(none)')} Show resolved config with provenance (which file each value comes from).
|
|
652
|
+
${funcName('migrate')} Create app-config.yaml from local.settings.json (extract non-secrets).
|
|
653
|
+
${funcName('validate')} Validate app-config.yaml (schema, secrets, allowlist) without starting.
|
|
654
|
+
|
|
655
|
+
${title('Options:')}
|
|
656
|
+
${success('--app-path')} <dir> Path to the function app directory (default: cwd).
|
|
657
|
+
${success('-h')}, ${success('--help')} Show this help message.
|
|
658
|
+
|
|
659
|
+
${title('Configuration Files:')}
|
|
660
|
+
${funcName('app-config.yaml')} Non-secret behavioral config (committed to source control).
|
|
661
|
+
Contains runtime, SKU target, scale settings, and app settings.
|
|
662
|
+
${funcName('local.settings.json')} Secrets and connection strings (git-ignored).
|
|
663
|
+
Values here override app-config.yaml values.
|
|
664
|
+
|
|
665
|
+
${title('Precedence:')} CLI flags > local.settings.json > app-config.yaml > defaults
|
|
666
|
+
|
|
667
|
+
${title('Examples:')}
|
|
668
|
+
fnx config Show resolved config
|
|
669
|
+
fnx config migrate Create app-config.yaml from local.settings.json
|
|
670
|
+
fnx config validate Check app-config.yaml for errors
|
|
671
|
+
fnx config validate --app-path ./my-app Validate a specific app`.trim());
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function printDoctorHelp() {
|
|
675
|
+
console.log(`
|
|
676
|
+
${bold(title('fnx doctor'))} — Validate project setup and diagnose common issues.
|
|
677
|
+
|
|
678
|
+
${title('Usage:')} fnx doctor [options]
|
|
679
|
+
|
|
680
|
+
${title('Checks:')}
|
|
681
|
+
• host.json Present and valid (version 2.0)
|
|
682
|
+
• app-config.yaml Schema valid, no secrets, runtime configured
|
|
683
|
+
• local.settings.json Present and valid JSON
|
|
684
|
+
• Worker runtime Detected from config files
|
|
685
|
+
• Host cache Cached host binaries in ~/.fnx/hosts/
|
|
686
|
+
• Default ports 7071 (HTTP) and 7072 (MCP) availability
|
|
687
|
+
• Azurite Storage emulator status
|
|
688
|
+
|
|
689
|
+
${title('Options:')}
|
|
690
|
+
${success('--app-path')} <dir> Path to the function app directory (default: cwd).
|
|
691
|
+
${success('-h')}, ${success('--help')} Show this help message.
|
|
692
|
+
|
|
693
|
+
${title('Exit Codes:')}
|
|
694
|
+
${success('0')} All checks passed (or warnings only)
|
|
695
|
+
${errorColor('1')} One or more checks failed
|
|
696
|
+
|
|
697
|
+
${title('Examples:')}
|
|
698
|
+
fnx doctor Check current directory
|
|
699
|
+
fnx doctor --app-path ./my-app Check a specific app`.trim());
|
|
700
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// config-schema.js — Canonical mapping: app-config.yaml paths → host environment variables
|
|
2
|
+
//
|
|
3
|
+
// This is the single source of truth for the config format. fnx is the first consumer,
|
|
4
|
+
// but workers, deployment tools, and other components can adopt the same schema.
|
|
5
|
+
|
|
6
|
+
// ── Structured field mappings (YAML path → env var) ──
|
|
7
|
+
|
|
8
|
+
export const STRUCTURED_FIELDS = {
|
|
9
|
+
'runtime.name': {
|
|
10
|
+
envVar: 'FUNCTIONS_WORKER_RUNTIME',
|
|
11
|
+
required: true,
|
|
12
|
+
allowed: ['python', 'node', 'dotnet-isolated', 'dotnet', 'java', 'powershell', 'custom'],
|
|
13
|
+
description: 'Language runtime for the function app',
|
|
14
|
+
},
|
|
15
|
+
'runtime.version': {
|
|
16
|
+
envVar: 'FUNCTIONS_WORKER_RUNTIME_VERSION',
|
|
17
|
+
required: false,
|
|
18
|
+
description: 'Language runtime version (e.g. "3.11" for Python)',
|
|
19
|
+
},
|
|
20
|
+
'scaleAndConcurrency.maximumInstanceCount': {
|
|
21
|
+
envVar: 'WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT',
|
|
22
|
+
required: false,
|
|
23
|
+
type: 'number',
|
|
24
|
+
description: 'Maximum number of instances for scale-out',
|
|
25
|
+
},
|
|
26
|
+
'scaleAndConcurrency.instanceMemoryMB': {
|
|
27
|
+
envVar: 'WEBSITE_INSTANCE_MEMORY_MB',
|
|
28
|
+
required: false,
|
|
29
|
+
type: 'number',
|
|
30
|
+
description: 'Memory allocation per instance in MB',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── Allowlisted configuration keys (non-secret app settings) ──
|
|
35
|
+
// These are the only keys permitted in the `configurations:` section.
|
|
36
|
+
// Reference: https://learn.microsoft.com/en-us/azure/azure-functions/functions-app-settings
|
|
37
|
+
|
|
38
|
+
export const ALLOWED_CONFIGURATIONS = new Set([
|
|
39
|
+
// Core runtime
|
|
40
|
+
'AzureWebJobsFeatureFlags',
|
|
41
|
+
'AZURE_FUNCTIONS_ENVIRONMENT',
|
|
42
|
+
'FUNCTIONS_WORKER_PROCESS_COUNT',
|
|
43
|
+
'FUNCTIONS_WORKER_SHARED_MEMORY_DATA_TRANSFER_ENABLED',
|
|
44
|
+
|
|
45
|
+
// Python worker
|
|
46
|
+
'PYTHON_ISOLATE_WORKER_DEPENDENCIES',
|
|
47
|
+
'PYTHON_ENABLE_WORKER_EXTENSIONS',
|
|
48
|
+
'PYTHON_THREADPOOL_THREAD_COUNT',
|
|
49
|
+
'PYTHON_ENABLE_DEBUG_LOGGING',
|
|
50
|
+
|
|
51
|
+
// Node worker
|
|
52
|
+
'languageWorkers__node__arguments',
|
|
53
|
+
|
|
54
|
+
// Java worker
|
|
55
|
+
'JAVA_OPTS',
|
|
56
|
+
'FUNCTIONS_WORKER_JAVA_LOAD_APP_LIBS',
|
|
57
|
+
|
|
58
|
+
// .NET (isolated)
|
|
59
|
+
'FUNCTIONS_WORKER_DOTNET_RELEASE_COMPILATION',
|
|
60
|
+
|
|
61
|
+
// Host behavior
|
|
62
|
+
'AzureWebJobsDisableHomepage',
|
|
63
|
+
'FUNCTIONS_REQUEST_BODY_SIZE_LIMIT',
|
|
64
|
+
'PythonPath',
|
|
65
|
+
|
|
66
|
+
// Extension bundle overrides
|
|
67
|
+
'AzureFunctionsJobHost__extensionBundle__id',
|
|
68
|
+
'AzureFunctionsJobHost__extensionBundle__version',
|
|
69
|
+
|
|
70
|
+
// Scale controller
|
|
71
|
+
'WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT',
|
|
72
|
+
|
|
73
|
+
// Misc non-secret behavioral
|
|
74
|
+
'FUNCTIONS_V2_COMPATIBILITY_MODE',
|
|
75
|
+
'AzureWebJobsDotNetReleaseCompilation',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// ── Top-level YAML sections ──
|
|
79
|
+
|
|
80
|
+
export const VALID_TOP_LEVEL_KEYS = new Set([
|
|
81
|
+
'local',
|
|
82
|
+
'runtime',
|
|
83
|
+
'scaleAndConcurrency',
|
|
84
|
+
'configurations',
|
|
85
|
+
// Reserved for future use
|
|
86
|
+
// 'deployment',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// ── Local section keys (fnx-specific) ──
|
|
90
|
+
|
|
91
|
+
export const VALID_LOCAL_KEYS = new Set([
|
|
92
|
+
'targetSku',
|
|
93
|
+
'port',
|
|
94
|
+
'mcpPort',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
// ── Resolve structured YAML config → flat env var map ──
|
|
98
|
+
|
|
99
|
+
export function resolveEnvVars(config) {
|
|
100
|
+
const envVars = {};
|
|
101
|
+
const errors = [];
|
|
102
|
+
|
|
103
|
+
// Map structured fields
|
|
104
|
+
for (const [yamlPath, spec] of Object.entries(STRUCTURED_FIELDS)) {
|
|
105
|
+
const value = getNestedValue(config, yamlPath);
|
|
106
|
+
if (value !== undefined && value !== null) {
|
|
107
|
+
if (spec.allowed && !spec.allowed.includes(value)) {
|
|
108
|
+
errors.push(`${yamlPath}: "${value}" is not allowed. Valid: ${spec.allowed.join(', ')}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (spec.type === 'number' && typeof value !== 'number') {
|
|
112
|
+
errors.push(`${yamlPath}: expected number, got ${typeof value}`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
envVars[spec.envVar] = String(value);
|
|
116
|
+
} else if (spec.required) {
|
|
117
|
+
errors.push(`${yamlPath} is required (maps to ${spec.envVar})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Pass through configurations.* as env vars
|
|
122
|
+
if (config.configurations && typeof config.configurations === 'object') {
|
|
123
|
+
for (const [key, value] of Object.entries(config.configurations)) {
|
|
124
|
+
if (!ALLOWED_CONFIGURATIONS.has(key)) {
|
|
125
|
+
errors.push(`configurations.${key}: not in the allowlist. Move to local.settings.json if needed.`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
envVars[key] = String(value);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { envVars, errors };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Validate top-level structure ──
|
|
136
|
+
|
|
137
|
+
export function validateStructure(config) {
|
|
138
|
+
const warnings = [];
|
|
139
|
+
if (!config || typeof config !== 'object') {
|
|
140
|
+
return { warnings: ['app-config.yaml is empty or not a valid YAML object'] };
|
|
141
|
+
}
|
|
142
|
+
for (const key of Object.keys(config)) {
|
|
143
|
+
if (!VALID_TOP_LEVEL_KEYS.has(key)) {
|
|
144
|
+
warnings.push(`Unknown top-level key "${key}". Valid keys: ${[...VALID_TOP_LEVEL_KEYS].join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (config.local && typeof config.local === 'object') {
|
|
148
|
+
for (const key of Object.keys(config.local)) {
|
|
149
|
+
if (!VALID_LOCAL_KEYS.has(key)) {
|
|
150
|
+
warnings.push(`Unknown key "local.${key}". Valid keys: ${[...VALID_LOCAL_KEYS].join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { warnings };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Helper: get nested value by dot path ──
|
|
158
|
+
|
|
159
|
+
function getNestedValue(obj, path) {
|
|
160
|
+
const parts = path.split('.');
|
|
161
|
+
let current = obj;
|
|
162
|
+
for (const part of parts) {
|
|
163
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
164
|
+
current = current[part];
|
|
165
|
+
}
|
|
166
|
+
return current;
|
|
167
|
+
}
|