@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.
@@ -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
- await printHelpWithVersionInfo();
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
- // 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'));
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
- // Resolve SKU: CLI flag > app.config.json > local.settings.json > default "flex"
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 && 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';
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
+ }