@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 +80 -31
- package/lib/config-schema.js +167 -0
- package/lib/config.js +468 -0
- package/lib/host-launcher.js +3 -3
- package/lib/live-mcp-server.js +1 -1
- package/lib/pack.js +30 -12
- package/lib/secret-patterns.js +108 -0
- package/package.json +2 -1
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
|
-
//
|
|
147
|
-
const
|
|
148
|
-
const
|
|
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
|
|
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 &&
|
|
155
|
-
sku =
|
|
156
|
-
skuSource =
|
|
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
|
+
}
|
package/lib/host-launcher.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
364
|
+
console.error(dim(' Set "PythonPath" in app-config.yaml configurations or FNX_PYTHON_PATH env var.'));
|
|
365
365
|
}
|
|
366
366
|
}
|
|
367
367
|
|
package/lib/live-mcp-server.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
94
|
-
const
|
|
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
|
|
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
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
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
|
+
"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
|
}
|