@vrdmr/fnx-test 0.3.0 → 0.4.0

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/cli.js CHANGED
@@ -10,6 +10,7 @@ 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';
14
15
 
15
16
  const FNX_HOME = join(homedir(), '.fnx');
@@ -42,12 +43,16 @@ function hasHelp(args) {
42
43
  * 2. Otherwise check cwd for host.json.
43
44
  * 3. Fall back to cwd/src if it contains host.json.
44
45
  * 4. Error with actionable message if nothing found.
46
+ *
47
+ * opts.requireHostJson (default: true) — set to false for commands that
48
+ * work on config files only (e.g. fnx config migrate).
45
49
  */
46
- function resolveAppPath(args) {
50
+ function resolveAppPath(args, opts = {}) {
51
+ const requireHostJson = opts.requireHostJson !== false;
47
52
  const explicit = getFlag(args, '--app-path');
48
53
  if (explicit) {
49
54
  const resolved = resolvePath(explicit);
50
- if (!existsSync(join(resolved, 'host.json'))) {
55
+ if (requireHostJson && !existsSync(join(resolved, 'host.json'))) {
51
56
  console.error(errorColor(`Error: No host.json found in ${resolved}`));
52
57
  console.error(` The --app-path must point to a directory containing host.json.`);
53
58
  console.error(dim(` Example: fnx start --app-path ./my-function-app`));
@@ -67,6 +72,11 @@ function resolveAppPath(args) {
67
72
  return srcDir;
68
73
  }
69
74
 
75
+ // For config-only commands, fall back to cwd even without host.json
76
+ if (!requireHostJson) {
77
+ return cwd;
78
+ }
79
+
70
80
  console.error(errorColor(`Error: No function app found.`));
71
81
  console.error(` Could not find host.json in the current directory or ./src.`);
72
82
  console.error(dim(` Use --app-path <dir> to specify the function app location.`));
@@ -100,6 +110,25 @@ export async function main(args) {
100
110
  return;
101
111
  }
102
112
 
113
+ if (cmd === 'config') {
114
+ if (hasHelp(args.slice(1))) { printConfigHelp(); return; }
115
+ const subCmd = args[1];
116
+ const appPath = resolveAppPath(args, { requireHostJson: false });
117
+ if (subCmd === 'migrate') {
118
+ await migrateConfig(appPath);
119
+ } else if (subCmd === 'validate') {
120
+ const result = await validateConfig(appPath);
121
+ if (result.warnings.length) result.warnings.forEach(w => console.log(warning(` ⚠ ${w}`)));
122
+ if (result.secrets.length) result.secrets.forEach(s => console.log(errorColor(` ✗ ${s.path}: ${s.reason}`)));
123
+ if (result.errors.length) result.errors.forEach(e => console.log(errorColor(` ✗ ${e}`)));
124
+ if (result.valid) console.log(success(' ✓ app-config.yaml is valid.'));
125
+ else process.exit(1);
126
+ } else {
127
+ await showResolvedConfig(appPath);
128
+ }
129
+ return;
130
+ }
131
+
103
132
  if (cmd === 'sync') {
104
133
  if (hasHelp(args.slice(1))) { printSyncHelp(); return; }
105
134
  await runSync(args.slice(1));
@@ -124,6 +153,12 @@ export async function main(args) {
124
153
 
125
154
  if (hasHelp(args.slice(1))) { printStartHelp(); return; }
126
155
 
156
+ // Handle --sku list early (no config needed)
157
+ if (getFlag(args, '--sku') === 'list') {
158
+ await listProfiles();
159
+ return;
160
+ }
161
+
127
162
  await maybeWarnForCliUpgrade();
128
163
 
129
164
  const scriptRoot = resolveAppPath(args);
@@ -143,21 +178,22 @@ export async function main(args) {
143
178
  setProfilesSource(profilesSource);
144
179
  }
145
180
 
146
- // Read config files early (needed for SKU resolution and env vars)
147
- const appConfig = await readJsonFile(resolvePath(scriptRoot, 'app.config.json'));
148
- const localSettings = await readJsonFile(resolvePath(scriptRoot, 'local.settings.json'));
181
+ // Load and validate app configuration (app-config.yaml + local.settings.json)
182
+ const appCfg = await loadConfig(scriptRoot);
183
+ const { mergedValues, workerRuntime } = appCfg;
184
+
185
+ if (!workerRuntime) {
186
+ console.error(errorColor('Error: runtime.name not set in app-config.yaml and FUNCTIONS_WORKER_RUNTIME not in local.settings.json'));
187
+ process.exit(1);
188
+ }
149
189
 
150
- // Resolve SKU: CLI flag > app.config.json > local.settings.json > default "flex"
190
+ // Resolve SKU: CLI flag > app-config.yaml > default "flex"
151
191
  let sku = getFlag(args, '--sku');
152
192
  let skuSource = 'CLI flag';
153
193
 
154
- if (!sku && appConfig?.TargetSku) {
155
- sku = appConfig.TargetSku;
156
- skuSource = 'app.config.json';
157
- }
158
- if (!sku && localSettings?.TargetSku) {
159
- sku = localSettings.TargetSku;
160
- skuSource = 'local.settings.json';
194
+ if (!sku && appCfg.sku) {
195
+ sku = appCfg.sku;
196
+ skuSource = appCfg.skuSource;
161
197
  }
162
198
  if (!sku) {
163
199
  sku = 'flex';
@@ -166,11 +202,6 @@ export async function main(args) {
166
202
  console.log(dim(`Tip: Use --sku <name> to target a specific SKU. Run --sku list to see options.\n`));
167
203
  }
168
204
 
169
- if (sku === 'list') {
170
- await listProfiles();
171
- return;
172
- }
173
-
174
205
  // 1. Resolve profile
175
206
  if (skuSource !== 'default') {
176
207
  console.log(title(`Resolving SKU profile: ${sku} (from ${skuSource})...`));
@@ -195,19 +226,6 @@ export async function main(args) {
195
226
  console.log(` ${dim('Profile Source:')} ${info(source)}`);
196
227
  console.log();
197
228
 
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
229
  // F9: .NET isolated worker only — block in-process projects with guidance
212
230
  const dotnetRuntimes = ['dotnet', 'dotnet-isolated'];
213
231
  if (dotnetRuntimes.includes(workerRuntime)) {
@@ -440,6 +458,7 @@ ${title('Commands:')}
440
458
  ${funcName('start')} Launch the Azure Functions host runtime for a specific SKU.
441
459
  ${funcName('sync')} Sync cached host/extensions with current catalog profile.
442
460
  ${funcName('pack')} Package a Functions app into a deployment zip.
461
+ ${funcName('config')} Show, validate, or migrate app configuration.
443
462
  ${funcName('warmup')} Pre-download host binaries and extension bundles.
444
463
  ${funcName('templates-mcp')} Start the Azure Functions templates MCP server (stdio).
445
464
 
@@ -604,3 +623,33 @@ ${title('VS Code Configuration:')}
604
623
  }
605
624
  }`.trim());
606
625
  }
626
+
627
+ function printConfigHelp() {
628
+ console.log(`
629
+ ${bold(title('fnx config'))} — Show, validate, or migrate app configuration.
630
+
631
+ ${title('Usage:')} fnx config [subcommand] [options]
632
+
633
+ ${title('Subcommands:')}
634
+ ${funcName('(none)')} Show resolved config with provenance (which file each value comes from).
635
+ ${funcName('migrate')} Create app-config.yaml from local.settings.json (extract non-secrets).
636
+ ${funcName('validate')} Validate app-config.yaml (schema, secrets, allowlist) without starting.
637
+
638
+ ${title('Options:')}
639
+ ${success('--app-path')} <dir> Path to the function app directory (default: cwd).
640
+ ${success('-h')}, ${success('--help')} Show this help message.
641
+
642
+ ${title('Configuration Files:')}
643
+ ${funcName('app-config.yaml')} Non-secret behavioral config (committed to source control).
644
+ Contains runtime, SKU target, scale settings, and app settings.
645
+ ${funcName('local.settings.json')} Secrets and connection strings (git-ignored).
646
+ Values here override app-config.yaml values.
647
+
648
+ ${title('Precedence:')} CLI flags > local.settings.json > app-config.yaml > defaults
649
+
650
+ ${title('Examples:')}
651
+ fnx config Show resolved config
652
+ fnx config migrate Create app-config.yaml from local.settings.json
653
+ fnx config validate Check app-config.yaml for errors
654
+ fnx config validate --app-path ./my-app Validate a specific app`.trim());
655
+ }
@@ -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
+ }
package/lib/config.js ADDED
@@ -0,0 +1,468 @@
1
+ // config.js — App config loader, validator, and auto-creator
2
+ //
3
+ // Reads app-config.yaml (primary) or auto-creates it from local.settings.json.
4
+ // Validates against config-schema.js, checks for secrets, manages .gitignore protection.
5
+
6
+ import { readFile, writeFile, access, readdir } from 'node:fs/promises';
7
+ import { join, resolve as resolvePath } from 'node:path';
8
+ import { createInterface } from 'node:readline';
9
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
10
+
11
+ import { resolveEnvVars, validateStructure, ALLOWED_CONFIGURATIONS, STRUCTURED_FIELDS } from './config-schema.js';
12
+ import { detectSecrets } from './secret-patterns.js';
13
+ import { error as errorColor, warning as warningColor, success as successColor, info as infoColor, dim } from './colors.js';
14
+
15
+ const APP_CONFIG_FILE = 'app-config.yaml';
16
+ const LEGACY_APP_CONFIG_FILE = 'app.config.json';
17
+ const LOCAL_SETTINGS_FILE = 'local.settings.json';
18
+
19
+ // ── Public API ──
20
+
21
+ /**
22
+ * Load and validate app configuration from the given app directory.
23
+ *
24
+ * Resolution order:
25
+ * 1. app-config.yaml exists → read, validate, return
26
+ * 2. app-config.yaml missing + local.settings.json exists → auto-create from local.settings.json
27
+ * 3. Neither exists → interactive prompt to generate
28
+ *
29
+ * Returns: { config, envVars, mergedValues, workerRuntime, sku, skuSource }
30
+ */
31
+ export async function loadConfig(appPath, opts = {}) {
32
+ const appConfigPath = join(appPath, APP_CONFIG_FILE);
33
+ const legacyConfigPath = join(appPath, LEGACY_APP_CONFIG_FILE);
34
+ const localSettingsPath = join(appPath, LOCAL_SETTINGS_FILE);
35
+
36
+ let config;
37
+ const hasAppConfig = await fileExists(appConfigPath);
38
+ const hasLegacyConfig = await fileExists(legacyConfigPath);
39
+ const hasLocalSettings = await fileExists(localSettingsPath);
40
+
41
+ if (hasAppConfig) {
42
+ // Case 1: app-config.yaml exists
43
+ config = await readAppConfig(appConfigPath);
44
+ } else if (hasLegacyConfig) {
45
+ // Case 1b: Legacy app.config.json — convert to structured format
46
+ config = await readLegacyConfig(legacyConfigPath);
47
+ } else if (hasLocalSettings) {
48
+ // Case 2: Auto-create from local.settings.json
49
+ config = await autoCreateFromLocalSettings(appPath, localSettingsPath);
50
+ } else {
51
+ // Case 3: Nothing exists — interactive or error
52
+ const isInteractive = process.stdin.isTTY && !opts.nonInteractive;
53
+ if (!isInteractive) {
54
+ console.error(errorColor('Error: No app-config.yaml or local.settings.json found.'));
55
+ console.error(dim(` Create app-config.yaml in ${appPath} or run fnx interactively.`));
56
+ process.exit(1);
57
+ }
58
+ config = await interactiveCreate(appPath);
59
+ }
60
+
61
+ // Validate structure
62
+ const { warnings } = validateStructure(config);
63
+ for (const w of warnings) {
64
+ console.error(warningColor(` ⚠ ${w}`));
65
+ }
66
+
67
+ // Secret detection
68
+ const secrets = detectSecrets(config);
69
+ if (secrets.length > 0) {
70
+ await addToGitignore(appPath, APP_CONFIG_FILE);
71
+ console.error(errorColor('\n ✗ Secrets detected in app-config.yaml:'));
72
+ for (const s of secrets) {
73
+ console.error(errorColor(` • ${s.path}: ${s.reason}`));
74
+ }
75
+ console.error(errorColor(`\n app-config.yaml has been added to .gitignore as a safety measure.`));
76
+ console.error(dim(` Move secrets to local.settings.json, then remove app-config.yaml from .gitignore.`));
77
+ process.exit(1);
78
+ }
79
+
80
+ // Ensure app-config.yaml is NOT in .gitignore (clean state)
81
+ await removeFromGitignore(appPath, APP_CONFIG_FILE);
82
+
83
+ // Resolve structured fields → env vars
84
+ const { envVars, errors } = resolveEnvVars(config);
85
+ if (errors.length > 0) {
86
+ for (const e of errors) {
87
+ console.error(errorColor(` ✗ ${e}`));
88
+ }
89
+ process.exit(1);
90
+ }
91
+
92
+ // Read local.settings.json for secrets/overrides
93
+ const localSettings = hasLocalSettings ? await readJsonFile(localSettingsPath) : null;
94
+ const localValues = localSettings?.Values || {};
95
+
96
+ // Merge: app-config.yaml envVars → local.settings.json overrides
97
+ const mergedValues = { ...envVars, ...localValues };
98
+
99
+ // Resolve worker runtime
100
+ const workerRuntime = mergedValues.FUNCTIONS_WORKER_RUNTIME;
101
+
102
+ // Resolve SKU: config.local.targetSku (CLI flag handled by caller)
103
+ const sku = config.local?.targetSku || null;
104
+ const skuSource = sku ? 'app-config.yaml' : null;
105
+
106
+ return {
107
+ config,
108
+ envVars,
109
+ mergedValues,
110
+ workerRuntime,
111
+ sku,
112
+ skuSource,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Migrate local.settings.json → app-config.yaml (standalone command).
118
+ * Returns the generated config object.
119
+ */
120
+ export async function migrateConfig(appPath) {
121
+ const appConfigPath = join(appPath, APP_CONFIG_FILE);
122
+ const localSettingsPath = join(appPath, LOCAL_SETTINGS_FILE);
123
+
124
+ if (await fileExists(appConfigPath)) {
125
+ console.log(infoColor(` app-config.yaml already exists at ${appConfigPath}`));
126
+ return readAppConfig(appConfigPath);
127
+ }
128
+
129
+ if (!await fileExists(localSettingsPath)) {
130
+ console.error(errorColor(` No local.settings.json found at ${localSettingsPath}`));
131
+ process.exit(1);
132
+ }
133
+
134
+ return autoCreateFromLocalSettings(appPath, localSettingsPath);
135
+ }
136
+
137
+ /**
138
+ * Validate app-config.yaml (standalone command).
139
+ * Returns { valid, errors, warnings, secrets }.
140
+ */
141
+ export async function validateConfig(appPath) {
142
+ const appConfigPath = join(appPath, APP_CONFIG_FILE);
143
+
144
+ if (!await fileExists(appConfigPath)) {
145
+ return { valid: false, errors: ['app-config.yaml not found'], warnings: [], secrets: [] };
146
+ }
147
+
148
+ const config = await readAppConfig(appConfigPath);
149
+ const { warnings } = validateStructure(config);
150
+ const { errors } = resolveEnvVars(config);
151
+ const secrets = detectSecrets(config);
152
+
153
+ return {
154
+ valid: errors.length === 0 && secrets.length === 0,
155
+ errors,
156
+ warnings,
157
+ secrets,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Show resolved config with provenance (standalone command).
163
+ */
164
+ export async function showResolvedConfig(appPath) {
165
+ const appConfigPath = join(appPath, APP_CONFIG_FILE);
166
+ const localSettingsPath = join(appPath, LOCAL_SETTINGS_FILE);
167
+
168
+ const hasAppConfig = await fileExists(appConfigPath);
169
+ const hasLocalSettings = await fileExists(localSettingsPath);
170
+
171
+ if (!hasAppConfig && !hasLocalSettings) {
172
+ console.error(errorColor(' No configuration files found.'));
173
+ return;
174
+ }
175
+
176
+ let appConfigEnvVars = {};
177
+ if (hasAppConfig) {
178
+ const config = await readAppConfig(appConfigPath);
179
+ const result = resolveEnvVars(config);
180
+ appConfigEnvVars = result.envVars;
181
+ }
182
+
183
+ const localSettings = hasLocalSettings ? await readJsonFile(localSettingsPath) : null;
184
+ const localValues = localSettings?.Values || {};
185
+
186
+ // Sensitive keys to redact
187
+ const sensitiveKeys = ['AzureWebJobsStorage', 'EventHubConnectionString', 'ServiceBusConnectionString'];
188
+
189
+ console.log(infoColor('\n Resolved Configuration:\n'));
190
+ console.log(dim(' Source precedence: CLI flags > local.settings.json > app-config.yaml\n'));
191
+
192
+ const merged = { ...appConfigEnvVars, ...localValues };
193
+ const maxKeyLen = Math.max(...Object.keys(merged).map(k => k.length), 10);
194
+
195
+ for (const [key, value] of Object.entries(merged).sort(([a], [b]) => a.localeCompare(b))) {
196
+ const source = (key in localValues) ? 'local.settings.json' : 'app-config.yaml';
197
+ const isSecret = sensitiveKeys.some(sk => key.includes(sk)) && value !== 'UseDevelopmentStorage=true';
198
+ const displayValue = isSecret ? '***REDACTED***' : value;
199
+ console.log(` ${key.padEnd(maxKeyLen)} ${dim(displayValue)} ${dim(`← ${source}`)}`);
200
+ }
201
+ console.log('');
202
+ }
203
+
204
+ // ── Internal helpers ──
205
+
206
+ async function readAppConfig(filePath) {
207
+ const raw = await readFile(filePath, 'utf-8');
208
+ const config = parseYaml(raw);
209
+ if (!config || typeof config !== 'object') {
210
+ console.error(errorColor(` ✗ app-config.yaml is empty or invalid YAML`));
211
+ process.exit(1);
212
+ }
213
+ return config;
214
+ }
215
+
216
+ /**
217
+ * Read legacy app.config.json and convert to structured config format.
218
+ * This provides backward compatibility during the transition to app-config.yaml.
219
+ */
220
+ async function readLegacyConfig(filePath) {
221
+ const legacy = await readJsonFile(filePath);
222
+ if (!legacy) {
223
+ console.error(errorColor(` ✗ app.config.json is empty or invalid JSON`));
224
+ process.exit(1);
225
+ }
226
+
227
+ const config = {};
228
+
229
+ // Map TargetSku → local.targetSku
230
+ if (legacy.TargetSku) {
231
+ config.local = { targetSku: legacy.TargetSku };
232
+ }
233
+
234
+ // Map Values.FUNCTIONS_WORKER_RUNTIME → runtime.name
235
+ const values = legacy.Values || {};
236
+ if (values.FUNCTIONS_WORKER_RUNTIME) {
237
+ config.runtime = { name: values.FUNCTIONS_WORKER_RUNTIME };
238
+ if (values.FUNCTIONS_WORKER_RUNTIME_VERSION) {
239
+ config.runtime.version = values.FUNCTIONS_WORKER_RUNTIME_VERSION;
240
+ }
241
+ }
242
+
243
+ // Remaining values → configurations (skip structured fields)
244
+ const structuredEnvVars = new Set(['FUNCTIONS_WORKER_RUNTIME', 'FUNCTIONS_WORKER_RUNTIME_VERSION']);
245
+ const configEntries = {};
246
+ for (const [key, value] of Object.entries(values)) {
247
+ if (!structuredEnvVars.has(key)) {
248
+ configEntries[key] = value;
249
+ }
250
+ }
251
+ if (Object.keys(configEntries).length > 0) {
252
+ config.configurations = configEntries;
253
+ }
254
+
255
+ return config;
256
+ }
257
+
258
+ async function readJsonFile(filePath) {
259
+ try {
260
+ return JSON.parse(await readFile(filePath, 'utf-8'));
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ async function autoCreateFromLocalSettings(appPath, localSettingsPath) {
267
+ const localSettings = await readJsonFile(localSettingsPath);
268
+ if (!localSettings?.Values) {
269
+ console.error(errorColor(' local.settings.json has no Values section.'));
270
+ process.exit(1);
271
+ }
272
+
273
+ const values = localSettings.Values;
274
+ const secrets = detectSecretsInFlatValues(values);
275
+ const nonSecretKeys = Object.keys(values).filter(k => !secrets.has(k));
276
+
277
+ // Build structured config
278
+ const config = {};
279
+
280
+ // Extract runtime
281
+ const runtime = values.FUNCTIONS_WORKER_RUNTIME;
282
+ if (runtime) {
283
+ config.runtime = { name: runtime };
284
+ // Check for runtime version in env or values
285
+ const version = values.FUNCTIONS_WORKER_RUNTIME_VERSION;
286
+ if (version) config.runtime.version = version;
287
+ }
288
+
289
+ // Extract targetSku from local settings (non-standard but some users set it)
290
+ if (localSettings.TargetSku) {
291
+ config.local = { targetSku: localSettings.TargetSku };
292
+ }
293
+
294
+ // Remaining non-secret, non-structured values → configurations
295
+ const structuredEnvVars = new Set(Object.values(STRUCTURED_FIELDS).map(s => s.envVar));
296
+ const configEntries = {};
297
+ for (const key of nonSecretKeys) {
298
+ if (structuredEnvVars.has(key)) continue; // Already mapped structurally
299
+ if (ALLOWED_CONFIGURATIONS.has(key)) {
300
+ configEntries[key] = values[key];
301
+ }
302
+ }
303
+ if (Object.keys(configEntries).length > 0) {
304
+ config.configurations = configEntries;
305
+ }
306
+
307
+ // Write app-config.yaml
308
+ const yaml = generateYaml(config);
309
+ const appConfigPath = join(appPath, APP_CONFIG_FILE);
310
+ await writeFile(appConfigPath, yaml, 'utf-8');
311
+
312
+ console.log(successColor(` ✓ Created app-config.yaml from local.settings.json (non-secret settings extracted)`));
313
+ if (secrets.size > 0) {
314
+ console.log(dim(` ${secrets.size} secret(s) left in local.settings.json: ${[...secrets].join(', ')}`));
315
+ }
316
+
317
+ return config;
318
+ }
319
+
320
+ async function interactiveCreate(appPath) {
321
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
322
+ const ask = (q) => new Promise(r => rl.question(q, r));
323
+
324
+ console.log(warningColor('\n No configuration files found.\n'));
325
+
326
+ const proceed = await ask(' Generate app-config.yaml? [Y/n] ');
327
+ if (proceed.toLowerCase() === 'n') {
328
+ rl.close();
329
+ console.error(errorColor(' Cannot proceed without configuration.'));
330
+ process.exit(1);
331
+ }
332
+
333
+ console.log(dim(' Select your runtime:'));
334
+ console.log(dim(' 1) node'));
335
+ console.log(dim(' 2) python'));
336
+ console.log(dim(' 3) dotnet-isolated'));
337
+ console.log(dim(' 4) java'));
338
+ console.log(dim(' 5) powershell'));
339
+
340
+ const choice = await ask(' Runtime [1-5]: ');
341
+ const runtimes = { '1': 'node', '2': 'python', '3': 'dotnet-isolated', '4': 'java', '5': 'powershell' };
342
+ const runtime = runtimes[choice.trim()] || 'node';
343
+
344
+ rl.close();
345
+
346
+ const config = {
347
+ local: { targetSku: 'flex' },
348
+ runtime: { name: runtime },
349
+ configurations: {
350
+ AzureWebJobsFeatureFlags: 'EnableWorkerIndexing',
351
+ },
352
+ };
353
+
354
+ // Write app-config.yaml
355
+ const yaml = generateYaml(config);
356
+ await writeFile(join(appPath, APP_CONFIG_FILE), yaml, 'utf-8');
357
+ console.log(successColor(` ✓ Created app-config.yaml`));
358
+
359
+ // Also create local.settings.json with Azurite
360
+ const localSettingsPath = join(appPath, LOCAL_SETTINGS_FILE);
361
+ if (!await fileExists(localSettingsPath)) {
362
+ const localSettings = {
363
+ IsEncrypted: false,
364
+ Values: {
365
+ AzureWebJobsStorage: 'UseDevelopmentStorage=true',
366
+ },
367
+ };
368
+ await writeFile(localSettingsPath, JSON.stringify(localSettings, null, 2) + '\n', 'utf-8');
369
+ console.log(successColor(` ✓ Created local.settings.json (with Azurite storage)`));
370
+ }
371
+
372
+ return config;
373
+ }
374
+
375
+ function generateYaml(config) {
376
+ const lines = [
377
+ '# Azure Functions App Configuration',
378
+ '# Commit this to source control. Do NOT put secrets here.',
379
+ '# Secrets and connection strings go in local.settings.json (git-ignored).',
380
+ '#',
381
+ '# Reference: https://learn.microsoft.com/en-us/azure/azure-functions/functions-app-settings',
382
+ '',
383
+ ];
384
+
385
+ if (config.local) {
386
+ lines.push('# Local emulator (fnx) settings');
387
+ lines.push(stringifyYaml({ local: config.local }).trim());
388
+ lines.push('');
389
+ }
390
+
391
+ if (config.runtime) {
392
+ lines.push('# Runtime configuration');
393
+ lines.push(stringifyYaml({ runtime: config.runtime }).trim());
394
+ lines.push('');
395
+ }
396
+
397
+ if (config.scaleAndConcurrency) {
398
+ lines.push('# Scale & concurrency (mirrors ARM functionAppConfig)');
399
+ lines.push(stringifyYaml({ scaleAndConcurrency: config.scaleAndConcurrency }).trim());
400
+ lines.push('');
401
+ }
402
+
403
+ if (config.configurations) {
404
+ lines.push('# App settings (non-secret behavioral config)');
405
+ lines.push(stringifyYaml({ configurations: config.configurations }).trim());
406
+ lines.push('');
407
+ }
408
+
409
+ return lines.join('\n') + '\n';
410
+ }
411
+
412
+ /**
413
+ * Detect which keys in a flat values map are secrets (for migration).
414
+ * Returns a Set of secret key names.
415
+ */
416
+ function detectSecretsInFlatValues(values) {
417
+ const secretKeys = new Set();
418
+ const secretKeyPatterns = [
419
+ /ConnectionString$/i,
420
+ /^AzureWebJobsStorage$/i,
421
+ /^APPINSIGHTS_INSTRUMENTATIONKEY$/i,
422
+ /^APPLICATIONINSIGHTS_CONNECTION_STRING$/i,
423
+ /Password$/i, /Secret$/i, /ApiKey$/i, /Token$/i,
424
+ ];
425
+ const secretValuePatterns = [
426
+ /DefaultEndpointsProtocol\s*=/i,
427
+ /AccountKey\s*=/i,
428
+ /SharedAccessSignature\s*=/i,
429
+ /Endpoint\s*=\s*sb:\/\//i,
430
+ ];
431
+
432
+ for (const [key, value] of Object.entries(values)) {
433
+ const strValue = String(value);
434
+ // Azurite marker is not a secret
435
+ if (strValue === 'UseDevelopmentStorage=true') continue;
436
+
437
+ if (secretKeyPatterns.some(p => p.test(key))) { secretKeys.add(key); continue; }
438
+ if (secretValuePatterns.some(p => p.test(strValue))) { secretKeys.add(key); continue; }
439
+ }
440
+ return secretKeys;
441
+ }
442
+
443
+ async function fileExists(filePath) {
444
+ try { await access(filePath); return true; } catch { return false; }
445
+ }
446
+
447
+ async function addToGitignore(appPath, filename) {
448
+ const gitignorePath = join(appPath, '.gitignore');
449
+ try {
450
+ const content = await readFile(gitignorePath, 'utf-8');
451
+ if (content.includes(filename)) return; // Already there
452
+ await writeFile(gitignorePath, content.trimEnd() + '\n' + filename + '\n', 'utf-8');
453
+ } catch {
454
+ await writeFile(gitignorePath, filename + '\n', 'utf-8');
455
+ }
456
+ }
457
+
458
+ async function removeFromGitignore(appPath, filename) {
459
+ const gitignorePath = join(appPath, '.gitignore');
460
+ try {
461
+ const content = await readFile(gitignorePath, 'utf-8');
462
+ if (!content.includes(filename)) return;
463
+ const lines = content.split('\n').filter(l => l.trim() !== filename);
464
+ await writeFile(gitignorePath, lines.join('\n'), 'utf-8');
465
+ } catch {
466
+ // No .gitignore — nothing to do
467
+ }
468
+ }
@@ -54,7 +54,7 @@ export function createHostState() {
54
54
  // ─── Python executable detection ────────────────────────────────────────
55
55
  // The .NET host needs a compatible Python version. The host's bundled worker
56
56
  // supports up to 3.13 (3.14 is unsupported). We check:
57
- // 1. Explicit config (app.config.json "PythonPath")
57
+ // 1. Explicit config (app-config.yaml PythonPath or env var)
58
58
  // 2. .venv in the script root
59
59
  // 3. System python3.13 → python3.12 → python3.11 → python3 → python
60
60
  // This mirrors Core Tools behavior which also searches versioned binaries.
@@ -62,7 +62,7 @@ export function createHostState() {
62
62
  const SUPPORTED_PYTHON_VERSIONS = ['3.13', '3.12', '3.11', '3.10', '3.9'];
63
63
 
64
64
  function findPythonExecutable(scriptRoot, explicitPath) {
65
- // 0. Explicit path from config (app.config.json "PythonPath" or env var)
65
+ // 0. Explicit path from config (app-config.yaml PythonPath or env var)
66
66
  if (explicitPath) {
67
67
  if (existsSync(explicitPath)) return explicitPath;
68
68
  // Maybe it's a command name on PATH
@@ -361,7 +361,7 @@ export async function launchHost(hostDir, opts) {
361
361
  } catch { /* non-fatal */ }
362
362
  } else {
363
363
  console.error(warning('⚠️ Python runtime requested but no compatible python (3.9-3.13) found.'));
364
- console.error(dim(' Set "PythonPath" in app.config.json or FNX_PYTHON_PATH env var.'));
364
+ console.error(dim(' Set "PythonPath" in app-config.yaml configurations or FNX_PYTHON_PATH env var.'));
365
365
  }
366
366
  }
367
367
 
@@ -200,7 +200,7 @@ Only HTTP functions can be invoked. For non-HTTP, upload data to the trigger sou
200
200
  'get_app_settings',
201
201
  {
202
202
  title: 'Get App Settings',
203
- description: `Get merged app settings (app.config.json + local.settings.json) with secrets redacted.
203
+ description: `Get merged app settings (app-config.yaml + local.settings.json) with secrets redacted.
204
204
  Shows environment variables injected into the host process.`,
205
205
  inputSchema: z.object({}),
206
206
  },
package/lib/pack.js CHANGED
@@ -90,21 +90,39 @@ async function stageDotnetIsolatedBuild(scriptRoot, tempRoot) {
90
90
  }
91
91
 
92
92
  export async function detectRuntimeFromConfig(scriptRoot) {
93
- const appConfigPath = resolvePath(scriptRoot, 'app.config.json');
94
- const localSettingsPath = resolvePath(scriptRoot, 'local.settings.json');
93
+ // Try app-config.yaml first (new format), then fall back to app.config.json (legacy)
94
+ const { parse: parseYaml } = await import('yaml');
95
95
 
96
- const parseIfExists = async (filePath) => {
97
- try {
98
- const raw = await readFile(filePath, 'utf-8');
99
- return JSON.parse(raw);
100
- } catch {
101
- return null;
102
- }
96
+ const tryRead = async (filePath) => {
97
+ try { return await readFile(filePath, 'utf-8'); } catch { return null; }
103
98
  };
104
99
 
105
- const appConfig = await parseIfExists(appConfigPath);
106
- const localSettings = await parseIfExists(localSettingsPath);
107
- return appConfig?.Values?.FUNCTIONS_WORKER_RUNTIME || localSettings?.Values?.FUNCTIONS_WORKER_RUNTIME || null;
100
+ // app-config.yaml: runtime.name
101
+ const yamlContent = await tryRead(resolvePath(scriptRoot, 'app-config.yaml'));
102
+ if (yamlContent) {
103
+ const config = parseYaml(yamlContent);
104
+ if (config?.runtime?.name) return config.runtime.name;
105
+ }
106
+
107
+ // Legacy app.config.json: Values.FUNCTIONS_WORKER_RUNTIME
108
+ const jsonContent = await tryRead(resolvePath(scriptRoot, 'app.config.json'));
109
+ if (jsonContent) {
110
+ try {
111
+ const config = JSON.parse(jsonContent);
112
+ if (config?.Values?.FUNCTIONS_WORKER_RUNTIME) return config.Values.FUNCTIONS_WORKER_RUNTIME;
113
+ } catch { /* ignore */ }
114
+ }
115
+
116
+ // local.settings.json fallback
117
+ const localContent = await tryRead(resolvePath(scriptRoot, 'local.settings.json'));
118
+ if (localContent) {
119
+ try {
120
+ const config = JSON.parse(localContent);
121
+ if (config?.Values?.FUNCTIONS_WORKER_RUNTIME) return config.Values.FUNCTIONS_WORKER_RUNTIME;
122
+ } catch { /* ignore */ }
123
+ }
124
+
125
+ return null;
108
126
  }
109
127
 
110
128
  export async function packFunctionApp({ scriptRoot, runtime, outputPath, noBuild = false }) {
@@ -0,0 +1,108 @@
1
+ // secret-patterns.js — Heuristics for detecting secrets in app-config.yaml values
2
+ //
3
+ // If any value matches these patterns, app-config.yaml is added to .gitignore
4
+ // and fnx operations are blocked until the secret is moved to local.settings.json.
5
+
6
+ // ── Connection string patterns ──
7
+
8
+ const CONNECTION_STRING_PATTERNS = [
9
+ /DefaultEndpointsProtocol\s*=/i,
10
+ /AccountKey\s*=/i,
11
+ /SharedAccessSignature\s*=/i,
12
+ /Endpoint\s*=\s*sb:\/\//i, // Service Bus
13
+ /Endpoint\s*=\s*https?:\/\//i, // Generic endpoint-based
14
+ /Data Source\s*=.*Password\s*=/i, // SQL connection string
15
+ /Server\s*=.*User Id\s*=/i, // SQL alternate format
16
+ /mongodb(\+srv)?:\/\//i, // MongoDB
17
+ /redis:\/\//i, // Redis
18
+ /amqps?:\/\//i, // AMQP (Event Hub, RabbitMQ)
19
+ ];
20
+
21
+ // ── Key name patterns (these setting NAMES typically hold secrets) ──
22
+
23
+ const SECRET_KEY_NAMES = [
24
+ /ConnectionString$/i,
25
+ /^AzureWebJobsStorage$/i,
26
+ /^APPINSIGHTS_INSTRUMENTATIONKEY$/i,
27
+ /^APPLICATIONINSIGHTS_CONNECTION_STRING$/i,
28
+ /StorageConnectionString/i,
29
+ /EventHubConnectionString/i,
30
+ /ServiceBusConnectionString/i,
31
+ /^AzureWebJobsSecretStorage/i,
32
+ /SecretStorageKeyVault/i,
33
+ /Password$/i,
34
+ /Secret$/i,
35
+ /ApiKey$/i,
36
+ /Token$/i,
37
+ /^SAS_/i,
38
+ ];
39
+
40
+ // ── Value patterns (values that look like secrets regardless of key name) ──
41
+
42
+ const SECRET_VALUE_PATTERNS = [
43
+ /^sig=[A-Za-z0-9%+/=]{20,}/, // SAS token signature
44
+ /^Bearer\s+[A-Za-z0-9._-]{20,}/, // Bearer token
45
+ /^ey[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/, // JWT
46
+ ];
47
+
48
+ // Special case: "UseDevelopmentStorage=true" is NOT a secret
49
+ const AZURITE_VALUE = 'UseDevelopmentStorage=true';
50
+
51
+ /**
52
+ * Scan all values in the config for secrets.
53
+ * Returns array of { path, reason } for each detected secret.
54
+ */
55
+ export function detectSecrets(config) {
56
+ const findings = [];
57
+
58
+ // Scan configurations.* section
59
+ if (config.configurations && typeof config.configurations === 'object') {
60
+ for (const [key, value] of Object.entries(config.configurations)) {
61
+ const strValue = String(value);
62
+
63
+ // Skip Azurite marker — it's not a real secret
64
+ if (strValue === AZURITE_VALUE) continue;
65
+
66
+ // Check key name
67
+ for (const pattern of SECRET_KEY_NAMES) {
68
+ if (pattern.test(key)) {
69
+ findings.push({
70
+ path: `configurations.${key}`,
71
+ reason: `Key name "${key}" typically holds secrets. Move to local.settings.json.`,
72
+ });
73
+ break;
74
+ }
75
+ }
76
+
77
+ // Check value for connection string patterns
78
+ for (const pattern of CONNECTION_STRING_PATTERNS) {
79
+ if (pattern.test(strValue)) {
80
+ findings.push({
81
+ path: `configurations.${key}`,
82
+ reason: `Value looks like a connection string. Move to local.settings.json.`,
83
+ });
84
+ break;
85
+ }
86
+ }
87
+
88
+ // Check value patterns
89
+ for (const pattern of SECRET_VALUE_PATTERNS) {
90
+ if (pattern.test(strValue)) {
91
+ findings.push({
92
+ path: `configurations.${key}`,
93
+ reason: `Value looks like a token or credential. Move to local.settings.json.`,
94
+ });
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ // Deduplicate by path (a value might match both key name and value patterns)
102
+ const seen = new Set();
103
+ return findings.filter(f => {
104
+ if (seen.has(f.path)) return false;
105
+ seen.add(f.path);
106
+ return true;
107
+ });
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrdmr/fnx-test",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "SKU-aware Azure Functions local emulator",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.26.0",
37
+ "yaml": "^2.8.2",
37
38
  "zod": "^4.3.6"
38
39
  }
39
40
  }