@vrdmr/fnx-test 0.4.0 → 0.4.2
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 +23 -11
- package/lib/cli.js +47 -2
- package/lib/doctor.js +243 -0
- package/lib/funcignore.js +117 -0
- package/lib/help-art.js +134 -0
- package/lib/pack.js +71 -6
- package/package.json +2 -1
package/lib/azurite-manager.js
CHANGED
|
@@ -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.
|
|
@@ -59,14 +60,18 @@ async function isAzuriteRunning() {
|
|
|
59
60
|
* Returns the path/command or null.
|
|
60
61
|
*/
|
|
61
62
|
function findAzurite() {
|
|
62
|
-
// 1. Check the fnx tools cache first
|
|
63
|
-
const
|
|
63
|
+
// 1. Check the fnx tools cache first (Windows uses .cmd shims)
|
|
64
|
+
const isWin = process.platform === 'win32';
|
|
65
|
+
const cachedBin = join(AZURITE_INSTALL_DIR, 'node_modules', '.bin', isWin ? 'azurite.cmd' : 'azurite');
|
|
64
66
|
if (existsSync(cachedBin)) return cachedBin;
|
|
65
67
|
|
|
66
|
-
// 2. Check global PATH
|
|
68
|
+
// 2. Check global PATH (use 'where' on Windows, 'which' on Unix)
|
|
67
69
|
try {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
+
const whichCmd = isWin ? 'where azurite' : 'which azurite';
|
|
71
|
+
const result = execSync(whichCmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
|
|
72
|
+
// 'where' on Windows may return multiple lines; take the first
|
|
73
|
+
const firstLine = result.split(/\r?\n/)[0];
|
|
74
|
+
if (firstLine) return firstLine;
|
|
70
75
|
} catch { /* not found */ }
|
|
71
76
|
|
|
72
77
|
return null;
|
|
@@ -97,7 +102,9 @@ function installAzurite() {
|
|
|
97
102
|
return null;
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
|
|
105
|
+
// Use .cmd on Windows (npm creates .cmd shims for bin entries)
|
|
106
|
+
const binName = process.platform === 'win32' ? 'azurite.cmd' : 'azurite';
|
|
107
|
+
const installed = join(AZURITE_INSTALL_DIR, 'node_modules', '.bin', binName);
|
|
101
108
|
if (existsSync(installed)) {
|
|
102
109
|
console.log(info('[fnx] Azurite installed successfully.'));
|
|
103
110
|
return installed;
|
|
@@ -157,9 +164,13 @@ export async function ensureAzurite(mergedValues, opts = {}) {
|
|
|
157
164
|
// Ensure data directory exists
|
|
158
165
|
mkdirSync(join(homedir(), '.fnx', 'azurite-data'), { recursive: true });
|
|
159
166
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
// On Windows, use shell:true so cmd.exe can resolve .cmd shims from PATH-based paths
|
|
168
|
+
const spawnOptions = process.platform === 'win32'
|
|
169
|
+
? { stdio: 'ignore', shell: true }
|
|
170
|
+
: { stdio: 'ignore' };
|
|
171
|
+
|
|
172
|
+
azuriteProcess = spawn(azuriteBin, azuriteArgs, spawnOptions);
|
|
173
|
+
weStartedAzurite = true;
|
|
163
174
|
|
|
164
175
|
azuriteProcess.on('error', (err) => {
|
|
165
176
|
console.error(errorColor(`[fnx] Azurite failed to start: ${err.message}`));
|
|
@@ -188,11 +199,12 @@ export async function ensureAzurite(mergedValues, opts = {}) {
|
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
/**
|
|
191
|
-
* Stop the managed Azurite process.
|
|
202
|
+
* Stop the managed Azurite process (only if fnx started it).
|
|
192
203
|
*/
|
|
193
204
|
export function stopAzurite() {
|
|
194
|
-
if (azuriteProcess) {
|
|
205
|
+
if (azuriteProcess && weStartedAzurite) {
|
|
195
206
|
try { azuriteProcess.kill(); } catch { /* already dead */ }
|
|
196
207
|
azuriteProcess = null;
|
|
208
|
+
weStartedAzurite = false;
|
|
197
209
|
}
|
|
198
210
|
}
|
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
|
-
|
|
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,243 @@
|
|
|
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 (Windows uses .cmd shims)
|
|
186
|
+
const isWin = process.platform === 'win32';
|
|
187
|
+
const cachedBin = join(homedir(), '.fnx', 'tools', 'azurite', 'node_modules', '.bin', isWin ? 'azurite.cmd' : 'azurite');
|
|
188
|
+
if (existsSync(cachedBin)) {
|
|
189
|
+
return { name: 'Azurite', status: 'warn', message: 'Installed but not running — fnx start will auto-launch it', fix: 'Azurite will start automatically when needed' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const whichCmd = isWin ? 'where azurite' : 'which azurite';
|
|
194
|
+
execSync(whichCmd, { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
195
|
+
return { name: 'Azurite', status: 'warn', message: 'Installed globally but not running — fnx start will auto-launch it' };
|
|
196
|
+
} catch { /* not found */ }
|
|
197
|
+
|
|
198
|
+
return { name: 'Azurite', status: 'warn', message: 'Not installed — fnx will auto-install on first use', fix: 'Or install manually: npm install -g azurite' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Main doctor command ──
|
|
202
|
+
|
|
203
|
+
export async function runDoctor(appPath) {
|
|
204
|
+
console.log(`\n${bold(title('fnx doctor'))} — Project Diagnostics\n`);
|
|
205
|
+
console.log(dim(` Checking: ${appPath}\n`));
|
|
206
|
+
|
|
207
|
+
const checks = [
|
|
208
|
+
await checkHostJson(appPath),
|
|
209
|
+
await checkAppConfig(appPath),
|
|
210
|
+
await checkLocalSettings(appPath),
|
|
211
|
+
await checkRuntime(appPath),
|
|
212
|
+
await checkHostCache(),
|
|
213
|
+
await checkPorts(),
|
|
214
|
+
await checkAzurite(),
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
let hasIssues = false;
|
|
218
|
+
for (const check of checks) {
|
|
219
|
+
const icon = check.status === 'pass' ? success('✓') : check.status === 'warn' ? warning('⚠') : errorColor('✗');
|
|
220
|
+
const msg = check.status === 'pass' ? info(check.message) : check.status === 'warn' ? warning(check.message) : errorColor(check.message);
|
|
221
|
+
console.log(` ${icon} ${bold(check.name)}: ${msg}`);
|
|
222
|
+
if (check.fix && check.status !== 'pass') {
|
|
223
|
+
console.log(dim(` → ${check.fix}`));
|
|
224
|
+
}
|
|
225
|
+
if (check.status === 'fail') hasIssues = true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
console.log('');
|
|
229
|
+
const failCount = checks.filter(c => c.status === 'fail').length;
|
|
230
|
+
const warnCount = checks.filter(c => c.status === 'warn').length;
|
|
231
|
+
const passCount = checks.filter(c => c.status === 'pass').length;
|
|
232
|
+
|
|
233
|
+
if (failCount === 0 && warnCount === 0) {
|
|
234
|
+
console.log(success(' All checks passed! Your project is ready for fnx start.'));
|
|
235
|
+
} else if (failCount === 0) {
|
|
236
|
+
console.log(warning(` ${passCount} passed, ${warnCount} warning(s). Project should work but check warnings above.`));
|
|
237
|
+
} else {
|
|
238
|
+
console.log(errorColor(` ${failCount} error(s), ${warnCount} warning(s). Fix errors above before running fnx start.`));
|
|
239
|
+
}
|
|
240
|
+
console.log('');
|
|
241
|
+
|
|
242
|
+
return failCount > 0 ? 1 : 0;
|
|
243
|
+
}
|
|
@@ -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/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';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
2
|
+
import { access, constants, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir, platform } 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();
|
|
@@ -72,7 +80,54 @@ async function ensureExists(pathToCheck) {
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
async function zipDirectory(sourceDir, outputZip) {
|
|
75
|
-
|
|
83
|
+
if (platform() === 'win32') {
|
|
84
|
+
await zipWithPowerShell(sourceDir, outputZip);
|
|
85
|
+
} else {
|
|
86
|
+
await runCommand('zip', ['-r', '-q', outputZip, '.'], { cwd: sourceDir });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function zipFilteredFiles(sourceDir, outputZip, files) {
|
|
91
|
+
if (platform() === 'win32') {
|
|
92
|
+
await zipWithPowerShell(sourceDir, outputZip, files);
|
|
93
|
+
} else {
|
|
94
|
+
// Write file list to a temp file for zip -@ (read names from stdin)
|
|
95
|
+
const listFile = join(tmpdir(), `fnx-pack-${Date.now()}.txt`);
|
|
96
|
+
try {
|
|
97
|
+
await writeFile(listFile, files.join('\n'), 'utf-8');
|
|
98
|
+
await runCommand('zip', ['-q', outputZip, '-@'], {
|
|
99
|
+
cwd: sourceDir,
|
|
100
|
+
stdinFile: listFile,
|
|
101
|
+
});
|
|
102
|
+
} finally {
|
|
103
|
+
await rm(listFile, { force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create a zip archive using PowerShell on Windows.
|
|
110
|
+
* If `files` is provided, those paths are converted to absolute paths and included.
|
|
111
|
+
* Otherwise, the entire `sourceDir` is zipped.
|
|
112
|
+
*/
|
|
113
|
+
async function zipWithPowerShell(sourceDir, outputZip, files) {
|
|
114
|
+
const absOutput = resolvePath(outputZip);
|
|
115
|
+
if (files) {
|
|
116
|
+
// Write file list to a temp file, then use PowerShell to read + compress
|
|
117
|
+
const listFile = join(tmpdir(), `fnx-pack-${Date.now()}.txt`);
|
|
118
|
+
try {
|
|
119
|
+
// PowerShell needs absolute paths for Compress-Archive
|
|
120
|
+
const absPaths = files.map(f => join(sourceDir, f).replace(/\//g, '\\'));
|
|
121
|
+
await writeFile(listFile, absPaths.join('\n'), 'utf-8');
|
|
122
|
+
const ps = `$files = Get-Content '${listFile.replace(/'/g, "''")}'; Compress-Archive -Path $files -DestinationPath '${absOutput.replace(/'/g, "''")}' -Force`;
|
|
123
|
+
await runCommand('powershell', ['-NoProfile', '-Command', ps], { silent: true });
|
|
124
|
+
} finally {
|
|
125
|
+
await rm(listFile, { force: true });
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
const ps = `Compress-Archive -Path '${join(sourceDir, '*').replace(/'/g, "''")}' -DestinationPath '${absOutput.replace(/'/g, "''")}' -Force`;
|
|
129
|
+
await runCommand('powershell', ['-NoProfile', '-Command', ps], { silent: true });
|
|
130
|
+
}
|
|
76
131
|
}
|
|
77
132
|
|
|
78
133
|
async function stageJavaBuild(scriptRoot) {
|
|
@@ -143,14 +198,24 @@ export async function packFunctionApp({ scriptRoot, runtime, outputPath, noBuild
|
|
|
143
198
|
}
|
|
144
199
|
}
|
|
145
200
|
|
|
146
|
-
|
|
147
|
-
await
|
|
201
|
+
// Always read .funcignore from PROJECT ROOT, not sourceDir (build output)
|
|
202
|
+
const funcIgnore = await loadFuncIgnore(root, { runtime: resolvedRuntime });
|
|
203
|
+
const files = await getFilteredFiles(sourceDir, funcIgnore);
|
|
204
|
+
|
|
205
|
+
console.log(`Packing runtime '${resolvedRuntime}' from ${sourceDir} (${files.length} files)`);
|
|
206
|
+
|
|
207
|
+
if (files.length === 0) {
|
|
208
|
+
throw new Error('No files to pack after applying .funcignore filters.');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await zipFilteredFiles(sourceDir, resolvedOutput, files);
|
|
148
212
|
console.log(`Created package: ${resolvedOutput}`);
|
|
149
213
|
|
|
150
214
|
return {
|
|
151
215
|
runtime: resolvedRuntime,
|
|
152
216
|
sourceDir,
|
|
153
217
|
outputPath: resolvedOutput,
|
|
218
|
+
filesIncluded: files.length,
|
|
154
219
|
};
|
|
155
220
|
} finally {
|
|
156
221
|
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.
|
|
3
|
+
"version": "0.4.2",
|
|
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
|
}
|