@vrdmr/fnx-test 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/azurite-manager.js +5 -2
- package/lib/cli.js +127 -33
- package/lib/config-schema.js +167 -0
- package/lib/config.js +468 -0
- package/lib/doctor.js +241 -0
- package/lib/funcignore.js +117 -0
- package/lib/help-art.js +134 -0
- package/lib/host-launcher.js +3 -3
- package/lib/live-mcp-server.js +1 -1
- package/lib/pack.js +66 -16
- package/lib/secret-patterns.js +108 -0
- package/package.json +3 -1
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// doctor.js — Project diagnostics for fnx
|
|
2
|
+
//
|
|
3
|
+
// Validates project setup: config files, runtime, host compatibility,
|
|
4
|
+
// extension bundles, port availability, Azurite status, and resolved config.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { createConnection, createServer } from 'node:net';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { parse as parseYaml } from 'yaml';
|
|
13
|
+
|
|
14
|
+
import { validateStructure, resolveEnvVars, STRUCTURED_FIELDS } from './config-schema.js';
|
|
15
|
+
import { detectSecrets } from './secret-patterns.js';
|
|
16
|
+
import { title, info, success, error as errorColor, warning, dim, bold } from './colors.js';
|
|
17
|
+
|
|
18
|
+
const BLOB_PORT = 10000;
|
|
19
|
+
|
|
20
|
+
// ── Check definitions ──
|
|
21
|
+
|
|
22
|
+
async function checkHostJson(appPath) {
|
|
23
|
+
const p = join(appPath, 'host.json');
|
|
24
|
+
if (!existsSync(p)) {
|
|
25
|
+
return { name: 'host.json', status: 'fail', message: 'Not found — required for Azure Functions apps', fix: 'Create host.json with: { "version": "2.0" }' };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const content = JSON.parse(await readFile(p, 'utf-8'));
|
|
29
|
+
if (content.version !== '2.0') {
|
|
30
|
+
return { name: 'host.json', status: 'warn', message: `version is "${content.version}" (expected "2.0")`, fix: 'Set "version": "2.0" in host.json' };
|
|
31
|
+
}
|
|
32
|
+
return { name: 'host.json', status: 'pass', message: 'Present and valid' };
|
|
33
|
+
} catch {
|
|
34
|
+
return { name: 'host.json', status: 'fail', message: 'Invalid JSON', fix: 'Fix JSON syntax in host.json' };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function checkAppConfig(appPath) {
|
|
39
|
+
const yamlPath = join(appPath, 'app-config.yaml');
|
|
40
|
+
const legacyPath = join(appPath, 'app.config.json');
|
|
41
|
+
|
|
42
|
+
if (!existsSync(yamlPath)) {
|
|
43
|
+
if (existsSync(legacyPath)) {
|
|
44
|
+
return { name: 'app-config.yaml', status: 'warn', message: 'Not found — using legacy app.config.json', fix: 'Run: fnx config migrate' };
|
|
45
|
+
}
|
|
46
|
+
return { name: 'app-config.yaml', status: 'warn', message: 'Not found — will be auto-created on first fnx start', fix: 'Run: fnx config migrate (if local.settings.json exists)' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const raw = await readFile(yamlPath, 'utf-8');
|
|
51
|
+
const config = parseYaml(raw);
|
|
52
|
+
if (!config || typeof config !== 'object') {
|
|
53
|
+
return { name: 'app-config.yaml', status: 'fail', message: 'Empty or invalid YAML', fix: 'Fix YAML syntax or regenerate with: fnx config migrate' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { warnings } = validateStructure(config);
|
|
57
|
+
const { errors } = resolveEnvVars(config);
|
|
58
|
+
const secrets = detectSecrets(config);
|
|
59
|
+
|
|
60
|
+
if (secrets.length > 0) {
|
|
61
|
+
return { name: 'app-config.yaml', status: 'fail', message: `Contains ${secrets.length} secret(s) — ${secrets.map(s => s.path).join(', ')}`, fix: 'Move secrets to local.settings.json and remove from app-config.yaml' };
|
|
62
|
+
}
|
|
63
|
+
if (errors.length > 0) {
|
|
64
|
+
return { name: 'app-config.yaml', status: 'fail', message: errors[0], fix: 'Fix the configuration errors shown above' };
|
|
65
|
+
}
|
|
66
|
+
if (warnings.length > 0) {
|
|
67
|
+
return { name: 'app-config.yaml', status: 'warn', message: warnings.join('; ') };
|
|
68
|
+
}
|
|
69
|
+
return { name: 'app-config.yaml', status: 'pass', message: 'Present and valid' };
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return { name: 'app-config.yaml', status: 'fail', message: `Parse error: ${e.message}`, fix: 'Fix YAML syntax in app-config.yaml' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function checkLocalSettings(appPath) {
|
|
76
|
+
const p = join(appPath, 'local.settings.json');
|
|
77
|
+
if (!existsSync(p)) {
|
|
78
|
+
return { name: 'local.settings.json', status: 'warn', message: 'Not found — secrets and connection strings go here', fix: 'Create with: { "IsEncrypted": false, "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true" } }' };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
JSON.parse(await readFile(p, 'utf-8'));
|
|
82
|
+
return { name: 'local.settings.json', status: 'pass', message: 'Present and valid JSON' };
|
|
83
|
+
} catch {
|
|
84
|
+
return { name: 'local.settings.json', status: 'fail', message: 'Invalid JSON', fix: 'Fix JSON syntax in local.settings.json' };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function checkRuntime(appPath) {
|
|
89
|
+
// Try app-config.yaml first, then local.settings.json
|
|
90
|
+
const yamlPath = join(appPath, 'app-config.yaml');
|
|
91
|
+
const localPath = join(appPath, 'local.settings.json');
|
|
92
|
+
let runtime = null;
|
|
93
|
+
let source = null;
|
|
94
|
+
|
|
95
|
+
if (existsSync(yamlPath)) {
|
|
96
|
+
try {
|
|
97
|
+
const config = parseYaml(await readFile(yamlPath, 'utf-8'));
|
|
98
|
+
runtime = config?.runtime?.name;
|
|
99
|
+
if (runtime) source = 'app-config.yaml';
|
|
100
|
+
} catch { /* ignore parse errors — checkAppConfig handles this */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!runtime && existsSync(localPath)) {
|
|
104
|
+
try {
|
|
105
|
+
const settings = JSON.parse(await readFile(localPath, 'utf-8'));
|
|
106
|
+
runtime = settings?.Values?.FUNCTIONS_WORKER_RUNTIME;
|
|
107
|
+
if (runtime) source = 'local.settings.json';
|
|
108
|
+
} catch { /* ignore */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!runtime) {
|
|
112
|
+
return { name: 'Worker runtime', status: 'fail', message: 'Not configured', fix: 'Set runtime.name in app-config.yaml or FUNCTIONS_WORKER_RUNTIME in local.settings.json' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const runtimeSpec = STRUCTURED_FIELDS['runtime.name'];
|
|
116
|
+
if (runtimeSpec?.allowed && !runtimeSpec.allowed.includes(runtime)) {
|
|
117
|
+
return { name: 'Worker runtime', status: 'fail', message: `"${runtime}" is not a supported runtime`, fix: `Use one of: ${runtimeSpec.allowed.join(', ')}` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { name: 'Worker runtime', status: 'pass', message: `${runtime} (from ${source})` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function checkHostCache() {
|
|
124
|
+
const cacheDir = join(homedir(), '.fnx', 'hosts');
|
|
125
|
+
if (!existsSync(cacheDir)) {
|
|
126
|
+
return { name: 'Host cache', status: 'warn', message: 'No cached host binaries', fix: 'Run: fnx warmup' };
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const { readdirSync } = await import('node:fs');
|
|
130
|
+
const entries = readdirSync(cacheDir);
|
|
131
|
+
if (entries.length === 0) {
|
|
132
|
+
return { name: 'Host cache', status: 'warn', message: 'Cache directory empty', fix: 'Run: fnx warmup' };
|
|
133
|
+
}
|
|
134
|
+
return { name: 'Host cache', status: 'pass', message: `${entries.length} version(s) cached in ~/.fnx/hosts/` };
|
|
135
|
+
} catch {
|
|
136
|
+
return { name: 'Host cache', status: 'warn', message: 'Unable to read cache', fix: 'Check permissions on ~/.fnx/hosts/' };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isPortInUse(port, host = '127.0.0.1', timeoutMs = 1000) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const socket = createConnection({ port, host });
|
|
143
|
+
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
|
144
|
+
socket.on('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
|
145
|
+
socket.on('error', () => { clearTimeout(timer); socket.destroy(); resolve(false); });
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isPortFree(port) {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const srv = createServer();
|
|
152
|
+
srv.once('error', () => resolve(false));
|
|
153
|
+
srv.listen(port, '0.0.0.0', () => { srv.close(() => resolve(true)); });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function checkPorts() {
|
|
158
|
+
const ports = [
|
|
159
|
+
{ port: 7071, label: 'Host HTTP (7071)' },
|
|
160
|
+
{ port: 7072, label: 'MCP Server (7072)' },
|
|
161
|
+
];
|
|
162
|
+
const results = [];
|
|
163
|
+
for (const { port, label } of ports) {
|
|
164
|
+
const free = await isPortFree(port);
|
|
165
|
+
results.push(free
|
|
166
|
+
? { status: 'pass', detail: `${label} — available` }
|
|
167
|
+
: { status: 'warn', detail: `${label} — in use` }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const allFree = results.every(r => r.status === 'pass');
|
|
171
|
+
return {
|
|
172
|
+
name: 'Default ports',
|
|
173
|
+
status: allFree ? 'pass' : 'warn',
|
|
174
|
+
message: allFree ? '7071 and 7072 available' : results.filter(r => r.status === 'warn').map(r => r.detail).join('; '),
|
|
175
|
+
fix: allFree ? undefined : 'Use --port to specify a different port, or stop the process using the port',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function checkAzurite() {
|
|
180
|
+
const running = await isPortInUse(BLOB_PORT);
|
|
181
|
+
if (running) {
|
|
182
|
+
return { name: 'Azurite', status: 'pass', message: 'Running on default ports (10000–10002)' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check if azurite binary is available
|
|
186
|
+
const cachedBin = join(homedir(), '.fnx', 'tools', 'azurite', 'node_modules', '.bin', 'azurite');
|
|
187
|
+
if (existsSync(cachedBin)) {
|
|
188
|
+
return { name: 'Azurite', status: 'warn', message: 'Installed but not running — fnx start will auto-launch it', fix: 'Azurite will start automatically when needed' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
execSync('which azurite', { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
193
|
+
return { name: 'Azurite', status: 'warn', message: 'Installed globally but not running — fnx start will auto-launch it' };
|
|
194
|
+
} catch { /* not found */ }
|
|
195
|
+
|
|
196
|
+
return { name: 'Azurite', status: 'warn', message: 'Not installed — fnx will auto-install on first use', fix: 'Or install manually: npm install -g azurite' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Main doctor command ──
|
|
200
|
+
|
|
201
|
+
export async function runDoctor(appPath) {
|
|
202
|
+
console.log(`\n${bold(title('fnx doctor'))} — Project Diagnostics\n`);
|
|
203
|
+
console.log(dim(` Checking: ${appPath}\n`));
|
|
204
|
+
|
|
205
|
+
const checks = [
|
|
206
|
+
await checkHostJson(appPath),
|
|
207
|
+
await checkAppConfig(appPath),
|
|
208
|
+
await checkLocalSettings(appPath),
|
|
209
|
+
await checkRuntime(appPath),
|
|
210
|
+
await checkHostCache(),
|
|
211
|
+
await checkPorts(),
|
|
212
|
+
await checkAzurite(),
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
let hasIssues = false;
|
|
216
|
+
for (const check of checks) {
|
|
217
|
+
const icon = check.status === 'pass' ? success('✓') : check.status === 'warn' ? warning('⚠') : errorColor('✗');
|
|
218
|
+
const msg = check.status === 'pass' ? info(check.message) : check.status === 'warn' ? warning(check.message) : errorColor(check.message);
|
|
219
|
+
console.log(` ${icon} ${bold(check.name)}: ${msg}`);
|
|
220
|
+
if (check.fix && check.status !== 'pass') {
|
|
221
|
+
console.log(dim(` → ${check.fix}`));
|
|
222
|
+
}
|
|
223
|
+
if (check.status === 'fail') hasIssues = true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log('');
|
|
227
|
+
const failCount = checks.filter(c => c.status === 'fail').length;
|
|
228
|
+
const warnCount = checks.filter(c => c.status === 'warn').length;
|
|
229
|
+
const passCount = checks.filter(c => c.status === 'pass').length;
|
|
230
|
+
|
|
231
|
+
if (failCount === 0 && warnCount === 0) {
|
|
232
|
+
console.log(success(' All checks passed! Your project is ready for fnx start.'));
|
|
233
|
+
} else if (failCount === 0) {
|
|
234
|
+
console.log(warning(` ${passCount} passed, ${warnCount} warning(s). Project should work but check warnings above.`));
|
|
235
|
+
} else {
|
|
236
|
+
console.log(errorColor(` ${failCount} error(s), ${warnCount} warning(s). Fix errors above before running fnx start.`));
|
|
237
|
+
}
|
|
238
|
+
console.log('');
|
|
239
|
+
|
|
240
|
+
return failCount > 0 ? 1 : 0;
|
|
241
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// .funcignore support — gitignore-syntax file filtering for fnx pack
|
|
2
|
+
// Mirrors func core tools behavior from repo/src/Cli/func/Common/FileSystemHelpers.cs
|
|
3
|
+
// and repo/src/Cli/func/Helpers/PublishHelper.cs
|
|
4
|
+
|
|
5
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
6
|
+
import { join, relative } from 'node:path';
|
|
7
|
+
import ignore from 'ignore';
|
|
8
|
+
|
|
9
|
+
// Always excluded regardless of .funcignore (matches FileSystemHelpers.GetLocalFiles)
|
|
10
|
+
const DEFAULT_EXCLUDED_FILES = ['.funcignore', '.gitignore', 'local.settings.json', 'project.lock.json'];
|
|
11
|
+
const DEFAULT_EXCLUDED_DIRS = ['.git', '.vscode'];
|
|
12
|
+
|
|
13
|
+
// Common Python virtualenv directory names
|
|
14
|
+
const PYTHON_VENV_DIRS = ['.venv', 'venv', 'env', '.env', 'virtualenv', '.virtualenv'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Load and parse .funcignore from a project root directory.
|
|
18
|
+
* Returns an ignore instance with default exclusions + .funcignore patterns applied.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} projectRoot - Path to the function app project root
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {string} [options.runtime] - Runtime name (e.g. 'python') for runtime-specific auto-ignores
|
|
23
|
+
* @returns {import('ignore').Ignore}
|
|
24
|
+
*/
|
|
25
|
+
export async function loadFuncIgnore(projectRoot, options = {}) {
|
|
26
|
+
const ig = ignore();
|
|
27
|
+
|
|
28
|
+
// Default file exclusions
|
|
29
|
+
ig.add(DEFAULT_EXCLUDED_FILES);
|
|
30
|
+
|
|
31
|
+
// Default directory exclusions (trailing slash = directory pattern)
|
|
32
|
+
ig.add(DEFAULT_EXCLUDED_DIRS.map(d => d + '/'));
|
|
33
|
+
|
|
34
|
+
// Read .funcignore if it exists
|
|
35
|
+
try {
|
|
36
|
+
const content = await readFile(join(projectRoot, '.funcignore'), 'utf-8');
|
|
37
|
+
ig.add(content);
|
|
38
|
+
} catch {
|
|
39
|
+
// No .funcignore — only defaults apply
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Python: auto-detect virtualenv directories
|
|
43
|
+
if (options.runtime === 'python') {
|
|
44
|
+
await addPythonVenvIgnores(ig, projectRoot);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return ig;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Detect common Python virtualenv directories and add them to ignore patterns.
|
|
52
|
+
* Mirrors repo/src/Cli/func/Helpers/PythonHelpers.cs venv detection.
|
|
53
|
+
*/
|
|
54
|
+
async function addPythonVenvIgnores(ig, projectRoot) {
|
|
55
|
+
for (const venvName of PYTHON_VENV_DIRS) {
|
|
56
|
+
try {
|
|
57
|
+
const venvPath = join(projectRoot, venvName);
|
|
58
|
+
const stats = await stat(venvPath);
|
|
59
|
+
if (stats.isDirectory()) {
|
|
60
|
+
// Check for pyvenv.cfg or activate script as confirmation it's a real venv
|
|
61
|
+
try {
|
|
62
|
+
await stat(join(venvPath, 'pyvenv.cfg'));
|
|
63
|
+
ig.add(venvName + '/');
|
|
64
|
+
} catch {
|
|
65
|
+
try {
|
|
66
|
+
await stat(join(venvPath, 'bin', 'activate'));
|
|
67
|
+
ig.add(venvName + '/');
|
|
68
|
+
} catch {
|
|
69
|
+
try {
|
|
70
|
+
await stat(join(venvPath, 'Scripts', 'activate.bat'));
|
|
71
|
+
ig.add(venvName + '/');
|
|
72
|
+
} catch {
|
|
73
|
+
// Directory exists but isn't a venv — skip
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Directory doesn't exist — skip
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Walk a directory tree and return all files that pass the funcignore filter.
|
|
86
|
+
* Returns paths relative to sourceDir.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} sourceDir - Directory to walk
|
|
89
|
+
* @param {import('ignore').Ignore} funcIgnore - Loaded ignore instance
|
|
90
|
+
* @returns {Promise<string[]>} - Relative paths of included files
|
|
91
|
+
*/
|
|
92
|
+
export async function getFilteredFiles(sourceDir, funcIgnore) {
|
|
93
|
+
const results = [];
|
|
94
|
+
|
|
95
|
+
async function walk(dir) {
|
|
96
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const fullPath = join(dir, entry.name);
|
|
99
|
+
const relPath = relative(sourceDir, fullPath).replace(/\\/g, '/');
|
|
100
|
+
|
|
101
|
+
if (funcIgnore.ignores(relPath)) continue;
|
|
102
|
+
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
// Also check if directory itself is ignored (with trailing slash)
|
|
105
|
+
if (funcIgnore.ignores(relPath + '/')) continue;
|
|
106
|
+
await walk(fullPath);
|
|
107
|
+
} else {
|
|
108
|
+
results.push(relPath);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await walk(sourceDir);
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { DEFAULT_EXCLUDED_FILES, DEFAULT_EXCLUDED_DIRS, PYTHON_VENV_DIRS };
|
package/lib/help-art.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// lib/help-art.js — ASCII art footer + QR code for fnx help output
|
|
2
|
+
//
|
|
3
|
+
// Azure Functions ASCII art credit: marcduiker/azure-functions-ascii-art (MIT)
|
|
4
|
+
// QR encodes: https://aka.ms/func-docs
|
|
5
|
+
|
|
6
|
+
import { dim, url as urlColor, enabled, codes } from './colors.js';
|
|
7
|
+
|
|
8
|
+
// Two-tone lightning bolt colors from the original SVG:
|
|
9
|
+
// lightning1 (#FBF500) → bright yellow (\x1b[93m])
|
|
10
|
+
// lightning2 (#797C00) → dark yellow (\x1b[33m])
|
|
11
|
+
// brackets (#007F7E) → cyan (\x1b[36m])
|
|
12
|
+
const brightYellow = (s) => enabled ? `\x1b[93m${s}${codes.reset}` : s;
|
|
13
|
+
const darkYellow = (s) => enabled ? `\x1b[33m${s}${codes.reset}` : s;
|
|
14
|
+
const cyan = (s) => enabled ? `\x1b[36m${s}${codes.reset}` : s;
|
|
15
|
+
const brightBlue = (s) => enabled ? `\x1b[94m${s}${codes.reset}` : s;
|
|
16
|
+
|
|
17
|
+
// Each entry: { line, bright, dark } where bright/dark are the count of
|
|
18
|
+
// %-chars colored as lightning1 vs lightning2 (derived from the SVG coords).
|
|
19
|
+
const ART = [
|
|
20
|
+
{ line: ' %%%%%%', bright: 3, dark: 3 },
|
|
21
|
+
{ line: ' %%%%%%', bright: 3, dark: 3 },
|
|
22
|
+
{ line: ' @ %%%%%% @', bright: 3, dark: 3 },
|
|
23
|
+
{ line: ' @@ %%%%%% @@', bright: 3, dark: 3 },
|
|
24
|
+
{ line: ' @@@ %%%%%%%%%%% @@@', bright: 3, dark: 8 },
|
|
25
|
+
{ line: ' @@ %%%%%%%%%% @@', bright: 7, dark: 3 },
|
|
26
|
+
{ line: ' @@ %%%% @@', bright: 1, dark: 3 },
|
|
27
|
+
{ line: ' @@ %%% @@', bright: 1, dark: 2 },
|
|
28
|
+
{ line: ' @@ %% @@', bright: 1, dark: 1 },
|
|
29
|
+
{ line: ' %%', bright: 1, dark: 1 },
|
|
30
|
+
{ line: ' %', bright: 0, dark: 1 },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Pre-generated QR code for https://aka.ms/func-docs
|
|
34
|
+
// (qrcode npm, utf8 output, leading/trailing padding stripped)
|
|
35
|
+
const QR = [
|
|
36
|
+
`█▀▀▀▀▀█ ▀█▀█▄▄█ █▀▀▀▀▀█`,
|
|
37
|
+
`█ ███ █ █▄ ▀▄▄██ █ ███ █`,
|
|
38
|
+
`█ ▀▀▀ █ █ ▀▀▄ ▄▀ █ ▀▀▀ █`,
|
|
39
|
+
`▀▀▀▀▀▀▀ █ ▀▄▀ █ █ ▀▀▀▀▀▀▀`,
|
|
40
|
+
`█▄▀█▀█▀ ▄▄█▀▀█ ▀▄ ▀█▀▀▀▄`,
|
|
41
|
+
`▀▀█▀█▄▀▄ ▀▀ ▀ █▀█▄ ▀ ▀ ▀█`,
|
|
42
|
+
`█▀ ▄▄▄▀▀▀█ ▄▀▀ ▀▀▄▀▄▀█▀`,
|
|
43
|
+
`█ ▄▀▀█▀▄ ▀█ ▄ ▀▀▀▄███▀ ▀█`,
|
|
44
|
+
`▀ ▀ ▀ ▀▀█ █ ▀█▀▀█▀▀▀█▄▀`,
|
|
45
|
+
`█▀▀▀▀▀█ ▄ █▀▀█ █ ▀ █▄▀▀█`,
|
|
46
|
+
`█ ███ █ █ █▀█▀▀▀▀▀█▀█▄█▄▄`,
|
|
47
|
+
`█ ▀▀▀ █ ▀▄█▄▄▀▄▄█▄ ▄▄█▀ █`,
|
|
48
|
+
`▀▀▀▀▀▀▀ ▀▀▀ ▀▀ ▀▀▀▀▀▀`,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const DOCS_URL = 'https://aka.ms/func-docs';
|
|
52
|
+
const ART_WIDTH = 33; // widest art line (line 6)
|
|
53
|
+
|
|
54
|
+
// QR finder patterns (7×7 modules) in character-row space:
|
|
55
|
+
// top-left: rows 0-3, cols 0-6
|
|
56
|
+
// top-right: rows 0-3, cols 18-24
|
|
57
|
+
// bottom-left: rows 9-12, cols 0-6
|
|
58
|
+
function colorizeQRLine(line, rowIdx) {
|
|
59
|
+
if (!enabled) return line;
|
|
60
|
+
const chars = [...line];
|
|
61
|
+
let result = '';
|
|
62
|
+
let prevColor = null;
|
|
63
|
+
|
|
64
|
+
for (let c = 0; c < chars.length; c++) {
|
|
65
|
+
const isCorner = (rowIdx <= 3 && c <= 6) ||
|
|
66
|
+
(rowIdx <= 3 && c >= 18) ||
|
|
67
|
+
(rowIdx >= 9 && c <= 6);
|
|
68
|
+
const color = isCorner ? '\x1b[36m' : '\x1b[93m';
|
|
69
|
+
if (color !== prevColor) {
|
|
70
|
+
result += color;
|
|
71
|
+
prevColor = color;
|
|
72
|
+
}
|
|
73
|
+
result += chars[c];
|
|
74
|
+
}
|
|
75
|
+
return result + codes.reset;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function colorizeArtLine(line, bright, dark) {
|
|
79
|
+
if (!enabled) return line;
|
|
80
|
+
return line
|
|
81
|
+
.replace(/@+/g, (m) => cyan(m))
|
|
82
|
+
.replace(/%+/g, (m) => {
|
|
83
|
+
const b = m.slice(0, bright);
|
|
84
|
+
const d = m.slice(bright);
|
|
85
|
+
return (b ? brightYellow(b) : '') + (d ? darkYellow(d) : '');
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render the ASCII art footer with side-by-side QR code.
|
|
91
|
+
* Shown only when --ascii is passed. Adapts to terminal width.
|
|
92
|
+
*/
|
|
93
|
+
export function renderAsciiFooter() {
|
|
94
|
+
const cols = process.stdout.columns || 80;
|
|
95
|
+
const isTTY = process.stdout.isTTY !== false;
|
|
96
|
+
|
|
97
|
+
if (isTTY && cols >= 66) return renderSideBySide();
|
|
98
|
+
if (isTTY && cols >= 40) return renderArtOnly();
|
|
99
|
+
return ` ${dim('Docs:')} ${urlColor(DOCS_URL)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderArtOnly() {
|
|
103
|
+
const lines = ART.map(({ line, bright, dark }) =>
|
|
104
|
+
colorizeArtLine(line, bright, dark),
|
|
105
|
+
);
|
|
106
|
+
lines.push('');
|
|
107
|
+
lines.push(` ${dim('Docs:')} ${urlColor(DOCS_URL)}`);
|
|
108
|
+
return lines.join('\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderSideBySide() {
|
|
112
|
+
const topPad = Math.max(0, Math.floor((QR.length - ART.length) / 2)); // 1
|
|
113
|
+
const blankArt = ' '.repeat(ART_WIDTH);
|
|
114
|
+
const gap = ' ';
|
|
115
|
+
const totalRows = Math.max(QR.length, ART.length + topPad);
|
|
116
|
+
const lines = [];
|
|
117
|
+
|
|
118
|
+
for (let i = 0; i < totalRows; i++) {
|
|
119
|
+
const artIdx = i - topPad;
|
|
120
|
+
let artCol;
|
|
121
|
+
if (artIdx >= 0 && artIdx < ART.length) {
|
|
122
|
+
const { line, bright, dark } = ART[artIdx];
|
|
123
|
+
artCol = colorizeArtLine(line.padEnd(ART_WIDTH), bright, dark);
|
|
124
|
+
} else {
|
|
125
|
+
artCol = blankArt;
|
|
126
|
+
}
|
|
127
|
+
lines.push(artCol + gap + colorizeQRLine(QR[i] || '', i));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
lines.push('');
|
|
131
|
+
lines.push(blankArt + gap + dim('Scan for Azure Functions docs'));
|
|
132
|
+
lines.push(blankArt + gap + urlColor(DOCS_URL));
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
package/lib/host-launcher.js
CHANGED
|
@@ -54,7 +54,7 @@ export function createHostState() {
|
|
|
54
54
|
// ─── Python executable detection ────────────────────────────────────────
|
|
55
55
|
// The .NET host needs a compatible Python version. The host's bundled worker
|
|
56
56
|
// supports up to 3.13 (3.14 is unsupported). We check:
|
|
57
|
-
// 1. Explicit config (app
|
|
57
|
+
// 1. Explicit config (app-config.yaml PythonPath or env var)
|
|
58
58
|
// 2. .venv in the script root
|
|
59
59
|
// 3. System python3.13 → python3.12 → python3.11 → python3 → python
|
|
60
60
|
// This mirrors Core Tools behavior which also searches versioned binaries.
|
|
@@ -62,7 +62,7 @@ export function createHostState() {
|
|
|
62
62
|
const SUPPORTED_PYTHON_VERSIONS = ['3.13', '3.12', '3.11', '3.10', '3.9'];
|
|
63
63
|
|
|
64
64
|
function findPythonExecutable(scriptRoot, explicitPath) {
|
|
65
|
-
// 0. Explicit path from config (app
|
|
65
|
+
// 0. Explicit path from config (app-config.yaml PythonPath or env var)
|
|
66
66
|
if (explicitPath) {
|
|
67
67
|
if (existsSync(explicitPath)) return explicitPath;
|
|
68
68
|
// Maybe it's a command name on PATH
|
|
@@ -361,7 +361,7 @@ export async function launchHost(hostDir, opts) {
|
|
|
361
361
|
} catch { /* non-fatal */ }
|
|
362
362
|
} else {
|
|
363
363
|
console.error(warning('⚠️ Python runtime requested but no compatible python (3.9-3.13) found.'));
|
|
364
|
-
console.error(dim(' Set "PythonPath" in app
|
|
364
|
+
console.error(dim(' Set "PythonPath" in app-config.yaml configurations or FNX_PYTHON_PATH env var.'));
|
|
365
365
|
}
|
|
366
366
|
}
|
|
367
367
|
|
package/lib/live-mcp-server.js
CHANGED
|
@@ -200,7 +200,7 @@ Only HTTP functions can be invoked. For non-HTTP, upload data to the trigger sou
|
|
|
200
200
|
'get_app_settings',
|
|
201
201
|
{
|
|
202
202
|
title: 'Get App Settings',
|
|
203
|
-
description: `Get merged app settings (app
|
|
203
|
+
description: `Get merged app settings (app-config.yaml + local.settings.json) with secrets redacted.
|
|
204
204
|
Shows environment variables injected into the host process.`,
|
|
205
205
|
inputSchema: z.object({}),
|
|
206
206
|
},
|
package/lib/pack.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { basename, resolve as resolvePath, join } from 'node:path';
|
|
2
|
-
import { access, constants, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { access, constants, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
+
import { loadFuncIgnore, getFilteredFiles } from './funcignore.js';
|
|
5
6
|
|
|
6
7
|
const RUNTIME_ALIASES = new Map([
|
|
7
8
|
['node', 'node'],
|
|
@@ -48,7 +49,7 @@ function runCommand(command, args, options = {}) {
|
|
|
48
49
|
return new Promise((resolve, reject) => {
|
|
49
50
|
const child = spawn(command, args, {
|
|
50
51
|
cwd: options.cwd,
|
|
51
|
-
stdio: options.silent ? 'pipe' : 'inherit',
|
|
52
|
+
stdio: options.stdinFile ? ['pipe', 'inherit', 'pipe'] : (options.silent ? 'pipe' : 'inherit'),
|
|
52
53
|
env: process.env,
|
|
53
54
|
});
|
|
54
55
|
|
|
@@ -59,6 +60,13 @@ function runCommand(command, args, options = {}) {
|
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
// Pipe file content to stdin if specified
|
|
64
|
+
if (options.stdinFile) {
|
|
65
|
+
readFile(options.stdinFile, 'utf-8').then((content) => {
|
|
66
|
+
child.stdin.end(content);
|
|
67
|
+
}).catch(reject);
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
child.on('error', (err) => reject(err));
|
|
63
71
|
child.on('close', (code) => {
|
|
64
72
|
if (code === 0) return resolve();
|
|
@@ -75,6 +83,20 @@ async function zipDirectory(sourceDir, outputZip) {
|
|
|
75
83
|
await runCommand('zip', ['-r', '-q', outputZip, '.'], { cwd: sourceDir });
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
async function zipFilteredFiles(sourceDir, outputZip, files) {
|
|
87
|
+
// Write file list to a temp file for zip -@ (read names from stdin)
|
|
88
|
+
const listFile = join(tmpdir(), `fnx-pack-${Date.now()}.txt`);
|
|
89
|
+
try {
|
|
90
|
+
await writeFile(listFile, files.join('\n'), 'utf-8');
|
|
91
|
+
await runCommand('zip', ['-q', outputZip, '-@'], {
|
|
92
|
+
cwd: sourceDir,
|
|
93
|
+
stdinFile: listFile,
|
|
94
|
+
});
|
|
95
|
+
} finally {
|
|
96
|
+
await rm(listFile, { force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
async function stageJavaBuild(scriptRoot) {
|
|
79
101
|
await runCommand('mvn', ['clean', 'package', '-DskipTests'], { cwd: scriptRoot });
|
|
80
102
|
const targetDir = resolvePath(scriptRoot, 'target', 'azure-functions');
|
|
@@ -90,21 +112,39 @@ async function stageDotnetIsolatedBuild(scriptRoot, tempRoot) {
|
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
export async function detectRuntimeFromConfig(scriptRoot) {
|
|
93
|
-
|
|
94
|
-
const
|
|
115
|
+
// Try app-config.yaml first (new format), then fall back to app.config.json (legacy)
|
|
116
|
+
const { parse: parseYaml } = await import('yaml');
|
|
95
117
|
|
|
96
|
-
const
|
|
97
|
-
try {
|
|
98
|
-
const raw = await readFile(filePath, 'utf-8');
|
|
99
|
-
return JSON.parse(raw);
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
118
|
+
const tryRead = async (filePath) => {
|
|
119
|
+
try { return await readFile(filePath, 'utf-8'); } catch { return null; }
|
|
103
120
|
};
|
|
104
121
|
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
122
|
+
// app-config.yaml: runtime.name
|
|
123
|
+
const yamlContent = await tryRead(resolvePath(scriptRoot, 'app-config.yaml'));
|
|
124
|
+
if (yamlContent) {
|
|
125
|
+
const config = parseYaml(yamlContent);
|
|
126
|
+
if (config?.runtime?.name) return config.runtime.name;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Legacy app.config.json: Values.FUNCTIONS_WORKER_RUNTIME
|
|
130
|
+
const jsonContent = await tryRead(resolvePath(scriptRoot, 'app.config.json'));
|
|
131
|
+
if (jsonContent) {
|
|
132
|
+
try {
|
|
133
|
+
const config = JSON.parse(jsonContent);
|
|
134
|
+
if (config?.Values?.FUNCTIONS_WORKER_RUNTIME) return config.Values.FUNCTIONS_WORKER_RUNTIME;
|
|
135
|
+
} catch { /* ignore */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// local.settings.json fallback
|
|
139
|
+
const localContent = await tryRead(resolvePath(scriptRoot, 'local.settings.json'));
|
|
140
|
+
if (localContent) {
|
|
141
|
+
try {
|
|
142
|
+
const config = JSON.parse(localContent);
|
|
143
|
+
if (config?.Values?.FUNCTIONS_WORKER_RUNTIME) return config.Values.FUNCTIONS_WORKER_RUNTIME;
|
|
144
|
+
} catch { /* ignore */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
108
148
|
}
|
|
109
149
|
|
|
110
150
|
export async function packFunctionApp({ scriptRoot, runtime, outputPath, noBuild = false }) {
|
|
@@ -125,14 +165,24 @@ export async function packFunctionApp({ scriptRoot, runtime, outputPath, noBuild
|
|
|
125
165
|
}
|
|
126
166
|
}
|
|
127
167
|
|
|
128
|
-
|
|
129
|
-
await
|
|
168
|
+
// Always read .funcignore from PROJECT ROOT, not sourceDir (build output)
|
|
169
|
+
const funcIgnore = await loadFuncIgnore(root, { runtime: resolvedRuntime });
|
|
170
|
+
const files = await getFilteredFiles(sourceDir, funcIgnore);
|
|
171
|
+
|
|
172
|
+
console.log(`Packing runtime '${resolvedRuntime}' from ${sourceDir} (${files.length} files)`);
|
|
173
|
+
|
|
174
|
+
if (files.length === 0) {
|
|
175
|
+
throw new Error('No files to pack after applying .funcignore filters.');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await zipFilteredFiles(sourceDir, resolvedOutput, files);
|
|
130
179
|
console.log(`Created package: ${resolvedOutput}`);
|
|
131
180
|
|
|
132
181
|
return {
|
|
133
182
|
runtime: resolvedRuntime,
|
|
134
183
|
sourceDir,
|
|
135
184
|
outputPath: resolvedOutput,
|
|
185
|
+
filesIncluded: files.length,
|
|
136
186
|
};
|
|
137
187
|
} finally {
|
|
138
188
|
await rm(tempRoot, { recursive: true, force: true });
|