@vrdmr/fnx-test 0.2.1 → 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/README.md CHANGED
@@ -11,7 +11,7 @@ npm install -g @vrdmr/fnx-test
11
11
  Or run without installing:
12
12
 
13
13
  ```bash
14
- npx @vrdmr/fnx-test start --sku flex --scriptroot ./my-app
14
+ npx @vrdmr/fnx-test start --sku flex --app-path ./my-app
15
15
  ```
16
16
 
17
17
  ## Quick Start
@@ -21,10 +21,10 @@ npx @vrdmr/fnx-test start --sku flex --scriptroot ./my-app
21
21
  fnx start --sku list
22
22
 
23
23
  # Run your function app with the Flex Consumption host
24
- fnx start --sku flex --scriptroot ./my-function-app
24
+ fnx start --sku flex --app-path ./my-function-app
25
25
 
26
26
  # Run with Windows Consumption host
27
- fnx start --sku windows-consumption --scriptroot ./my-function-app
27
+ fnx start --sku windows-consumption --app-path ./my-function-app
28
28
  ```
29
29
 
30
30
  ## What It Does
@@ -53,12 +53,12 @@ fnx start --sku flex
53
53
  ## Commands
54
54
 
55
55
  ```bash
56
- fnx start --sku <sku> --scriptroot <path> # Run function app
56
+ fnx start --sku <sku> --app-path <path> # Run function app
57
57
  fnx start --sku list # List available SKUs
58
58
  fnx warmup [--sku <sku>] [--all] # Pre-download host + bundle
59
59
  fnx sync [host|extensions] [--sku <sku>] # Reconcile cache with latest catalog / rollback
60
60
  fnx templates-mcp # Start MCP server for AI agents
