@vrdmr/fnx-test 0.4.3 → 0.5.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.
@@ -60,6 +60,7 @@ export function createHostState() {
60
60
  // This mirrors Core Tools behavior which also searches versioned binaries.
61
61
 
62
62
  const SUPPORTED_PYTHON_VERSIONS = ['3.13', '3.12', '3.11', '3.10', '3.9'];
63
+ const IS_WINDOWS = platform() === 'win32';
63
64
 
64
65
  function findPythonExecutable(scriptRoot, explicitPath) {
65
66
  // 0. Explicit path from config (app-config.yaml PythonPath or env var)
@@ -84,25 +85,48 @@ function findPythonExecutable(scriptRoot, explicitPath) {
84
85
  // venv python is too new, fall through to versioned search
85
86
  } catch { /* fall through */ }
86
87
  }
87
- if (existsSync(venvPythonWin)) return venvPythonWin;
88
+ if (existsSync(venvPythonWin)) {
89
+ // Windows: verify venv python version is supported
90
+ try {
91
+ const ver = execSync(`"${venvPythonWin}" --version`, { encoding: 'utf-8' }).trim();
92
+ const minor = ver.match(/Python 3\.(\d+)/)?.[1];
93
+ if (minor && parseInt(minor) <= 13) return venvPythonWin;
94
+ } catch { /* fall through */ }
95
+ }
88
96
 
89
97
  // 2. Check for a venv/ directory
90
98
  const venvAlt = join(scriptRoot, 'venv', 'bin', 'python');
99
+ const venvAltWin = join(scriptRoot, 'venv', 'Scripts', 'python.exe');
91
100
  if (existsSync(venvAlt)) return venvAlt;
101
+ if (existsSync(venvAltWin)) return venvAltWin;
92
102
 
93
103
  // 3. Search for versioned python binaries (most compatible first)
94
104
  for (const ver of SUPPORTED_PYTHON_VERSIONS) {
95
- const cmd = `python${ver}`;
96
- try {
97
- execSync(`${cmd} --version`, { stdio: 'ignore' });
98
- return cmd;
99
- } catch { /* not found */ }
105
+ if (IS_WINDOWS) {
106
+ // Windows: use 'py -3.XX' launcher to resolve actual executable path
107
+ try {
108
+ const pyPath = execSync(`py -${ver} -c "import sys; print(sys.executable)"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
109
+ if (pyPath && existsSync(pyPath)) return pyPath;
110
+ } catch { /* not found */ }
111
+ } else {
112
+ const cmd = `python${ver}`;
113
+ try {
114
+ execSync(`${cmd} --version`, { stdio: 'ignore' });
115
+ return cmd;
116
+ } catch { /* not found */ }
117
+ }
100
118
  }
101
119
 
102
120
  // 4. Fall back to python3 / python (may be unsupported version)
103
- for (const cmd of ['python3', 'python']) {
121
+ const fallbacks = IS_WINDOWS ? ['python', 'python3'] : ['python3', 'python'];
122
+ for (const cmd of fallbacks) {
104
123
  try {
105
124
  execSync(`${cmd} --version`, { stdio: 'ignore' });
125
+ if (IS_WINDOWS) {
126
+ // Windows: resolve to absolute path
127
+ const pyPath = execSync(`${cmd} -c "import sys; print(sys.executable)"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
128
+ if (pyPath && existsSync(pyPath)) return pyPath;
129
+ }
106
130
  return cmd;
107
131
  } catch { /* not found */ }
108
132
  }
@@ -150,18 +174,38 @@ function createLogFilter(verbose, hostState) {
150
174
  const levelMatch = line.match(/^(trce|dbug|info|warn|fail|crit): (.+)/);
151
175
 
152
176
  if (levelMatch) {
153
- const [, level] = levelMatch;
177
+ const [, level, category] = levelMatch;
154
178
  lastLogLevel = level;
155
179
 
156
- // In clean mode, suppress all structured log headers.
157
- // The only info we surface is the function list (extracted separately)
158
- // and user-facing messages from Worker.LanguageWorkerChannel.
180
+ // Always show warnings and errors
181
+ if (level === 'warn' || level === 'fail' || level === 'crit') {
182
+ lastLogShown = true;
183
+ // Format: "[WARN] Category message" or "[ERROR] Category message"
184
+ // Warnings in yellow, errors/critical in red
185
+ if (level === 'warn') {
186
+ return warning(`[WARN] ${category}`);
187
+ } else {
188
+ const levelLabel = level === 'crit' ? 'CRIT' : 'ERROR';
189
+ return errorColor(`[${levelLabel}] ${category}`);
190
+ }
191
+ }
192
+
193
+ // Show function execution logs (Executing/Executed)
194
+ if (category.includes('Host.Function.Console') ||
195
+ category.includes('Function.') ||
196
+ line.includes("Executing '") ||
197
+ line.includes("Executed '")) {
198
+ lastLogShown = true;
199
+ return null; // Show the continuation message with the details
200
+ }
201
+
202
+ // Show worker initialization
159
203
  if (line.includes('Worker process started and initialized')) {
160
204
  lastLogShown = true;
161
205
  return null; // Show the continuation message
162
206
  }
163
207
 
164
- // Suppress everything else — system, host, framework, even warnings
208
+ // Suppress everything else — system, host, framework logs
165
209
  lastLogShown = false;
166
210
  return null;
167
211
  }
@@ -172,13 +216,19 @@ function createLogFilter(verbose, hostState) {
172
216
  const msg = line.trim();
173
217
  if (msg === '' || msg.startsWith('at ') || msg.startsWith('---') ||
174
218
  msg.startsWith('{') || msg.startsWith('}') || msg.startsWith('"')) return null;
219
+
220
+ // Format execution messages nicely
221
+ if (msg.includes("Executing '") || msg.includes("Executed '")) {
222
+ return ` → ${msg}`;
223
+ }
175
224
  return msg;
176
225
  }
177
226
 
178
227
  // Non-structured lines (plain text from ASP.NET: "Now listening on:", etc.)
179
228
  if (line.trim() === '') return null;
180
229
  if (line.startsWith('{') || line.startsWith('}') || line.startsWith('"')) return null;
181
- return line;
230
+ // Replace 0.0.0.0 with localhost for friendlier output
231
+ return line.replace(/0\.0\.0\.0/g, 'localhost');
182
232
  }
183
233
 
184
234
  function extractFunctionInfo(line) {
@@ -189,7 +239,21 @@ function createLogFilter(verbose, hostState) {
189
239
  if (hostState) hostState.httpFunctions = [...httpFunctions];
190
240
  }
191
241
 
192
- // Worker indexing JSON: extract non-HTTP trigger types from the indexed metadata
242
+ // Node.js v4 worker: "Loaded entry point file "dist/src/functions/<name>.js""
243
+ // Extract function name from the file path - this is a non-HTTP function being loaded
244
+ const nodeEntryMatch = line.match(/Loaded entry point file "([^"]+)"/);
245
+ if (nodeEntryMatch) {
246
+ const entryPath = nodeEntryMatch[1];
247
+ // Extract filename without extension as function name
248
+ const fileName = entryPath.split(/[/\\]/).pop()?.replace(/\.[jt]s$/, '');
249
+ if (fileName && !httpFunctions.some(f => f.name === fileName) && !nonHttpFunctions.some(f => f.name === fileName)) {
250
+ // Add as non-HTTP function (blob, timer, queue, etc.)
251
+ nonHttpFunctions.push({ name: fileName, triggerType: 'trigger' });
252
+ if (hostState) hostState.nonHttpFunctions = [...nonHttpFunctions];
253
+ }
254
+ }
255
+
256
+ // Worker indexing JSON (Python): extract non-HTTP trigger types from the indexed metadata
193
257
  // Format: {"message": "Successfully indexed function app.", "functions": "Function Name: X, Function Binding: [('triggerType', ...)] ..."}
194
258
  if (line.includes('Successfully indexed function app')) {
195
259
  const jsonStart = line.indexOf('{');
@@ -207,7 +271,7 @@ function createLogFilter(verbose, hostState) {
207
271
  // Find all trigger bindings and pick the non-HTTP one if present
208
272
  const allTriggers = [...bindings.matchAll(/\('(\w*[Tt]rigger)', '[^']*', '[^']*'\)/g)];
209
273
  const nonHttpTrigger = allTriggers.find(m => m[1] !== 'httpTrigger');
210
- if (nonHttpTrigger) {
274
+ if (nonHttpTrigger && !nonHttpFunctions.some(f => f.name === name)) {
211
275
  nonHttpFunctions.push({ name, triggerType: nonHttpTrigger[1] });
212
276
  if (hostState) hostState.nonHttpFunctions = [...nonHttpFunctions];
213
277
  }
@@ -248,30 +312,36 @@ function createLogFilter(verbose, hostState) {
248
312
  }
249
313
 
250
314
  function extractListeningUrl(line) {
315
+ // Capture the base URL when host starts listening
251
316
  const match = line.match(/Now listening on: (.+)/);
252
- if (match && !functionsShown) {
253
- functionsShown = true;
317
+ if (match) {
254
318
  const baseUrl = match[1].replace('0.0.0.0', 'localhost');
255
-
256
- // Update host state
257
319
  if (hostState) {
258
320
  hostState.state = 'Running';
259
321
  hostState.baseUrl = baseUrl;
260
322
  }
323
+ }
261
324
 
325
+ // Show functions list when "Application started" appears (after indexing is complete)
326
+ if (line.includes('Application started') && !functionsShown) {
327
+ functionsShown = true;
328
+ const baseUrl = hostState?.baseUrl || 'http://localhost:7071';
329
+
330
+ console.log(title('\nFunctions:\n'));
262
331
  if (httpFunctions.length > 0 || nonHttpFunctions.length > 0) {
263
- console.log(title('\nFunctions:\n'));
264
332
  for (const fn of httpFunctions) {
265
333
  console.log(`\t${funcName(fn.name)}: [${fn.methods}] ${urlColor(`${baseUrl}/${fn.route}`)}`);
266
334
  }
267
335
  for (const fn of nonHttpFunctions) {
268
336
  console.log(`\t${funcName(fn.name)}: ${fn.triggerType}`);
269
337
  }
270
- if (!verbose) {
271
- console.log(dim('\nFor detailed output, run fnx with --verbose flag.'));
272
- }
273
- console.log();
338
+ } else {
339
+ console.log(dim(`\tNo functions found. Base URL: ${urlColor(baseUrl)}`));
274
340
  }
341
+ if (!verbose) {
342
+ console.log(dim('\nFor detailed output, run fnx with --verbose flag.'));
343
+ }
344
+ console.log();
275
345
  }
276
346
  }
277
347
 
@@ -318,7 +388,6 @@ export async function launchHost(hostDir, opts) {
318
388
  ASPNETCORE_URLS: `http://0.0.0.0:${opts.port}`,
319
389
  FUNCTIONS_WORKER_RUNTIME: opts.workerRuntime,
320
390
  'AzureFunctionsJobHost:extensionBundle:version': opts.extensionBundleVersion,
321
- AzureWebJobsFeatureFlags: 'EnableWorkerIndexing',
322
391
  // Enable extension bundle auto-download (host checks IsCoreTools())
323
392
  FUNCTIONS_CORETOOLS_ENVIRONMENT: 'true',
324
393
  // Set bundle download/cache path under ~/.fnx/bundles/
@@ -374,6 +443,11 @@ export async function launchHost(hostDir, opts) {
374
443
  console.log(`${dim('Host Version:')} ${info(`${opts.profile.hostVersion} (${opts.profile.displayName})`)}`);
375
444
  if (opts.workerRuntime === 'python' && env['languageWorkers__python__defaultExecutablePath']) {
376
445
  console.log(`${dim('Python:')} ${info(`${env['languageWorkers__python__defaultExecutablePath']} (${env['FUNCTIONS_WORKER_RUNTIME_VERSION'] || 'unknown'})`)}`);
446
+ if (IS_WINDOWS) {
447
+ console.log();
448
+ console.log(warning('⚠️ Python on Windows is for local development only.'));
449
+ console.log(dim(' Azure Functions does not support Python on Windows hosting plans.'));
450
+ }
377
451
  }
378
452
  console.log();
379
453
 
@@ -389,9 +463,19 @@ export async function launchHost(hostDir, opts) {
389
463
 
390
464
  hostState.pid = child.pid;
391
465
 
392
- // Kill the entire process group (host + Python/Node workers it spawns)
466
+ const isWindows = platform() === 'win32';
467
+
468
+ // Kill the entire process tree (host + Python/Node workers it spawns)
393
469
  function killHostGroup(signal) {
394
- try { process.kill(-child.pid, signal); } catch { /* already dead */ }
470
+ try {
471
+ if (isWindows) {
472
+ // Windows: use taskkill to kill the process tree
473
+ execSync(`taskkill /T /F /PID ${child.pid}`, { stdio: 'ignore' });
474
+ } else {
475
+ // Unix: kill the process group (negative PID)
476
+ process.kill(-child.pid, signal);
477
+ }
478
+ } catch { /* already dead */ }
395
479
  }
396
480
 
397
481
  // Ensure the host process group is killed if Node exits unexpectedly
@@ -415,17 +499,24 @@ export async function launchHost(hostDir, opts) {
415
499
  stopAzurite();
416
500
  killHostGroup(signal);
417
501
  if (hostState._mcpServer) hostState._mcpServer.close();
418
- // Give the host 2s to shut down gracefully, then force kill the group
502
+ // Give the host 2s to shut down gracefully, then force kill
419
503
  const forceTimer = setTimeout(() => {
420
504
  killHostGroup('SIGKILL');
421
505
  process.exit(0);
422
506
  }, 2000);
423
507
  forceTimer.unref();
424
- child.once('exit', () => process.exit(0));
508
+ child.once('exit', () => {
509
+ clearTimeout(forceTimer);
510
+ process.exit(0);
511
+ });
425
512
  }
426
513
 
427
514
  process.on('SIGINT', () => cleanup('SIGINT'));
428
515
  process.on('SIGTERM', () => cleanup('SIGTERM'));
516
+ // Windows: handle the 'SIGBREAK' (Ctrl+Break) signal as well
517
+ if (platform() === 'win32') {
518
+ process.on('SIGBREAK', () => cleanup('SIGTERM'));
519
+ }
429
520
 
430
521
  return new Promise((resolve, reject) => {
431
522
  child.on('error', (err) => {
@@ -454,4 +545,4 @@ export async function launchHost(hostDir, opts) {
454
545
  opts.onHostLaunched(hostState, child);
455
546
  }
456
547
  });
457
- }
548
+ }
@@ -21,7 +21,7 @@ const BUNDLED_MANIFEST_FILE = join(__dirname, '..', '..', 'templates', 'manifest
21
21
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
22
22
 
23
23
  // Allowed GitHub organizations for template repositories
24
- const ALLOWED_ORGS = ['azure', 'azure-samples'];
24
+ export const ALLOWED_ORGS = ['azure', 'azure-samples', 'microsoft'];
25
25
 
26
26
  /**
27
27
  * Filter templates to only include those from trusted Azure orgs
@@ -48,14 +48,15 @@ function filterTrustedTemplates(templates, defaultRepoUrl, verbose) {
48
48
  }
49
49
 
50
50
  /**
51
- * Fetch manifest from URL with caching and bundled fallback
52
- * @param {string} url - Manifest URL
51
+ * Fetch manifest from URL with caching, backup URL, and bundled fallback
52
+ * @param {string} url - Primary manifest URL (CDN)
53
53
  * @param {Object} options - Options
54
54
  * @param {boolean} options.verbose - Show detailed logging
55
+ * @param {string} options.backupUrl - Backup URL to try if CDN fails
55
56
  * @returns {Promise<{templates: Array}>} Parsed manifest
56
57
  */
57
58
  export async function fetchManifest(url, options = {}) {
58
- const { verbose } = options;
59
+ const { verbose, backupUrl } = options;
59
60
 
60
61
  // Check if cached manifest is still valid
61
62
  const cached = await loadCachedManifest();
@@ -113,19 +114,37 @@ export async function fetchManifest(url, options = {}) {
113
114
 
114
115
  return manifest;
115
116
  } catch (err) {
116
- // Network error — fall back to cache if available
117
+ // CDN failed — fall back to cache first if available
117
118
  if (cached) {
118
- if (verbose) console.log(` Network error, using stale cache: ${err.message}`);
119
- // Filter cached manifest too
119
+ if (verbose) console.log(` CDN failed (${err.message}), using stale cache`);
120
120
  cached.manifest.templates = filterTrustedTemplates(cached.manifest.templates, cached.manifest.repositoryUrl, verbose);
121
121
  return cached.manifest;
122
122
  }
123
123
 
124
- // Fall back to bundled manifest
124
+ // No cache try backup URL (GitHub) if provided
125
+ if (backupUrl) {
126
+ if (verbose) console.log(` CDN failed (${err.message}), trying GitHub backup...`);
127
+ try {
128
+ const backupResponse = await fetch(backupUrl);
129
+ if (backupResponse.ok) {
130
+ const manifest = await backupResponse.json();
131
+ if (manifest && Array.isArray(manifest.templates)) {
132
+ manifest.templates = filterTrustedTemplates(manifest.templates, manifest.repositoryUrl, verbose);
133
+ if (verbose) console.log(` Fetched manifest from GitHub backup`);
134
+ // Cache the backup result too
135
+ await cacheManifest(manifest, { fetchedAt: Date.now(), url: backupUrl });
136
+ return manifest;
137
+ }
138
+ }
139
+ } catch (backupErr) {
140
+ if (verbose) console.log(` GitHub backup also failed: ${backupErr.message}`);
141
+ }
142
+ }
143
+
144
+ // Last resort — fall back to bundled manifest
125
145
  const bundled = await loadBundledManifest();
126
146
  if (bundled) {
127
- if (verbose) console.log(` CDN unavailable, using bundled manifest`);
128
- // Filter bundled manifest too
147
+ if (verbose) console.log(` All sources unavailable, using bundled manifest`);
129
148
  bundled.templates = filterTrustedTemplates(bundled.templates, bundled.repositoryUrl, verbose);
130
149
  return bundled;
131
150
  }