@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.
- package/lib/azurite-manager.js +67 -9
- package/lib/chat/index.js +281 -0
- package/lib/cli.js +24 -0
- package/lib/config.js +2 -8
- package/lib/host-launcher.js +121 -30
- package/lib/init/manifest.js +29 -10
- package/lib/init/prompts.js +459 -69
- package/lib/init/scaffold.js +56 -36
- package/lib/init.js +151 -10
- package/lib/runtimes.js +11 -22
- package/lib/setup/agent-detect.js +92 -0
- package/lib/setup/detect.js +117 -0
- package/lib/setup/index.js +572 -0
- package/lib/utils.js +27 -0
- package/manifests/default.yaml +124 -0
- package/manifests/skills/fnx-best-practices/SKILL.md +64 -0
- package/manifests/skills/fnx-best-practices/references/azure-functions-docs.md +60 -0
- package/manifests/skills/fnx-best-practices/references/fnx-specific.md +97 -0
- package/manifests/skills/fnx-create-function/SKILL.md +133 -0
- package/manifests/skills/fnx-create-function/references/templates.md +105 -0
- package/manifests/skills/fnx-diagnostics/SKILL.md +84 -0
- package/manifests/skills/fnx-diagnostics/references/diagnostic-checklist.md +59 -0
- package/manifests/skills/fnx-diagnostics/references/fnx-error-patterns.md +71 -0
- package/manifests/skills/fnx-feedback/SKILL.md +142 -0
- package/manifests/skills/fnx-intro/SKILL.md +83 -0
- package/manifests/skills/fnx-intro/references/fnx-commands.md +157 -0
- package/manifests/skills/fnx-intro/references/roadmap.md +60 -0
- package/package.json +3 -1
- package/profiles/sku-profiles.json +6 -6
package/lib/host-launcher.js
CHANGED
|
@@ -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))
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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', () =>
|
|
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
|
+
}
|
package/lib/init/manifest.js
CHANGED
|
@@ -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 -
|
|
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
|
-
//
|
|
117
|
+
// CDN failed — fall back to cache first if available
|
|
117
118
|
if (cached) {
|
|
118
|
-
if (verbose) console.log(`
|
|
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
|
-
//
|
|
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(`
|
|
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
|
}
|