@vrdmr/fnx-test 0.4.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.
@@ -11,6 +11,7 @@ const TABLE_PORT = 10002;
11
11
  const AZURITE_INSTALL_DIR = join(homedir(), '.fnx', 'tools', 'azurite');
12
12
 
13
13
  let azuriteProcess = null;
14
+ let weStartedAzurite = false;
14
15
 
15
16
  /**
16
17
  * Determine whether Azurite is needed based on AzureWebJobsStorage value.
@@ -160,6 +161,7 @@ export async function ensureAzurite(mergedValues, opts = {}) {
160
161
  azuriteProcess = spawn(azuriteBin, azuriteArgs, {
161
162
  stdio: 'ignore',
162
163
  });
164
+ weStartedAzurite = true;
163
165
 
164
166
  azuriteProcess.on('error', (err) => {
165
167
  console.error(errorColor(`[fnx] Azurite failed to start: ${err.message}`));
@@ -188,11 +190,12 @@ export async function ensureAzurite(mergedValues, opts = {}) {
188
190
  }
189
191
 
190
192
  /**
191
- * Stop the managed Azurite process.
193
+ * Stop the managed Azurite process (only if fnx started it).
192
194
  */
193
195
  export function stopAzurite() {
194
- if (azuriteProcess) {
196
+ if (azuriteProcess && weStartedAzurite) {
195
197
  try { azuriteProcess.kill(); } catch { /* already dead */ }
196
198
  azuriteProcess = null;
199
+ weStartedAzurite = false;
197
200
  }
198
201
  }
package/lib/cli.js CHANGED
@@ -12,6 +12,7 @@ import { detectDotnetModel, printInProcessError } from './dotnet-detector.js';
12
12
  import { detectRuntimeFromConfig, packFunctionApp } from './pack.js';
13
13
  import { loadConfig, migrateConfig, validateConfig, showResolvedConfig } from './config.js';
14
14
  import { title, info, funcName, url as urlColor, success, error as errorColor, warning, dim, bold, highlightUrls } from './colors.js';
15
+ import { renderAsciiFooter } from './help-art.js';
15
16
 
16
17
  const FNX_HOME = join(homedir(), '.fnx');
17
18
  const VERSION_CHECK_FILE = join(FNX_HOME, 'version-check.json');
@@ -87,7 +88,8 @@ export async function main(args) {
87
88
  const cmd = args[0];
88
89
 
89
90
  if (cmd === '-h' || cmd === '--help' || cmd === 'help' || !cmd) {
90
- await printHelpWithVersionInfo();
91
+ const showAscii = args.includes('--ascii');
92
+ await printHelpWithVersionInfo(showAscii);
91
93
  process.exit(cmd ? 0 : 1);
92
94
  }
93
95
 
@@ -110,6 +112,14 @@ export async function main(args) {
110
112
  return;
111
113
  }
112
114
 
115
+ if (cmd === 'doctor') {
116
+ if (hasHelp(args.slice(1))) { printDoctorHelp(); return; }
117
+ const { runDoctor } = await import('./doctor.js');
118
+ const appPath = resolveAppPath(args, { requireHostJson: false });
119
+ const exitCode = await runDoctor(appPath);
120
+ process.exit(exitCode);
121
+ }
122
+
113
123
  if (cmd === 'config') {
114
124
  if (hasHelp(args.slice(1))) { printConfigHelp(); return; }
115
125
  const subCmd = args[1];
@@ -410,7 +420,7 @@ export async function readJsonFile(filePath) {
410
420
  }
411
421
  }
412
422
 
413
- async function printHelpWithVersionInfo() {
423
+ async function printHelpWithVersionInfo(showAscii = false) {
414
424
  const pkg = await getFnxPackage();
415
425
  const cachedHosts = getCachedHostVersions().sort(compareVersions);
416
426
  const cachedBundles = getCachedBundleVersions().sort(compareVersions);
@@ -449,6 +459,10 @@ ${dim('fnx Version:')} ${title(pkg.version)}`);
449
459
 
450
460
  console.log();
451
461
  printHelp();
462
+
463
+ if (showAscii) {
464
+ console.log('\n' + renderAsciiFooter());
465
+ }
452
466
  }
453
467
 
454
468
  function printHelp() {
@@ -456,6 +470,7 @@ function printHelp() {
456
470
 
457
471
  ${title('Commands:')}
458
472
  ${funcName('start')} Launch the Azure Functions host runtime for a specific SKU.
473
+ ${funcName('doctor')} Validate project setup and diagnose common issues.
459
474
  ${funcName('sync')} Sync cached host/extensions with current catalog profile.
460
475
  ${funcName('pack')} Package a Functions app into a deployment zip.
461
476
  ${funcName('config')} Show, validate, or migrate app configuration.
@@ -470,6 +485,7 @@ ${title('Common Options:')}
470
485
  ${success('--verbose')} Show all host output (unfiltered).
471
486
  ${success('-v')}, ${success('--version')} Display the version of fnx.
472
487
  ${success('-h')}, ${success('--help')} Display this help information.
488
+ ${success('--ascii')} Show ASCII art + QR code (use with -h).
473
489
 
474
490
  ${title('Start Options:')} ${dim('(fnx start)')}
475
491
  ${success('--app-path')} <dir> Path to the function app directory (default: cwd).
@@ -499,6 +515,7 @@ ${title('Examples:')}
499
515
  fnx start Start with default SKU (flex)
500
516
  fnx start --sku windows-consumption Emulate Windows Consumption
501
517
  fnx start --sku flex --port 8080 Custom port
518
+ fnx doctor Validate project setup
502
519
  fnx pack --app-path ./my-app Package function app as zip
503
520
  fnx sync host --force Force re-download host binary
504
521
  fnx warmup --all Pre-download all SKUs
@@ -653,3 +670,31 @@ ${title('Examples:')}
653
670
  fnx config validate Check app-config.yaml for errors
654
671
  fnx config validate --app-path ./my-app Validate a specific app`.trim());
655
672
  }
673
+
674
+ function printDoctorHelp() {
675
+ console.log(`
676
+ ${bold(title('fnx doctor'))} — Validate project setup and diagnose common issues.
677
+
678
+ ${title('Usage:')} fnx doctor [options]
679
+
680
+ ${title('Checks:')}
681
+ • host.json Present and valid (version 2.0)
682
+ • app-config.yaml Schema valid, no secrets, runtime configured
683
+ • local.settings.json Present and valid JSON
684
+ • Worker runtime Detected from config files
685
+ • Host cache Cached host binaries in ~/.fnx/hosts/
686
+ • Default ports 7071 (HTTP) and 7072 (MCP) availability
687
+ • Azurite Storage emulator status
688
+
689
+ ${title('Options:')}
690
+ ${success('--app-path')} <dir> Path to the function app directory (default: cwd).
691
+ ${success('-h')}, ${success('--help')} Show this help message.
692
+
693
+ ${title('Exit Codes:')}
694
+ ${success('0')} All checks passed (or warnings only)
695
+ ${errorColor('1')} One or more checks failed
696
+
697
+ ${title('Examples:')}
698
+ fnx doctor Check current directory
699
+ fnx doctor --app-path ./my-app Check a specific app`.trim());
700
+ }
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 };
@@ -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/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');
@@ -143,14 +165,24 @@ export async function packFunctionApp({ scriptRoot, runtime, outputPath, noBuild
143
165
  }
144
166
  }
145
167
 
146
- console.log(`Packing runtime '${resolvedRuntime}' from ${sourceDir}`);
147
- await zipDirectory(sourceDir, resolvedOutput);
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);
148
179
  console.log(`Created package: ${resolvedOutput}`);
149
180
 
150
181
  return {
151
182
  runtime: resolvedRuntime,
152
183
  sourceDir,
153
184
  outputPath: resolvedOutput,
185
+ filesIncluded: files.length,
154
186
  };
155
187
  } finally {
156
188
  await rm(tempRoot, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrdmr/fnx-test",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "SKU-aware Azure Functions local emulator",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.26.0",
37
+ "ignore": "^7.0.5",
37
38
  "yaml": "^2.8.2",
38
39
  "zod": "^4.3.6"
39
40
  }