61
- fnx pack --scriptroot <path> # Package function app as deployment zip
61
+ fnx pack --app-path <path> # Package function app as deployment zip
62
62
  ```
63
63
 
64
64
 
@@ -3,6 +3,7 @@ import { createConnection } from 'node:net';
3
3
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
+ import { info, url as urlColor, warning, error as errorColor } from './colors.js';
6
7
 
7
8
  const BLOB_PORT = 10000;
8
9
  const QUEUE_PORT = 10001;
@@ -75,7 +76,7 @@ function findAzurite() {
75
76
  * Install azurite into ~/.fnx/tools/azurite/ if not already present.
76
77
  */
77
78
  function installAzurite() {
78
- console.log('[fnx] Installing Azurite to ~/.fnx/tools/azurite/ (first-time only)...');
79
+ console.log(info('[fnx] Installing Azurite to ~/.fnx/tools/azurite/ (first-time only)...'));
79
80
  mkdirSync(AZURITE_INSTALL_DIR, { recursive: true });
80
81
 
81
82
  // Initialize a minimal package.json if missing so npm install works
@@ -91,14 +92,14 @@ function installAzurite() {
91
92
  timeout: 120_000,
92
93
  });
93
94
  } catch (err) {
94
- console.error('[fnx] Failed to install Azurite. Install manually: npm install -g azurite');
95
- console.error(` ${err.message}`);
95
+ console.error(errorColor('[fnx] Failed to install Azurite. Install manually: npm install -g azurite'));
96
+ console.error(warning(` ${err.message}`));
96
97
  return null;
97
98
  }
98
99
 
99
100
  const installed = join(AZURITE_INSTALL_DIR, 'node_modules', '.bin', 'azurite');
100
101
  if (existsSync(installed)) {
101
- console.log('[fnx] Azurite installed successfully.');
102
+ console.log(info('[fnx] Azurite installed successfully.'));
102
103
  return installed;
103
104
  }
104
105
  return null;
@@ -127,23 +128,23 @@ export async function ensureAzurite(mergedValues, opts = {}) {
127
128
  }
128
129
 
129
130
  const storageVal = mergedValues?.AzureWebJobsStorage || '(empty)';
130
- console.log(`[fnx] Detected AzureWebJobsStorage=${storageVal}`);
131
+ console.log(info(`[fnx] Detected AzureWebJobsStorage=${storageVal}`));
131
132
 
132
133
  // Check if Azurite is already running
133
134
  if (await isAzuriteRunning()) {
134
- console.log('[fnx] Using existing Azurite instance on default ports.');
135
+ console.log(info('[fnx] Using existing Azurite instance on default ports.'));
135
136
  return null;
136
137
  }
137
138
 
138
139
  // Find or install azurite
139
140
  const azuriteBin = findOrInstallAzurite();
140
141
  if (!azuriteBin) {
141
- console.error('[fnx] ⚠️ Azurite not available. Storage triggers may fail.');
142
- console.error(' Install with: npm install -g azurite');
142
+ console.error(warning('[fnx] ⚠️ Azurite not available. Storage triggers may fail.'));
143
+ console.error(warning(' Install with: npm install -g azurite'));
143
144
  return null;
144
145
  }
145
146
 
146
- console.log('[fnx] Starting Azurite storage emulator...');
147
+ console.log(info('[fnx] Starting Azurite storage emulator...'));
147
148
 
148
149
  const azuriteArgs = [
149
150
  '--blobHost', '127.0.0.1', '--blobPort', String(BLOB_PORT),
@@ -161,13 +162,13 @@ export async function ensureAzurite(mergedValues, opts = {}) {
161
162
  });
162
163
 
163
164
  azuriteProcess.on('error', (err) => {
164
- console.error(`[fnx] Azurite failed to start: ${err.message}`);
165
+ console.error(errorColor(`[fnx] Azurite failed to start: ${err.message}`));
165
166
  azuriteProcess = null;
166
167
  });
167
168
 
168
169
  azuriteProcess.on('exit', (code) => {
169
170
  if (code && code !== 0) {
170
- console.error(`[fnx] Azurite exited unexpectedly with code ${code}.`);
171
+ console.error(errorColor(`[fnx] Azurite exited unexpectedly with code ${code}.`));
171
172
  }
172
173
  azuriteProcess = null;
173
174
  });
@@ -175,13 +176,13 @@ export async function ensureAzurite(mergedValues, opts = {}) {
175
176
  // Wait for Azurite to be ready
176
177
  const ready = await waitForTcp(BLOB_PORT, { timeoutMs: 15000 });
177
178
  if (!ready) {
178
- console.error('[fnx] ⚠️ Azurite did not become ready in time. Storage triggers may fail.');
179
+ console.error(warning('[fnx] ⚠️ Azurite did not become ready in time. Storage triggers may fail.'));
179
180
  return azuriteProcess;
180
181
  }
181
182
 
182
- console.log(`[fnx] Azurite Blob → http://127.0.0.1:${BLOB_PORT}`);
183
- console.log(`[fnx] Azurite Queue → http://127.0.0.1:${QUEUE_PORT}`);
184
- console.log(`[fnx] Azurite Table → http://127.0.0.1:${TABLE_PORT}`);
183
+ console.log(info(`[fnx] Azurite Blob → ${urlColor(`http://127.0.0.1:${BLOB_PORT}`)}`));
184
+ console.log(info(`[fnx] Azurite Queue → ${urlColor(`http://127.0.0.1:${QUEUE_PORT}`)}`));
185
+ console.log(info(`[fnx] Azurite Table → ${urlColor(`http://127.0.0.1:${TABLE_PORT}`)}`));
185
186
 
186
187
  return azuriteProcess;
187
188
  }
package/lib/cli.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { resolve as resolvePath, dirname, join } from 'node:path';
2
2
  import { readFile, mkdir, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
3
4
  import { createServer } from 'node:net';
4
5
  import { homedir } from 'node:os';
5
6
  import { fileURLToPath } from 'node:url';
6
- import { resolveProfile, listProfiles, setProfilesSource, fetchRegistryWithMeta } from './profile-resolver.js';
7
- import { ensureHost, ensureBundle, getCachedHostVersions, compareVersions, DEFAULT_KEEP_VERSIONS } 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';
8
9
  import { launchHost, createHostState } from './host-launcher.js';
9
10
  import { startLiveMcpServer } from './live-mcp-server.js';
10
11
  import { detectDotnetModel, printInProcessError } from './dotnet-detector.js';
11
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';
12
14
 
13
15
  const FNX_HOME = join(homedir(), '.fnx');
14
16
  const VERSION_CHECK_FILE = join(FNX_HOME, 'version-check.json');
@@ -30,38 +32,83 @@ async function findOpenPort(start, maxRetries = 10) {
30
32
  return start; // fall through — let the host report the error
31
33
  }
32
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
+
33
76
  export async function main(args) {
34
77
  const cmd = args[0];
35
78
 
36
79
  if (cmd === '-h' || cmd === '--help' || cmd === 'help' || !cmd) {
37
- printHelp();
80
+ await printHelpWithVersionInfo();
38
81
  process.exit(cmd ? 0 : 1);
39
82
  }
40
83
 
41
84
  if (cmd === '-v' || cmd === '--version') {
42
85
  const pkg = await getFnxPackage();
43
- console.log(`fnx v${pkg.version}`);
86
+ console.log(title(`fnx v${pkg.version}`));
44
87
  process.exit(0);
45
88
  }
46
89
 
47
90
  if (cmd === 'templates-mcp') {
91
+ if (hasHelp(args.slice(1))) { printTemplatesMcpHelp(); return; }
48
92
  await startTemplatesMcp();
49
93
  return;
50
94
  }
51
95
 
52
96
  if (cmd === 'warmup') {
97
+ if (hasHelp(args.slice(1))) { printWarmupHelp(); return; }
53
98
  const { warmup } = await import('./warmup.js');
54
99
  await warmup(args.slice(1));
55
100
  return;
56
101
  }
57
102
 
58
103
  if (cmd === 'sync') {
104
+ if (hasHelp(args.slice(1))) { printSyncHelp(); return; }
59
105
  await runSync(args.slice(1));
60
106
  return;
61
107
  }
62
108
 
63
109
  if (cmd === 'pack') {
64
- const scriptRoot = getFlag(args, '--scriptroot') || process.cwd();
110
+ if (hasHelp(args.slice(1))) { printPackHelp(); return; }
111
+ const scriptRoot = resolveAppPath(args);
65
112
  const runtime = getFlag(args, '--runtime') || await detectRuntimeFromConfig(scriptRoot);
66
113
  const outputPath = getFlag(args, '--output');
67
114
  const noBuild = args.includes('--no-build');
@@ -70,18 +117,20 @@ export async function main(args) {
70
117
  }
71
118
 
72
119
  if (cmd !== 'start') {
73
- console.error(`Unknown command: ${cmd}\n`);
120
+ console.error(errorColor(`Unknown command: ${cmd}\n`));
74
121
  printHelp();
75
122
  process.exit(1);
76
123
  }
77
124
 
125
+ if (hasHelp(args.slice(1))) { printStartHelp(); return; }
126
+
78
127
  await maybeWarnForCliUpgrade();
79
128
 
80
- const scriptRoot = getFlag(args, '--scriptroot') || process.cwd();
129
+ const scriptRoot = resolveAppPath(args);
81
130
  const requestedPort = parseInt(getFlag(args, '--port') || '7071');
82
131
  const port = await findOpenPort(requestedPort);
83
132
  if (port !== requestedPort) {
84
- console.log(` Port ${requestedPort} in use, using ${port} instead.`);
133
+ console.log(warning(` Port ${requestedPort} in use, using ${port} instead.`));
85
134
  }
86
135
  const mcpPort = getFlag(args, '--mcp-port') || String(port + 1);
87
136
  const verbose = args.includes('--verbose');
@@ -113,8 +162,8 @@ export async function main(args) {
113
162
  if (!sku) {
114
163
  sku = 'flex';
115
164
  skuSource = 'default';
116
- console.log(`No --sku specified, defaulting to '${sku}'.`);
117
- 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`));
118
167
  }
119
168
 
120
169
  if (sku === 'list') {
@@ -124,9 +173,9 @@ export async function main(args) {
124
173
 
125
174
  // 1. Resolve profile
126
175
  if (skuSource !== 'default') {
127
- console.log(`Resolving SKU profile: ${sku} (from ${skuSource})...`);
176
+ console.log(title(`Resolving SKU profile: ${sku} (from ${skuSource})...`));
128
177
  } else {
129
- console.log(`Resolving SKU profile: ${sku}...`);
178
+ console.log(title(`Resolving SKU profile: ${sku}...`));
130
179
  }
131
180
 
132
181
  const { registry, source } = await fetchRegistryWithMeta();
@@ -137,13 +186,13 @@ export async function main(args) {
137
186
  }
138
187
 
139
188
  profile.name = sku;
140
- console.log(` Target SKU: ${profile.displayName}`);
141
- console.log(` Host Version: ${profile.hostVersion}`);
142
- console.log(` Extension Bundle: ${profile.extensionBundleVersion}`);
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)}`);
143
192
  if (profile.maxExtensionBundleVersion) {
144
- console.log(` Max Bundle Cap: ${profile.maxExtensionBundleVersion}`);
193
+ console.log(` ${dim('Max Bundle Cap:')} ${info(profile.maxExtensionBundleVersion)}`);
145
194
  }
146
- console.log(` Profile Source: ${source}`);
195
+ console.log(` ${dim('Profile Source:')} ${info(source)}`);
147
196
  console.log();
148
197
 
149
198
  // Early validation: merge config and check runtime before downloading anything
@@ -155,7 +204,7 @@ export async function main(args) {
155
204
  const workerRuntime = mergedValues.FUNCTIONS_WORKER_RUNTIME;
156
205
 
157
206
  if (!workerRuntime) {
158
- 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'));
159
208
  process.exit(1);
160
209
  }
161
210
 
@@ -173,14 +222,14 @@ export async function main(args) {
173
222
 
174
223
  // 2. Ensure host is downloaded
175
224
  const hostDir = await ensureHost(profile, { keepVersions: DEFAULT_KEEP_VERSIONS });
176
- console.log(` Host path: ${hostDir}`);
225
+ console.log(` ${dim('Host path:')} ${info(hostDir)}`);
177
226
 
178
227
  // 3. Pre-download the correct extension bundle for this SKU
179
228
  // This resolves the exact version from CDN index, capped by maxExtensionBundleVersion,
180
229
  // and downloads it so the host finds it cached and never fetches a wrong version.
181
230
  const resolvedBundleVersion = await ensureBundle(profile, { keepVersions: DEFAULT_KEEP_VERSIONS });
182
231
  if (resolvedBundleVersion) {
183
- console.log(` Bundle resolved: ${resolvedBundleVersion}`);
232
+ console.log(` ${dim('Bundle resolved:')} ${info(resolvedBundleVersion)}`);
184
233
  }
185
234
  console.log();
186
235
 
@@ -191,8 +240,8 @@ export async function main(args) {
191
240
  startLiveMcpServer(hostState, parseInt(mcpPort))
192
241
  .then((server) => { hostState._mcpServer = server; })
193
242
  .catch((err) => {
194
- console.error(` ⚠️ MCP server failed to start on port ${mcpPort}: ${err.message}`);
195
- 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.`));
196
245
  });
197
246
  // Don't await — host startup should not depend on MCP server
198
247
  }
@@ -246,17 +295,17 @@ async function runSync(args) {
246
295
  const profile = await resolveProfile(sku);
247
296
  profile.name = sku;
248
297
 
249
- console.log(`Syncing SKU '${sku}' (${profile.displayName})...`);
298
+ console.log(title(`Syncing SKU '${sku}' (${profile.displayName})...`));
250
299
  if (target === 'all' || target === 'host') {
251
300
  await ensureHost(profile, { force, keepVersions: keep });
252
- console.log(' ✓ Host synchronized.');
301
+ console.log(success(' ✓ Host synchronized.'));
253
302
  }
254
303
  if (target === 'all' || target === 'extensions') {
255
304
  const bundle = await ensureBundle(profile, { force, keepVersions: keep });
256
- console.log(` ✓ Extensions synchronized (${bundle || 'cached'}).`);
305
+ console.log(success(` ✓ Extensions synchronized (${bundle || 'cached'}).`));
257
306
  }
258
307
 
259
- console.log(` Retention policy: keep latest ${keep} version(s).`);
308
+ console.log(dim(` Retention policy: keep latest ${keep} version(s).`));
260
309
  }
261
310
 
262
311
  function printHostDriftWarning(targetHostVersion) {
@@ -267,11 +316,11 @@ function printHostDriftWarning(targetHostVersion) {
267
316
  if (!highest) return;
268
317
 
269
318
  if (compareVersions(targetHostVersion, highest) > 0) {
270
- console.log(` ℹ️ New host available: ${targetHostVersion} (local latest: ${highest}).`);
271
- console.log(' Run `fnx sync` or `fnx sync host` to download it.\n');
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'));
272
321
  } else if (compareVersions(targetHostVersion, highest) < 0) {
273
- console.log(` ⚠️ Host rollback detected: local ${highest}, catalog ${targetHostVersion}.`);
274
- console.log(' Run `fnx sync` or `fnx sync host` to align with the supported version.\n');
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'));
275
324
  }
276
325
  }
277
326
 
@@ -306,8 +355,8 @@ async function maybeWarnForCliUpgrade() {
306
355
  }
307
356
 
308
357
  function printUpgradeTip(latestVersion) {
309
- console.log(` ℹ️ A newer fnx version is available (${latestVersion}).`);
310
- console.log(' Run `npm i -g @vrdmr/fnx-test@latest` to upgrade.\n');
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'));
311
360
  }
312
361
 
313
362
  async function getFnxPackage() {
@@ -343,113 +392,215 @@ export async function readJsonFile(filePath) {
343
392
  }
344
393
  }
345
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
+
346
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() {
347
537
  console.log(`
348
- Azure Functions Local Emulator (fnx Phoenix Emulate)
349
- SKU-aware host runtime for local development.
350
-
351
- Usage: fnx <action> [-/--options]
352
-
353
- Actions:
354
- start Launch the Azure Functions host runtime for a specific SKU.
355
- Downloads and caches the correct host version automatically.
356
- sync Sync cached host/extensions with current catalog profile.
357
- Use: fnx sync, fnx sync host, fnx sync extensions.
358
- pack Package a Functions app into a deployment zip (func pack equivalent).
359
- Supports python, node, java, powershell, and dotnet-isolated.
360
- warmup Pre-download host binaries and extension bundles for offline use.
361
- Runs automatically as postinstall hook. Use --dry-run to preview.
362
- templates-mcp Start the Azure Functions templates MCP server (stdio transport).
363
- Drop-in replacement for manvir-templates-mcp-server.
364
- Provides 68 templates across 4 languages via MCP protocol.
365
-
366
- Options:
367
- --sku <name> Target SKU to emulate. Determines which host version runs.
368
- Resolution order: CLI flag → app.config.json → local.settings.json → default (flex).
369
- Use --sku list to see all available SKUs.
370
- --scriptroot Path to the function app directory. Defaults to the current directory.
371
- Must contain host.json and either app.config.json or local.settings.json.
372
- --port <port> Port for the host HTTP listener. Default: 7071.
373
- --mcp-port <p> Port for the live MCP server. Default: host port + 1 (7072).
374
- --profiles <src> SKU profiles source. Can be:
375
- A URL (http/https) to a profiles JSON endpoint
376
- A local file path to a profiles JSON file
377
- Inline JSON string (e.g. '{"profiles":{...}}')
378
- Default: FUNC_PROFILES_URL env var, or http://localhost:4566/api/profiles.
379
- --keep <n> For sync only: keep latest N host/bundle versions in cache (default: 2).
380
- --force For sync only: re-download assets even if already cached.
381
- --no-mcp Disable the live MCP server (host-only mode).
382
- --no-azurite Skip automatic Azurite start (for users who manage Azurite separately).
383
- --verbose Show all host output (unfiltered). Default: clean output only.
384
- --runtime <name> Runtime used by pack. If omitted, reads FUNCTIONS_WORKER_RUNTIME
385
- from app.config.json/local.settings.json.
386
- --output <file> Output zip path for pack. Default: <scriptroot-name>.zip.
387
- --no-build Skip build steps for java/dotnet-isolated during pack.
388
- -v, --version Display the version of fnx.
389
- -h, --help Display this help information.
390
-
391
- Available SKUs:
392
- flex Azure Functions Flex Consumption (latest host, default)
393
- linux-premium Linux Premium / Elastic Premium
394
- windows-consumption Windows Consumption (classic)
395
- windows-dedicated Windows Dedicated (App Service Plan)
396
- linux-consumption Linux Consumption (retiring)
397
-
398
- Configuration:
399
- app.config.json Non-secret app settings (committed to source control).
400
- Contains TargetSku and Values (e.g. FUNCTIONS_WORKER_RUNTIME).
401
- local.settings.json Secrets and connection strings (git-ignored).
402
- Values here override app.config.json Values.
403
-
404
- Config values from both files are merged and injected as environment
405
- variables into the host process. local.settings.json values take precedence.
406
-
407
- Examples:
408
- fnx start Start with default SKU (flex) in current directory
409
- fnx start --sku flex Emulate Flex Consumption
410
- fnx start --sku windows-consumption Emulate Windows Consumption (older host version)
411
- fnx start --sku list List all available SKU profiles with host versions
412
- fnx start --sku flex --port 8080 Start on a custom port
413
- fnx start --scriptroot ./my-app Start from a specific function app directory
414
- fnx pack --scriptroot ./my-app Package function app as zip deployment artifact
415
-
416
- Side-by-side comparison:
417
- # Terminal 1: Run as Flex Consumption
418
- fnx start --sku flex --port 7071
419
-
420
- # Terminal 2: Run as Windows Consumption (different host version)
421
- fnx start --sku windows-consumption --port 7072
422
-
423
- # Compare behavior across SKUs with the same function app!
424
-
425
- MCP server (for VS Code Copilot / AI assistants):
426
- fnx templates-mcp Start templates MCP server (stdio)
427
- fnx start Also starts live MCP server on port+1
428
- fnx start --mcp-port 9000 Live MCP server on custom port
429
- fnx start --no-mcp Disable live MCP server
430
-
431
- # .vscode/mcp.json — templates only (stdio):
432
- # {
433
- # "servers": {
434
- # "azure-functions-templates": {
435
- # "type": "stdio",
436
- # "command": "fnx",
437
- # "args": ["templates-mcp"]
438
- # }
439
- # }
440
- # }
441
- #
442
- # .vscode/mcp.json — live host data (when fnx start is running):
443
- # {
444
- # "servers": {
445
- # "fnx-functions-debug": {
446
- # "type": "http",
447
- # "url": "http://127.0.0.1:7072/mcp"
448
- # }
449
- # }
450
- # }
451
-
452
- Supported runtimes: node, python, java, powershell, dotnet-isolated
453
- (.NET in-process / Microsoft.NET.Sdk.Functions is not supported — isolated worker model only)
454
- `.trim());
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() {
558
+ console.log(`
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());
455
606
  }
package/lib/colors.js ADDED
@@ -0,0 +1,34 @@
1
+ // lib/colors.js — zero-dependency ANSI color helper
2
+ // Matches func start theme from OutputTheme.cs
3
+
4
+ const enabled = !process.env.NO_COLOR && process.stdout.isTTY !== false;
5
+
6
+ const codes = {
7
+ reset: '\x1b[0m',
8
+ bold: '\x1b[1m',
9
+ red: '\x1b[31m',
10
+ green: '\x1b[32m',
11
+ yellow: '\x1b[33m',
12
+ cyan: '\x1b[36m',
13
+ gray: '\x1b[90m',
14
+ };
15
+
16
+ const c = (code) => (str) => enabled ? `${code}${str}${codes.reset}` : str;
17
+
18
+ export const title = c(codes.cyan); // DarkCyan — banner, section headers
19
+ export const info = c(codes.cyan); // DarkCyan — [fnx] prefixed lines
20
+ export const funcName = c(codes.yellow); // DarkYellow — function names
21
+ export const url = c(codes.green); // DarkGreen — URLs
22
+ export const success = c(codes.green); // DarkGreen — ✓ success
23
+ export const error = c(codes.red); // Red — errors, ✗
24
+ export const warning = c(codes.yellow); // DarkYellow — ⚠️ warnings
25
+ export const verbose = c(codes.green); // DarkGreen — debug/verbose
26
+ export const dim = c(codes.gray); // DarkGray — low-priority
27
+ export const bold = c(codes.bold); // Bold — emphasis
28
+
29
+ // Highlight URLs within a string
30
+ export function highlightUrls(str) {
31
+ return str.replace(/(https?:\/\/[^\s,)]+)/g, (m) => url(m));
32
+ }
33
+
34
+ export { enabled, codes };
@@ -5,6 +5,7 @@ import { createInterface } from 'node:readline';
5
5
  import { existsSync } from 'node:fs';
6
6
  import { getHostExeName } from './host-manager.js';
7
7
  import { ensureAzurite, stopAzurite } from './azurite-manager.js';
8
+ import { title, info, funcName, url as urlColor, success, error as errorColor, warning, dim, highlightUrls } from './colors.js';
8
9
 
9
10
  // ─── Shared host state (consumed by live MCP server) ─────────────────────
10
11
  // This object is populated by the log filter and exposed for the MCP server.
@@ -259,15 +260,15 @@ function createLogFilter(verbose, hostState) {
259
260
  }
260
261
 
261
262
  if (httpFunctions.length > 0 || nonHttpFunctions.length > 0) {
262
- console.log('\nFunctions:\n');
263
+ console.log(title('\nFunctions:\n'));
263
264
  for (const fn of httpFunctions) {
264
- console.log(`\t${fn.name}: [${fn.methods}] ${baseUrl}/${fn.route}`);
265
+ console.log(`\t${funcName(fn.name)}: [${fn.methods}] ${urlColor(`${baseUrl}/${fn.route}`)}`);
265
266
  }
266
267
  for (const fn of nonHttpFunctions) {
267
- console.log(`\t${fn.name}: ${fn.triggerType}`);
268
+ console.log(`\t${funcName(fn.name)}: ${fn.triggerType}`);
268
269
  }
269
270
  if (!verbose) {
270
- console.log('\nFor detailed output, run fnx with --verbose flag.');
271
+ console.log(dim('\nFor detailed output, run fnx with --verbose flag.'));
271
272
  }
272
273
  console.log();
273
274
  }
@@ -277,6 +278,24 @@ function createLogFilter(verbose, hostState) {
277
278
  return { processLine, extractFunctionInfo, extractListeningUrl, isSuppressed };
278
279
  }
279
280
 
281
+ function colorizeHostOutput(line) {
282
+ // Executing/Executed function invocations
283
+ const execMatch = line.match(/^(.*?)(Execut(?:ing|ed) 'Functions\.)(\w+)('.*?)(\(Succeeded.*?\)|\(Failed.*?\))?(.*)$/);
284
+ if (execMatch) {
285
+ let result = execMatch[1] + execMatch[2] + funcName(execMatch[3]) + execMatch[4];
286
+ if (execMatch[5]) {
287
+ result += execMatch[5].startsWith('(Succeeded') ? success(execMatch[5]) : errorColor(execMatch[5]);
288
+ }
289
+ result += execMatch[6] || '';
290
+ return highlightUrls(result);
291
+ }
292
+ // "Application started" line
293
+ if (line.includes('Application started')) {
294
+ return success(line);
295
+ }
296
+ return highlightUrls(line);
297
+ }
298
+
280
299
  export { createLogFilter };
281
300
 
282
301
  export async function launchHost(hostDir, opts) {
@@ -341,8 +360,8 @@ export async function launchHost(hostDir, opts) {
341
360
  }
342
361
  } catch { /* non-fatal */ }
343
362
  } else {
344
- console.error('⚠️ Python runtime requested but no compatible python (3.9-3.13) found.');
345
- console.error(' Set "PythonPath" in app.config.json or FNX_PYTHON_PATH env var.');
363
+ console.error(warning('⚠️ Python runtime requested but no compatible python (3.9-3.13) found.'));
364
+ console.error(dim(' Set "PythonPath" in app.config.json or FNX_PYTHON_PATH env var.'));
346
365
  }
347
366
  }
348
367
 
@@ -350,11 +369,11 @@ export async function launchHost(hostDir, opts) {
350
369
  const azuriteProc = await ensureAzurite(opts.mergedValues, { noAzurite: opts.noAzurite });
351
370
 
352
371
  console.log();
353
- console.log('Azure Functions Local Emulator (fnx — Phoenix Emulate)');
354
- console.log(`Emulator Version: 0.1.0`);
355
- console.log(`Host Version: ${opts.profile.hostVersion} (${opts.profile.displayName})`);
372
+ console.log(title('Azure Functions Local Emulator (fnx — Phoenix Emulate)'));
373
+ console.log(`${dim('Emulator Version:')} ${info('0.1.0')}`);
374
+ console.log(`${dim('Host Version:')} ${info(`${opts.profile.hostVersion} (${opts.profile.displayName})`)}`);
356
375
  if (opts.workerRuntime === 'python' && env['languageWorkers__python__defaultExecutablePath']) {
357
- console.log(`Python: ${env['languageWorkers__python__defaultExecutablePath']} (${env['FUNCTIONS_WORKER_RUNTIME_VERSION'] || 'unknown'})`);
376
+ console.log(`${dim('Python:')} ${info(`${env['languageWorkers__python__defaultExecutablePath']} (${env['FUNCTIONS_WORKER_RUNTIME_VERSION'] || 'unknown'})`)}`);
358
377
  }
359
378
  console.log();
360
379
 
@@ -387,7 +406,7 @@ export async function launchHost(hostDir, opts) {
387
406
 
388
407
  const output = filter.processLine(line);
389
408
  if (output) {
390
- console.log(output);
409
+ console.log(colorizeHostOutput(output));
391
410
  }
392
411
  });
393
412
  }
@@ -410,8 +429,8 @@ export async function launchHost(hostDir, opts) {
410
429
 
411
430
  return new Promise((resolve, reject) => {
412
431
  child.on('error', (err) => {
413
- console.error(`\nFailed to start host: ${err.message}`);
414
- console.error(`Host executable: ${hostExe}`);
432
+ console.error(errorColor(`\nFailed to start host: ${err.message}`));
433
+ console.error(dim(`Host executable: ${hostExe}`));
415
434
  hostState.state = 'Error';
416
435
  reject(err);
417
436
  });
@@ -419,9 +438,9 @@ export async function launchHost(hostDir, opts) {
419
438
  child.on('exit', (code, signal) => {
420
439
  hostState.state = 'Stopped';
421
440
  if (signal) {
422
- console.log(`\nHost terminated by signal: ${signal}`);
441
+ console.log(dim(`\nHost terminated by signal: ${signal}`));
423
442
  } else if (code !== 0) {
424
- console.error(`\nHost exited with code: ${code}`);
443
+ console.error(errorColor(`\nHost exited with code: ${code}`));
425
444
  }
426
445
  resolve({ code, hostState });
427
446
  });
@@ -6,6 +6,7 @@ import { homedir, platform } from 'node:os';
6
6
  import { createWriteStream } from 'node:fs';
7
7
  import { execSync } from 'node:child_process';
8
8
  import { arch } from 'node:os';
9
+ import { info, success, warning, dim } from './colors.js';
9
10
 
10
11
  const FNX_HOME = join(homedir(), '.fnx');
11
12
  const HOST_CACHE = join(FNX_HOME, 'hosts');
@@ -125,7 +126,7 @@ export async function ensureHost(profile, { force = false, keepVersions = DEFAUL
125
126
  const hostExe = join(hostDir, getHostExeName());
126
127
 
127
128
  if (!force && existsSync(hostExe)) {
128
- console.log(' Host cached, skipping download.');
129
+ console.log(success(' Host cached, skipping download.'));
129
130
  await updateVersionMetadata(HOST_META_FILE, profile.hostVersion, {
130
131
  lastUsedAt: toIsoNow(),
131
132
  sku: profile.name || profile.displayName,
@@ -150,7 +151,7 @@ export async function ensureHost(profile, { force = false, keepVersions = DEFAUL
150
151
  );
151
152
  }
152
153
 
153
- console.log(` Downloading host ${profile.hostVersion} for ${rid}...`);
154
+ console.log(info(` Downloading host ${profile.hostVersion} for ${rid}...`));
154
155
 
155
156
  await mkdir(hostDir, { recursive: true });
156
157
  const tempZip = join(hostDir, '_download.zip');
@@ -196,7 +197,7 @@ export async function ensureHost(profile, { force = false, keepVersions = DEFAUL
196
197
  }
197
198
  }
198
199
 
199
- console.log(' Host ready.');
200
+ console.log(success(' Host ready.'));
200
201
  } finally {
201
202
  try { await rm(tempZip); } catch { /* ignore */ }
202
203
  }
@@ -242,7 +243,7 @@ async function patchWorkerConfigs(hostDir) {
242
243
  const old = config.description.defaultExecutablePath;
243
244
  config.description.defaultExecutablePath = bestPython;
244
245
  await writeFile(workerConfig, JSON.stringify(config, null, 4));
245
- console.log(` Patched python worker config: ${old} → ${bestPython}`);
246
+ console.log(dim(` Patched python worker config: ${old} → ${bestPython}`));
246
247
  }
247
248
  } catch { /* non-fatal */ }
248
249
  }
@@ -289,7 +290,7 @@ export async function ensureBundle(profile, { force = false, keepVersions = DEFA
289
290
  const maxVersion = profile.maxExtensionBundleVersion;
290
291
 
291
292
  // Fetch CDN index to get all available versions
292
- console.log(' Resolving extension bundle...');
293
+ console.log(info(' Resolving extension bundle...'));
293
294
  let allVersions;
294
295
  try {
295
296
  const indexUrl = `${BUNDLE_CDN}/${BUNDLE_ID}/index.json`;
@@ -298,7 +299,7 @@ export async function ensureBundle(profile, { force = false, keepVersions = DEFA
298
299
  allVersions = await res.json();
299
300
  } catch (err) {
300
301
  // If CDN is unreachable, check if we have any cached version that fits
301
- console.log(` ⚠️ Bundle index fetch failed (${err.message}), checking cache...`);
302
+ console.log(warning(` ⚠️ Bundle index fetch failed (${err.message}), checking cache...`));
302
303
  return findCachedBundle(bundleDir, range, maxVersion);
303
304
  }
304
305
 
@@ -313,7 +314,7 @@ export async function ensureBundle(profile, { force = false, keepVersions = DEFA
313
314
 
314
315
  const versionDir = join(bundleDir, bestVersion);
315
316
  if (!force && existsSync(join(versionDir, 'bundle.json'))) {
316
- console.log(` Bundle ${bestVersion} cached.`);
317
+ console.log(success(` Bundle ${bestVersion} cached.`));
317
318
  await updateVersionMetadata(BUNDLE_META_FILE, bestVersion, {
318
319
  lastUsedAt: toIsoNow(),
319
320
  sku: profile.name || profile.displayName,
@@ -329,7 +330,7 @@ export async function ensureBundle(profile, { force = false, keepVersions = DEFA
329
330
 
330
331
  // Download and extract
331
332
  const zipUrl = `${BUNDLE_CDN}/${BUNDLE_ID}/${bestVersion}/${BUNDLE_ID}.${bestVersion}_any-any.zip`;
332
- console.log(` Downloading bundle ${bestVersion}...`);
333
+ console.log(info(` Downloading bundle ${bestVersion}...`));
333
334
 
334
335
  await mkdir(versionDir, { recursive: true });
335
336
  const tempZip = join(versionDir, '_bundle.zip');
@@ -364,7 +365,7 @@ export async function ensureBundle(profile, { force = false, keepVersions = DEFA
364
365
  } else {
365
366
  execSync(`unzip -o -q "${tempZip}" -d "${versionDir}"`, { stdio: 'pipe' });
366
367
  }
367
- console.log(` Bundle ${bestVersion} ready.`);
368
+ console.log(success(` Bundle ${bestVersion} ready.`));
368
369
  } finally {
369
370
  try { await rm(tempZip); } catch { /* ignore */ }
370
371
  }
@@ -385,7 +386,7 @@ function findCachedBundle(bundleDir, range, maxVersion) {
385
386
  const cached = readdirSync(bundleDir).filter(d => existsSync(join(bundleDir, d, 'bundle.json')));
386
387
  const best = findBestBundleVersion(cached, range, maxVersion);
387
388
  if (best) {
388
- console.log(` Using cached bundle ${best}.`);
389
+ console.log(success(` Using cached bundle ${best}.`));
389
390
  }
390
391
  return best;
391
392
  }
@@ -395,4 +396,10 @@ export function getCachedHostVersions() {
395
396
  return readdirSync(HOST_CACHE).filter((v) => existsSync(join(HOST_CACHE, v, getHostExeName())));
396
397
  }
397
398
 
399
+ export function getCachedBundleVersions() {
400
+ const bundleDir = join(BUNDLE_CACHE, BUNDLE_ID);
401
+ if (!existsSync(bundleDir)) return [];
402
+ return readdirSync(bundleDir).filter((d) => existsSync(join(bundleDir, d, 'bundle.json')));
403
+ }
404
+
398
405
  export { getHostExeName, getPlatformRid, compareVersions, parseVersion, DEFAULT_KEEP_VERSIONS };
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { createServer as createHttpServer } from 'node:http';
19
+ import { url as urlColor, warning } from './colors.js';
19
20
 
20
21
  // ─── Tool registration (called per session) ─────────────────────────
21
22
 
@@ -341,9 +342,9 @@ export async function startLiveMcpServer(hostState, mcpPort) {
341
342
  httpServer.removeListener('error', onError);
342
343
  // Runtime errors after startup
343
344
  httpServer.on('error', (err) => {
344
- console.error(` ⚠️ MCP server error: ${err.message}`);
345
+ console.error(warning(` ⚠️ MCP server error: ${err.message}`));
345
346
  });
346
- console.log(` Functions Debug MCP Server: http://127.0.0.1:${port}/mcp`);
347
+ console.log(` Functions Debug MCP Server: ${urlColor(`http://127.0.0.1:${port}/mcp`)}`);
347
348
  resolve(httpServer);
348
349
  });
349
350
  }
@@ -1,4 +1,5 @@
1
1
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { readFileSync, existsSync } from 'node:fs';
2
3
  import { join, resolve as resolvePath, isAbsolute } from 'node:path';
3
4
  import { homedir } from 'node:os';
4
5
  import { fileURLToPath } from 'node:url';
@@ -110,3 +111,17 @@ export async function listProfiles() {
110
111
  }
111
112
  console.log(`\n Last updated: ${registry.updatedAt}`);
112
113
  }
114
+
115
+ /** Synchronous read: cached profiles → bundled fallback. No network. */
116
+ export function readProfilesSync() {
117
+ try {
118
+ if (existsSync(CACHE_FILE)) {
119
+ return JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
120
+ }
121
+ } catch { /* fall through */ }
122
+ try {
123
+ return JSON.parse(readFileSync(BUNDLED_PROFILES_PATH, 'utf-8'));
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
package/lib/warmup.js CHANGED
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { resolveProfile, listProfiles, fetchRegistryWithMeta } from './profile-resolver.js';
6
6
  import { ensureHost, ensureBundle, getHostExeName, getPlatformRid } from './host-manager.js';
7
+ import { title, info, success, warning, dim } from './colors.js';
7
8
 
8
9
  const FNX_DIR = join(homedir(), '.fnx');
9
10
  const META_FILE = join(FNX_DIR, '_meta.json');
@@ -57,9 +58,9 @@ async function runWarmup(args) {
57
58
  const rid = getPlatformRid();
58
59
 
59
60
  console.log();
60
- console.log(`fnx warmup — pre-downloading assets for ${all ? 'ALL SKUs' : 'offline use'}`);
61
+ console.log(title(`fnx warmup — pre-downloading assets for ${all ? 'ALL SKUs' : 'offline use'}`));
61
62
  console.log();
62
- console.log(` Platform: ${rid}`);
63
+ console.log(` ${dim('Platform:')} ${info(rid)}`);
63
64
 
64
65
  const meta = await loadMeta();
65
66
 
@@ -68,10 +69,10 @@ async function runWarmup(args) {
68
69
  console.log();
69
70
  const profile = await resolveProfile(skuName);
70
71
 
71
- console.log(` Target SKU: ${profile.displayName} (${skuName})`);
72
- console.log(` Host Version: ${profile.hostVersion}`);
72
+ console.log(` ${dim('Target SKU:')} ${info(`${profile.displayName} (${skuName})`)}`);
73
+ console.log(` ${dim('Host Version:')} ${info(profile.hostVersion)}`);
73
74
  if (profile.maxExtensionBundleVersion) {
74
- console.log(` Bundle Range: ${profile.extensionBundleVersion} (max: ${profile.maxExtensionBundleVersion})`);
75
+ console.log(` ${dim('Bundle Range:')} ${info(`${profile.extensionBundleVersion} (max: ${profile.maxExtensionBundleVersion})`)}`);
75
76
  }
76
77
  console.log();
77
78
 
@@ -99,9 +100,9 @@ async function runWarmup(args) {
99
100
  }
100
101
 
101
102
  console.log();
102
- console.log(` ✓ fnx start --sku ${skuName} will work offline.`);
103
+ console.log(success(` ✓ fnx start --sku ${skuName} will work offline.`));
103
104
  } catch (err) {
104
- console.error(` ⚠️ Warmup failed for ${skuName}: ${err.message}`);
105
+ console.error(warning(` ⚠️ Warmup failed for ${skuName}: ${err.message}`));
105
106
  }
106
107
  }
107
108
 
@@ -113,7 +114,7 @@ async function runWarmup(args) {
113
114
  }
114
115
 
115
116
  console.log();
116
- console.log(' Done.');
117
+ console.log(success(' Done.'));
117
118
  console.log();
118
119
  }
119
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrdmr/fnx-test",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "SKU-aware Azure Functions local emulator",
5
5
  "type": "module",
6
6
  "bin": {