@vrdmr/fnx-test 0.2.0 → 0.3.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
@@ -1,12 +1,20 @@
1
1
  import { resolve as resolvePath, dirname, join } from 'node:path';
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
3
4
  import { createServer } from 'node:net';
5
+ import { homedir } from 'node:os';
4
6
  import { fileURLToPath } from 'node:url';
5
- import { resolveProfile, listProfiles, setProfilesSource } from './profile-resolver.js';
6
- import { ensureHost, ensureBundle } from './host-manager.js';
7
+ import { resolveProfile, listProfiles, setProfilesSource, fetchRegistryWithMeta, readProfilesSync } from './profile-resolver.js';
8
+ import { ensureHost, ensureBundle, getCachedHostVersions, getCachedBundleVersions, compareVersions, DEFAULT_KEEP_VERSIONS } from './host-manager.js';
7
9
  import { launchHost, createHostState } from './host-launcher.js';
8
10
  import { startLiveMcpServer } from './live-mcp-server.js';
9
11
  import { detectDotnetModel, printInProcessError } from './dotnet-detector.js';
12
+ import { detectRuntimeFromConfig, packFunctionApp } from './pack.js';
13
+ import { title, info, funcName, url as urlColor, success, error as errorColor, warning, dim, bold, highlightUrls } from './colors.js';
14
+
15
+ const FNX_HOME = join(homedir(), '.fnx');
16
+ const VERSION_CHECK_FILE = join(FNX_HOME, 'version-check.json');
17
+ const VERSION_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
10
18
 
11
19
  function isPortFree(port) {
12
20
  return new Promise((resolve) => {
@@ -24,46 +32,105 @@ async function findOpenPort(start, maxRetries = 10) {
24
32
  return start; // fall through — let the host report the error
25
33
  }
26
34
 
35
+ function hasHelp(args) {
36
+ return args.includes('-h') || args.includes('--help');
37
+ }
38
+
39
+ /**
40
+ * Resolve the function app directory.
41
+ * 1. If --app-path is given, use it — but verify host.json exists.
42
+ * 2. Otherwise check cwd for host.json.
43
+ * 3. Fall back to cwd/src if it contains host.json.
44
+ * 4. Error with actionable message if nothing found.
45
+ */
46
+ function resolveAppPath(args) {
47
+ const explicit = getFlag(args, '--app-path');
48
+ if (explicit) {
49
+ const resolved = resolvePath(explicit);
50
+ if (!existsSync(join(resolved, 'host.json'))) {
51
+ console.error(errorColor(`Error: No host.json found in ${resolved}`));
52
+ console.error(` The --app-path must point to a directory containing host.json.`);
53
+ console.error(dim(` Example: fnx start --app-path ./my-function-app`));
54
+ process.exit(1);
55
+ }
56
+ return resolved;
57
+ }
58
+
59
+ const cwd = process.cwd();
60
+ if (existsSync(join(cwd, 'host.json'))) {
61
+ return cwd;
62
+ }
63
+
64
+ const srcDir = join(cwd, 'src');
65
+ if (existsSync(join(srcDir, 'host.json'))) {
66
+ console.log(info(` Using function app at ${dim('./src')} (found host.json there)`));
67
+ return srcDir;
68
+ }
69
+
70
+ console.error(errorColor(`Error: No function app found.`));
71
+ console.error(` Could not find host.json in the current directory or ./src.`);
72
+ console.error(dim(` Use --app-path <dir> to specify the function app location.`));
73
+ process.exit(1);
74
+ }
75
+
27
76
  export async function main(args) {
28
77
  const cmd = args[0];
29
78
 
30
79
  if (cmd === '-h' || cmd === '--help' || cmd === 'help' || !cmd) {
31
- printHelp();
80
+ await printHelpWithVersionInfo();
32
81
  process.exit(cmd ? 0 : 1);
33
82
  }
34
83
 
35
84
  if (cmd === '-v' || cmd === '--version') {
36
- const { readFileSync } = await import('node:fs');
37
- const { fileURLToPath } = await import('node:url');
38
- const { dirname, join } = await import('node:path');
39
- const dir = dirname(fileURLToPath(import.meta.url));
40
- const pkg = JSON.parse(readFileSync(join(dir, '..', 'package.json'), 'utf-8'));
41
- console.log(`fnx v${pkg.version}`);
85
+ const pkg = await getFnxPackage();
86
+ console.log(title(`fnx v${pkg.version}`));
42
87
  process.exit(0);
43
88
  }
44
89
 
45
90
  if (cmd === 'templates-mcp') {
91
+ if (hasHelp(args.slice(1))) { printTemplatesMcpHelp(); return; }
46
92
  await startTemplatesMcp();
47
93
  return;
48
94
  }
49
95
 
50
96
  if (cmd === 'warmup') {
97
+ if (hasHelp(args.slice(1))) { printWarmupHelp(); return; }
51
98
  const { warmup } = await import('./warmup.js');
52
99
  await warmup(args.slice(1));
53
100
  return;
54
101
  }
55
102
 
103
+ if (cmd === 'sync') {
104
+ if (hasHelp(args.slice(1))) { printSyncHelp(); return; }
105
+ await runSync(args.slice(1));
106
+ return;
107
+ }
108
+
109
+ if (cmd === 'pack') {
110
+ if (hasHelp(args.slice(1))) { printPackHelp(); return; }
111
+ const scriptRoot = resolveAppPath(args);
112
+ const runtime = getFlag(args, '--runtime') || await detectRuntimeFromConfig(scriptRoot);
113
+ const outputPath = getFlag(args, '--output');
114
+ const noBuild = args.includes('--no-build');
115
+ await packFunctionApp({ scriptRoot, runtime, outputPath, noBuild });
116
+ return;
117
+ }
118
+
56
119
  if (cmd !== 'start') {
57
- console.error(`Unknown command: ${cmd}\n`);
120
+ console.error(errorColor(`Unknown command: ${cmd}\n`));
58
121
  printHelp();
59
122
  process.exit(1);
60
123
  }
61
124
 
62
- const scriptRoot = getFlag(args, '--scriptroot') || process.cwd();
125
+ if (hasHelp(args.slice(1))) { printStartHelp(); return; }
126
+
127
+ await maybeWarnForCliUpgrade();
128
+
129
+ const scriptRoot = resolveAppPath(args);
63
130
  const requestedPort = parseInt(getFlag(args, '--port') || '7071');
64
131
  const port = await findOpenPort(requestedPort);
65
132
  if (port !== requestedPort) {
66
- console.log(` Port ${requestedPort} in use, using ${port} instead.`);
133
+ console.log(warning(` Port ${requestedPort} in use, using ${port} instead.`));
67
134
  }
68
135
  const mcpPort = getFlag(args, '--mcp-port') || String(port + 1);
69
136
  const verbose = args.includes('--verbose');
@@ -95,8 +162,8 @@ export async function main(args) {
95
162
  if (!sku) {
96
163
  sku = 'flex';
97
164
  skuSource = 'default';
98
- console.log(`No --sku specified, defaulting to '${sku}'.`);
99
- console.log(`Tip: Use --sku <name> to target a specific SKU. Run --sku list to see options.\n`);
165
+ console.log(info(`No --sku specified, defaulting to '${sku}'.`));
166
+ console.log(dim(`Tip: Use --sku <name> to target a specific SKU. Run --sku list to see options.\n`));
100
167
  }
101
168
 
102
169
  if (sku === 'list') {
@@ -106,33 +173,29 @@ export async function main(args) {
106
173
 
107
174
  // 1. Resolve profile
108
175
  if (skuSource !== 'default') {
109
- console.log(`Resolving SKU profile: ${sku} (from ${skuSource})...`);
176
+ console.log(title(`Resolving SKU profile: ${sku} (from ${skuSource})...`));
110
177
  } else {
111
- console.log(`Resolving SKU profile: ${sku}...`);
178
+ console.log(title(`Resolving SKU profile: ${sku}...`));
112
179
  }
113
- const profile = await resolveProfile(sku);
114
- console.log(` Target SKU: ${profile.displayName}`);
115
- console.log(` Host Version: ${profile.hostVersion}`);
116
- console.log(` Extension Bundle: ${profile.extensionBundleVersion}`);
117
- if (profile.maxExtensionBundleVersion) {
118
- console.log(` Max Bundle Cap: ${profile.maxExtensionBundleVersion}`);
119
- }
120
- console.log();
121
180
 
122
- // 2. Ensure host is downloaded
123
- const hostDir = await ensureHost(profile);
124
- console.log(` Host path: ${hostDir}`);
181
+ const { registry, source } = await fetchRegistryWithMeta();
182
+ const profile = registry.profiles[sku];
183
+ if (!profile) {
184
+ const valid = Object.keys(registry.profiles).join(', ');
185
+ throw new Error(`Unknown SKU '${sku}'. Available: ${valid}`);
186
+ }
125
187
 
126
- // 3. Pre-download the correct extension bundle for this SKU
127
- // This resolves the exact version from CDN index, capped by maxExtensionBundleVersion,
128
- // and downloads it so the host finds it cached and never fetches a wrong version.
129
- const resolvedBundleVersion = await ensureBundle(profile);
130
- if (resolvedBundleVersion) {
131
- console.log(` Bundle resolved: ${resolvedBundleVersion}`);
188
+ profile.name = sku;
189
+ console.log(` ${dim('Target SKU:')} ${info(profile.displayName)}`);
190
+ console.log(` ${dim('Host Version:')} ${info(profile.hostVersion)}`);
191
+ console.log(` ${dim('Extension Bundle:')} ${info(profile.extensionBundleVersion)}`);
192
+ if (profile.maxExtensionBundleVersion) {
193
+ console.log(` ${dim('Max Bundle Cap:')} ${info(profile.maxExtensionBundleVersion)}`);
132
194
  }
195
+ console.log(` ${dim('Profile Source:')} ${info(source)}`);
133
196
  console.log();
134
197
 
135
- // 4. Merge config: app.config.json Values + local.settings.json Values
198
+ // Early validation: merge config and check runtime before downloading anything
136
199
  const mergedValues = {
137
200
  ...(appConfig?.Values || {}),
138
201
  ...(localSettings?.Values || {}),
@@ -141,7 +204,7 @@ export async function main(args) {
141
204
  const workerRuntime = mergedValues.FUNCTIONS_WORKER_RUNTIME;
142
205
 
143
206
  if (!workerRuntime) {
144
- console.error('Error: FUNCTIONS_WORKER_RUNTIME not set in app.config.json or local.settings.json');
207
+ console.error(errorColor('Error: FUNCTIONS_WORKER_RUNTIME not set in app.config.json or local.settings.json'));
145
208
  process.exit(1);
146
209
  }
147
210
 
@@ -155,15 +218,30 @@ export async function main(args) {
155
218
  }
156
219
  }
157
220
 
158
- // 5. Create shared host state and start live MCP server
221
+ printHostDriftWarning(profile.hostVersion);
222
+
223
+ // 2. Ensure host is downloaded
224
+ const hostDir = await ensureHost(profile, { keepVersions: DEFAULT_KEEP_VERSIONS });
225
+ console.log(` ${dim('Host path:')} ${info(hostDir)}`);
226
+
227
+ // 3. Pre-download the correct extension bundle for this SKU
228
+ // This resolves the exact version from CDN index, capped by maxExtensionBundleVersion,
229
+ // and downloads it so the host finds it cached and never fetches a wrong version.
230
+ const resolvedBundleVersion = await ensureBundle(profile, { keepVersions: DEFAULT_KEEP_VERSIONS });
231
+ if (resolvedBundleVersion) {
232
+ console.log(` ${dim('Bundle resolved:')} ${info(resolvedBundleVersion)}`);
233
+ }
234
+ console.log();
235
+
236
+ // 4. Create shared host state and start live MCP server
159
237
  const hostState = createHostState();
160
238
 
161
239
  if (!noMcp) {
162
240
  startLiveMcpServer(hostState, parseInt(mcpPort))
163
241
  .then((server) => { hostState._mcpServer = server; })
164
242
  .catch((err) => {
165
- console.error(` ⚠️ MCP server failed to start on port ${mcpPort}: ${err.message}`);
166
- console.error(` Use --no-mcp to disable, or --mcp-port <port> to change port.`);
243
+ console.error(warning(` ⚠️ MCP server failed to start on port ${mcpPort}: ${err.message}`));
244
+ console.error(dim(` Use --no-mcp to disable, or --mcp-port <port> to change port.`));
167
245
  });
168
246
  // Don't await — host startup should not depend on MCP server
169
247
  }
@@ -201,6 +279,91 @@ export async function main(args) {
201
279
  });
202
280
  }
203
281
 
282
+ async function runSync(args) {
283
+ const target = ['host', 'extensions'].includes(args[0]) ? args[0] : 'all';
284
+ const force = args.includes('--force');
285
+ const keep = parseInt(getFlag(args, '--keep') || String(DEFAULT_KEEP_VERSIONS), 10);
286
+ const sku = getFlag(args, '--sku') || 'flex';
287
+ const profilesSource = getFlag(args, '--profiles');
288
+ if (profilesSource) setProfilesSource(profilesSource);
289
+
290
+ if (sku === 'list') {
291
+ await listProfiles();
292
+ return;
293
+ }
294
+
295
+ const profile = await resolveProfile(sku);
296
+ profile.name = sku;
297
+
298
+ console.log(title(`Syncing SKU '${sku}' (${profile.displayName})...`));
299
+ if (target === 'all' || target === 'host') {
300
+ await ensureHost(profile, { force, keepVersions: keep });
301
+ console.log(success(' ✓ Host synchronized.'));
302
+ }
303
+ if (target === 'all' || target === 'extensions') {
304
+ const bundle = await ensureBundle(profile, { force, keepVersions: keep });
305
+ console.log(success(` ✓ Extensions synchronized (${bundle || 'cached'}).`));
306
+ }
307
+
308
+ console.log(dim(` Retention policy: keep latest ${keep} version(s).`));
309
+ }
310
+
311
+ function printHostDriftWarning(targetHostVersion) {
312
+ const cached = getCachedHostVersions();
313
+ if (cached.length === 0) return;
314
+
315
+ const highest = [...cached].sort(compareVersions).pop();
316
+ if (!highest) return;
317
+
318
+ if (compareVersions(targetHostVersion, highest) > 0) {
319
+ console.log(info(` ℹ️ New host available: ${targetHostVersion} (local latest: ${highest}).`));
320
+ console.log(dim(' Run `fnx sync` or `fnx sync host` to download it.\n'));
321
+ } else if (compareVersions(targetHostVersion, highest) < 0) {
322
+ console.log(warning(` ⚠️ Host rollback detected: local ${highest}, catalog ${targetHostVersion}.`));
323
+ console.log(dim(' Run `fnx sync` or `fnx sync host` to align with the supported version.\n'));
324
+ }
325
+ }
326
+
327
+ async function maybeWarnForCliUpgrade() {
328
+ try {
329
+ const pkg = await getFnxPackage();
330
+ const current = pkg.version;
331
+ const cached = await readJsonFile(VERSION_CHECK_FILE);
332
+ const now = Date.now();
333
+
334
+ if (cached?.checkedAt && now - new Date(cached.checkedAt).getTime() < VERSION_CHECK_TTL_MS) {
335
+ if (cached.latestVersion && compareVersions(cached.latestVersion, current) > 0) {
336
+ printUpgradeTip(cached.latestVersion);
337
+ }
338
+ return;
339
+ }
340
+
341
+ const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/latest`;
342
+ const res = await fetch(registryUrl);
343
+ if (!res.ok) return;
344
+ const latest = await res.json();
345
+
346
+ await mkdir(FNX_HOME, { recursive: true });
347
+ await writeFile(VERSION_CHECK_FILE, JSON.stringify({ checkedAt: new Date().toISOString(), latestVersion: latest.version }, null, 2));
348
+
349
+ if (latest.version && compareVersions(latest.version, current) > 0) {
350
+ printUpgradeTip(latest.version);
351
+ }
352
+ } catch {
353
+ // non-fatal: offline/private registry/etc.
354
+ }
355
+ }
356
+
357
+ function printUpgradeTip(latestVersion) {
358
+ console.log(info(` ℹ️ A newer fnx version is available (${latestVersion}).`));
359
+ console.log(dim(' Run `npm i -g @vrdmr/fnx-test@latest` to upgrade.\n'));
360
+ }
361
+
362
+ async function getFnxPackage() {
363
+ const dir = dirname(fileURLToPath(import.meta.url));
364
+ return JSON.parse(await readFile(join(dir, '..', 'package.json'), 'utf-8'));
365
+ }
366
+
204
367
  async function startTemplatesMcp() {
205
368
  const { runStdioMcpServer } = await import('./mcp-server.js');
206
369
  const { getTemplateTools } = await import('./mcp-tools/templates.js');
@@ -229,102 +392,215 @@ export async function readJsonFile(filePath) {
229
392
  }
230
393
  }
231
394
 
395
+ async function printHelpWithVersionInfo() {
396
+ const pkg = await getFnxPackage();
397
+ const cachedHosts = getCachedHostVersions().sort(compareVersions);
398
+ const cachedBundles = getCachedBundleVersions().sort(compareVersions);
399
+
400
+ // Build SKU → host version map from cached/bundled profiles (no network)
401
+ const registry = readProfilesSync();
402
+ const hostToSkus = {};
403
+ if (registry?.profiles) {
404
+ for (const [sku, p] of Object.entries(registry.profiles)) {
405
+ const v = p.hostVersion;
406
+ if (!hostToSkus[v]) hostToSkus[v] = [];
407
+ hostToSkus[v].push(sku);
408
+ }
409
+ }
410
+
411
+ // Fire-and-forget: refresh profile cache in background.
412
+ // Won't block -h — process.exit() will terminate regardless.
413
+ fetchRegistryWithMeta().catch(() => {});
414
+
415
+ console.log(`
416
+ ${bold(title('Azure Functions Local Emulator (fnx — Phoenix Emulate)'))}
417
+ ${dim('fnx Version:')} ${title(pkg.version)}`);
418
+
419
+ if (cachedHosts.length) {
420
+ console.log(`${dim('Cached Hosts:')} ${cachedHosts.map((v) => {
421
+ const skus = hostToSkus[v];
422
+ return skus ? `${info(v)} ${dim('(' + skus.join(', ') + ')')}` : info(v);
423
+ }).join(', ')}`);
424
+ } else {
425
+ console.log(`${dim('Cached Hosts:')} ${dim('(none — run fnx warmup)')}`);
426
+ }
427
+
428
+ if (cachedBundles.length) {
429
+ console.log(`${dim('Cached Bundles:')} ${cachedBundles.map((v) => info(v)).join(', ')}`);
430
+ }
431
+
432
+ console.log();
433
+ printHelp();
434
+ }
435
+
232
436
  function printHelp() {
437
+ console.log(`${title('Usage:')} fnx <command> [options]
438
+
439
+ ${title('Commands:')}
440
+ ${funcName('start')} Launch the Azure Functions host runtime for a specific SKU.
441
+ ${funcName('sync')} Sync cached host/extensions with current catalog profile.
442
+ ${funcName('pack')} Package a Functions app into a deployment zip.
443
+ ${funcName('warmup')} Pre-download host binaries and extension bundles.
444
+ ${funcName('templates-mcp')} Start the Azure Functions templates MCP server (stdio).
445
+
446
+ Run ${dim('fnx <command> -h')} for command-specific options.
447
+
448
+ ${title('Common Options:')}
449
+ ${success('--sku')} <name> Target SKU to emulate (default: flex).
450
+ Use ${success('--sku list')} to see all available SKUs.
451
+ ${success('--verbose')} Show all host output (unfiltered).
452
+ ${success('-v')}, ${success('--version')} Display the version of fnx.
453
+ ${success('-h')}, ${success('--help')} Display this help information.
454
+
455
+ ${title('Start Options:')} ${dim('(fnx start)')}
456
+ ${success('--app-path')} <dir> Path to the function app directory (default: cwd).
457
+ ${success('--port')} <port> Port for the host HTTP listener (default: 7071).
458
+ ${success('--mcp-port')} <port> Port for the live MCP server (default: host port + 1).
459
+ ${success('--no-mcp')} Disable the live MCP server.
460
+ ${success('--no-azurite')} Skip automatic Azurite start.
461
+
462
+ ${title('Sync Options:')} ${dim('(fnx sync [host|extensions])')}
463
+ ${success('--keep')} <n> Keep latest N versions in cache (default: 2).
464
+ ${success('--force')} Re-download even if already cached.
465
+
466
+ ${title('Pack Options:')} ${dim('(fnx pack)')}
467
+ ${success('--app-path')} <dir> Path to the function app directory (default: cwd).
468
+ ${success('--runtime')} <name> Runtime identifier (default: auto-detected from config).
469
+ ${success('--output')} <file> Output zip path (default: <app-name>.zip).
470
+ ${success('--no-build')} Skip build steps for java/dotnet-isolated.
471
+
472
+ ${title('Available SKUs:')}
473
+ ${funcName('flex')} Flex Consumption (latest host, default)
474
+ ${funcName('linux-premium')} Linux Premium / Elastic Premium
475
+ ${funcName('windows-consumption')} Windows Consumption (classic)
476
+ ${funcName('windows-dedicated')} Windows Dedicated (App Service Plan)
477
+ ${funcName('linux-consumption')} Linux Consumption (retiring)
478
+
479
+ ${title('Examples:')}
480
+ fnx start Start with default SKU (flex)
481
+ fnx start --sku windows-consumption Emulate Windows Consumption
482
+ fnx start --sku flex --port 8080 Custom port
483
+ fnx pack --app-path ./my-app Package function app as zip
484
+ fnx sync host --force Force re-download host binary
485
+ fnx warmup --all Pre-download all SKUs
486
+ fnx templates-mcp Start templates MCP server
487
+
488
+ ${dim('Advanced Options:')}
489
+ ${success('--profiles')} <src> Override SKU profiles source. Can be a URL, local file path,
490
+ or inline JSON. Default: CDN → cached → bundled.
491
+ Set FUNC_PROFILES_URL env var for persistent override.`.trim());
492
+ }
493
+
494
+ function printStartHelp() {
495
+ console.log(`
496
+ ${bold(title('fnx start'))} — Launch the Azure Functions host runtime.
497
+
498
+ ${title('Usage:')} fnx start [options]
499
+
500
+ ${title('Options:')}
501
+ ${success('--sku')} <name> Target SKU to emulate (default: flex). Use ${success('--sku list')} to see options.
502
+ ${success('--app-path')} <dir> Path to the function app directory (default: cwd).
503
+ ${success('--port')} <port> Port for the host HTTP listener (default: 7071).
504
+ ${success('--mcp-port')} <port> Port for the live MCP server (default: host port + 1).
505
+ ${success('--verbose')} Show all host output (unfiltered).
506
+ ${success('--no-mcp')} Disable the live MCP server.
507
+ ${success('--no-azurite')} Skip automatic Azurite start.
508
+ ${success('-h')}, ${success('--help')} Show this help message.
509
+
510
+ ${title('Examples:')}
511
+ fnx start Start with default SKU (flex)
512
+ fnx start --sku flex --port 8080 Custom port
513
+ fnx start --sku windows-consumption Emulate Windows Consumption
514
+ fnx start --verbose Show all host output`.trim());
515
+ }
516
+
517
+ function printSyncHelp() {
518
+ console.log(`
519
+ ${bold(title('fnx sync'))} — Sync cached host/extensions with current catalog profile.
520
+
521
+ ${title('Usage:')} fnx sync [host|extensions] [options]
522
+
523
+ ${title('Options:')}
524
+ ${success('--sku')} <name> Target SKU to sync (default: flex). Use ${success('--sku list')} to see options.
525
+ ${success('--keep')} <n> Keep latest N versions in cache (default: 2).
526
+ ${success('--force')} Re-download even if already cached.
527
+ ${success('-h')}, ${success('--help')} Show this help message.
528
+
529
+ ${title('Examples:')}
530
+ fnx sync Sync host and extensions for default SKU
531
+ fnx sync host Sync host only
532
+ fnx sync extensions Sync extensions only
533
+ fnx sync --force Force re-download`.trim());
534
+ }
535
+
536
+ function printPackHelp() {
537
+ console.log(`
538
+ ${bold(title('fnx pack'))} — Package a Functions app into a deployment zip.
539
+
540
+ ${title('Usage:')} fnx pack [options]
541
+
542
+ ${title('Options:')}
543
+ ${success('--app-path')} <dir> Path to the function app directory (default: cwd).
544
+ ${success('--runtime')} <name> Runtime identifier (default: auto-detected from config).
545
+ ${success('--output')} <file> Output zip path (default: <app-name>.zip).
546
+ ${success('--no-build')} Skip build steps for java/dotnet-isolated.
547
+ ${success('-h')}, ${success('--help')} Show this help message.
548
+
549
+ ${title('Supported runtimes:')} node, python, java, powershell, dotnet-isolated
550
+
551
+ ${title('Examples:')}
552
+ fnx pack Package current directory
553
+ fnx pack --app-path ./my-app Package a specific app
554
+ fnx pack --runtime python --no-build Skip build step`.trim());
555
+ }
556
+
557
+ function printWarmupHelp() {
233
558
  console.log(`
234
- Azure Functions Local Emulator (fnx Phoenix Emulate)
235
- SKU-aware host runtime for local development.
236
-
237
- Usage: fnx <action> [-/--options]
238
-
239
- Actions:
240
- start Launch the Azure Functions host runtime for a specific SKU.
241
- Downloads and caches the correct host version automatically.
242
- warmup Pre-download host binaries and extension bundles for offline use.
243
- Runs automatically as postinstall hook. Use --dry-run to preview.
244
- templates-mcp Start the Azure Functions templates MCP server (stdio transport).
245
- Drop-in replacement for manvir-templates-mcp-server.
246
- Provides 68 templates across 4 languages via MCP protocol.
247
-
248
- Options:
249
- --sku <name> Target SKU to emulate. Determines which host version runs.
250
- Resolution order: CLI flag → app.config.json → local.settings.json → default (flex).
251
- Use --sku list to see all available SKUs.
252
- --scriptroot Path to the function app directory. Defaults to the current directory.
253
- Must contain host.json and either app.config.json or local.settings.json.
254
- --port <port> Port for the host HTTP listener. Default: 7071.
255
- --mcp-port <p> Port for the live MCP server. Default: host port + 1 (7072).
256
- --no-mcp Disable the live MCP server (host-only mode).
257
- --no-azurite Skip automatic Azurite start (for users who manage Azurite separately).
258
- --profiles <src> SKU profiles source. Can be:
259
- • A URL (http/https) to a profiles JSON endpoint
260
- A local file path to a profiles JSON file
261
- • Inline JSON string (e.g. '{"profiles":{...}}')
262
- Default: FUNC_PROFILES_URL env var, or http://localhost:4566/api/profiles.
263
- --verbose Show all host output (unfiltered). Default: clean output only.
264
- -v, --version Display the version of fnx.
265
- -h, --help Display this help information.
266
-
267
- Available SKUs:
268
- flex Azure Functions Flex Consumption (latest host, default)
269
- linux-premium Linux Premium / Elastic Premium
270
- windows-consumption Windows Consumption (classic)
271
- windows-dedicated Windows Dedicated (App Service Plan)
272
- linux-consumption Linux Consumption (retiring)
273
-
274
- Configuration:
275
- app.config.json Non-secret app settings (committed to source control).
276
- Contains TargetSku and Values (e.g. FUNCTIONS_WORKER_RUNTIME).
277
- local.settings.json Secrets and connection strings (git-ignored).
278
- Values here override app.config.json Values.
279
-
280
- Config values from both files are merged and injected as environment
281
- variables into the host process. local.settings.json values take precedence.
282
-
283
- Examples:
284
- fnx start Start with default SKU (flex) in current directory
285
- fnx start --sku flex Emulate Flex Consumption
286
- fnx start --sku windows-consumption Emulate Windows Consumption (older host version)
287
- fnx start --sku list List all available SKU profiles with host versions
288
- fnx start --sku flex --port 8080 Start on a custom port
289
- fnx start --scriptroot ./my-app Start from a specific function app directory
290
-
291
- Side-by-side comparison:
292
- # Terminal 1: Run as Flex Consumption
293
- fnx start --sku flex --port 7071
294
-
295
- # Terminal 2: Run as Windows Consumption (different host version)
296
- fnx start --sku windows-consumption --port 7072
297
-
298
- # Compare behavior across SKUs with the same function app!
299
-
300
- MCP server (for VS Code Copilot / AI assistants):
301
- fnx templates-mcp Start templates MCP server (stdio)
302
- fnx start Also starts live MCP server on port+1
303
- fnx start --mcp-port 9000 Live MCP server on custom port
304
- fnx start --no-mcp Disable live MCP server
305
-
306
- # .vscode/mcp.json — templates only (stdio):
307
- # {
308
- # "servers": {
309
- # "azure-functions-templates": {
310
- # "type": "stdio",
311
- # "command": "fnx",
312
- # "args": ["templates-mcp"]
313
- # }
314
- # }
315
- # }
316
- #
317
- # .vscode/mcp.json — live host data (when fnx start is running):
318
- # {
319
- # "servers": {
320
- # "fnx-functions-debug": {
321
- # "type": "http",
322
- # "url": "http://127.0.0.1:7072/mcp"
323
- # }
324
- # }
325
- # }
326
-
327
- Supported runtimes: node, python, java, powershell, dotnet-isolated
328
- (.NET in-process / Microsoft.NET.Sdk.Functions is not supported — isolated worker model only)
329
- `.trim());
559
+ ${bold(title('fnx warmup'))} Pre-download host binaries and extension bundles for offline use.
560
+
561
+ ${title('Usage:')} fnx warmup [options]
562
+
563
+ ${title('Options:')}
564
+ ${success('--sku')} <name> Target SKU to warm (default: flex). Use ${success('--sku list')} to see options.
565
+ ${success('--all')} Warm ALL available SKUs (useful for CI/build agents).
566
+ ${success('--dry-run')} Show what would be downloaded without actually downloading.
567
+ ${success('--force')} Re-download even if already cached.
568
+ ${success('-h')}, ${success('--help')} Show this help message.
569
+
570
+ ${title('Environment Variables:')}
571
+ FNX_SKIP_DOWNLOAD=1 Skip warmup entirely (useful for CI/Docker).
572
+ FNX_DEFAULT_SKU=<name> Warm a specific SKU instead of flex.
573
+
574
+ ${title('Examples:')}
575
+ fnx warmup Pre-download default SKU (flex)
576
+ fnx warmup --all Warm ALL SKUs
577
+ fnx warmup --dry-run Show what would be downloaded
578
+ fnx warmup --force Re-download even if cached`.trim());
579
+ }
580
+
581
+ function printTemplatesMcpHelp() {
582
+ console.log(`
583
+ ${bold(title('fnx templates-mcp'))} Start the Azure Functions templates MCP server.
584
+
585
+ ${title('Usage:')} fnx templates-mcp
586
+
587
+ Starts a stdio-based MCP server that provides Azure Functions templates
588
+ and SKU profile information to AI assistants (VS Code Copilot, etc.).
589
+
590
+ ${title('Features:')}
591
+ • 68 templates across 4 languages (JavaScript, TypeScript, Python, C#)
592
+ SKU profile listing and resolution tools
593
+ Drop-in replacement for manvir-templates-mcp-server
594
+
595
+ ${title('VS Code Configuration:')}
596
+ Add to .vscode/mcp.json:
597
+ {
598
+ "servers": {
599
+ "azure-functions-templates": {
600
+ "type": "stdio",
601
+ "command": "fnx",
602
+ "args": ["templates-mcp"]
603
+ }
604
+ }
605
+ }`.trim());
330
606
  }