@vrdmr/fnx-test 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/lib/azurite-manager.js +16 -15
- package/lib/cli.js +290 -139
- package/lib/colors.js +34 -0
- package/lib/host-launcher.js +34 -15
- package/lib/host-manager.js +17 -10
- package/lib/live-mcp-server.js +3 -2
- package/lib/profile-resolver.js +15 -0
- package/lib/warmup.js +9 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ npm install -g @vrdmr/fnx-test
|
|
|
11
11
|
Or run without installing:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
npx @vrdmr/fnx-test start --sku flex --
|
|
14
|
+
npx @vrdmr/fnx-test start --sku flex --app-path ./my-app
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
## Quick Start
|
|
@@ -21,10 +21,10 @@ npx @vrdmr/fnx-test start --sku flex --scriptroot ./my-app
|
|
|
21
21
|
fnx start --sku list
|
|
22
22
|
|
|
23
23
|
# Run your function app with the Flex Consumption host
|
|
24
|
-
fnx start --sku flex --
|
|
24
|
+
fnx start --sku flex --app-path ./my-function-app
|
|
25
25
|
|
|
26
26
|
# Run with Windows Consumption host
|
|
27
|
-
fnx start --sku windows-consumption --
|
|
27
|
+
fnx start --sku windows-consumption --app-path ./my-function-app
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
## What It Does
|
|
@@ -53,12 +53,12 @@ fnx start --sku flex
|
|
|
53
53
|
## Commands
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
|
-
fnx start --sku <sku> --
|
|
56
|
+
fnx start --sku <sku> --app-path <path> # Run function app
|
|
57
57
|
fnx start --sku list # List available SKUs
|
|
58
58
|
fnx warmup [--sku <sku>] [--all] # Pre-download host + bundle
|
|
59
59
|
fnx sync [host|extensions] [--sku <sku>] # Reconcile cache with latest catalog / rollback
|
|
60
60
|
fnx templates-mcp # Start MCP server for AI agents
|
|
61
|
-
fnx pack --
|
|
61
|
+
fnx pack --app-path <path> # Package function app as deployment zip
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
|
package/lib/azurite-manager.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createConnection } from 'node:net';
|
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
+
import { info, url as urlColor, warning, error as errorColor } from './colors.js';
|
|
6
7
|
|
|
7
8
|
const BLOB_PORT = 10000;
|
|
8
9
|
const QUEUE_PORT = 10001;
|
|
@@ -75,7 +76,7 @@ function findAzurite() {
|
|
|
75
76
|
* Install azurite into ~/.fnx/tools/azurite/ if not already present.
|
|
76
77
|
*/
|
|
77
78
|
function installAzurite() {
|
|
78
|
-
console.log('[fnx] Installing Azurite to ~/.fnx/tools/azurite/ (first-time only)...');
|
|
79
|
+
console.log(info('[fnx] Installing Azurite to ~/.fnx/tools/azurite/ (first-time only)...'));
|
|
79
80
|
mkdirSync(AZURITE_INSTALL_DIR, { recursive: true });
|
|
80
81
|
|
|
81
82
|
// Initialize a minimal package.json if missing so npm install works
|
|
@@ -91,14 +92,14 @@ function installAzurite() {
|
|
|
91
92
|
timeout: 120_000,
|
|
92
93
|
});
|
|
93
94
|
} catch (err) {
|
|
94
|
-
console.error('[fnx] Failed to install Azurite. Install manually: npm install -g azurite');
|
|
95
|
-
console.error(` ${err.message}`);
|
|
95
|
+
console.error(errorColor('[fnx] Failed to install Azurite. Install manually: npm install -g azurite'));
|
|
96
|
+
console.error(warning(` ${err.message}`));
|
|
96
97
|
return null;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
const installed = join(AZURITE_INSTALL_DIR, 'node_modules', '.bin', 'azurite');
|
|
100
101
|
if (existsSync(installed)) {
|
|
101
|
-
console.log('[fnx] Azurite installed successfully.');
|
|
102
|
+
console.log(info('[fnx] Azurite installed successfully.'));
|
|
102
103
|
return installed;
|
|
103
104
|
}
|
|
104
105
|
return null;
|
|
@@ -127,23 +128,23 @@ export async function ensureAzurite(mergedValues, opts = {}) {
|
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
const storageVal = mergedValues?.AzureWebJobsStorage || '(empty)';
|
|
130
|
-
console.log(`[fnx] Detected AzureWebJobsStorage=${storageVal}`);
|
|
131
|
+
console.log(info(`[fnx] Detected AzureWebJobsStorage=${storageVal}`));
|
|
131
132
|
|
|
132
133
|
// Check if Azurite is already running
|
|
133
134
|
if (await isAzuriteRunning()) {
|
|
134
|
-
console.log('[fnx] Using existing Azurite instance on default ports.');
|
|
135
|
+
console.log(info('[fnx] Using existing Azurite instance on default ports.'));
|
|
135
136
|
return null;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
// Find or install azurite
|
|
139
140
|
const azuriteBin = findOrInstallAzurite();
|
|
140
141
|
if (!azuriteBin) {
|
|
141
|
-
console.error('[fnx] ⚠️ Azurite not available. Storage triggers may fail.');
|
|
142
|
-
console.error(' Install with: npm install -g azurite');
|
|
142
|
+
console.error(warning('[fnx] ⚠️ Azurite not available. Storage triggers may fail.'));
|
|
143
|
+
console.error(warning(' Install with: npm install -g azurite'));
|
|
143
144
|
return null;
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
console.log('[fnx] Starting Azurite storage emulator...');
|
|
147
|
+
console.log(info('[fnx] Starting Azurite storage emulator...'));
|
|
147
148
|
|
|
148
149
|
const azuriteArgs = [
|
|
149
150
|
'--blobHost', '127.0.0.1', '--blobPort', String(BLOB_PORT),
|
|
@@ -161,13 +162,13 @@ export async function ensureAzurite(mergedValues, opts = {}) {
|
|
|
161
162
|
});
|
|
162
163
|
|
|
163
164
|
azuriteProcess.on('error', (err) => {
|
|
164
|
-
console.error(`[fnx] Azurite failed to start: ${err.message}`);
|
|
165
|
+
console.error(errorColor(`[fnx] Azurite failed to start: ${err.message}`));
|
|
165
166
|
azuriteProcess = null;
|
|
166
167
|
});
|
|
167
168
|
|
|
168
169
|
azuriteProcess.on('exit', (code) => {
|
|
169
170
|
if (code && code !== 0) {
|
|
170
|
-
console.error(`[fnx] Azurite exited unexpectedly with code ${code}.`);
|
|
171
|
+
console.error(errorColor(`[fnx] Azurite exited unexpectedly with code ${code}.`));
|
|
171
172
|
}
|
|
172
173
|
azuriteProcess = null;
|
|
173
174
|
});
|
|
@@ -175,13 +176,13 @@ export async function ensureAzurite(mergedValues, opts = {}) {
|
|
|
175
176
|
// Wait for Azurite to be ready
|
|
176
177
|
const ready = await waitForTcp(BLOB_PORT, { timeoutMs: 15000 });
|
|
177
178
|
if (!ready) {
|
|
178
|
-
console.error('[fnx] ⚠️ Azurite did not become ready in time. Storage triggers may fail.');
|
|
179
|
+
console.error(warning('[fnx] ⚠️ Azurite did not become ready in time. Storage triggers may fail.'));
|
|
179
180
|
return azuriteProcess;
|
|
180
181
|
}
|
|
181
182
|
|
|
182
|
-
console.log(`[fnx] Azurite Blob → http://127.0.0.1:${BLOB_PORT}`);
|
|
183
|
-
console.log(`[fnx] Azurite Queue → http://127.0.0.1:${QUEUE_PORT}`);
|
|
184
|
-
console.log(`[fnx] Azurite Table → http://127.0.0.1:${TABLE_PORT}`);
|
|
183
|
+
console.log(info(`[fnx] Azurite Blob → ${urlColor(`http://127.0.0.1:${BLOB_PORT}`)}`));
|
|
184
|
+
console.log(info(`[fnx] Azurite Queue → ${urlColor(`http://127.0.0.1:${QUEUE_PORT}`)}`));
|
|
185
|
+
console.log(info(`[fnx] Azurite Table → ${urlColor(`http://127.0.0.1:${TABLE_PORT}`)}`));
|
|
185
186
|
|
|
186
187
|
return azuriteProcess;
|
|
187
188
|
}
|
package/lib/cli.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { resolve as resolvePath, dirname, join } from 'node:path';
|
|
2
2
|
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
3
4
|
import { createServer } from 'node:net';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { resolveProfile, listProfiles, setProfilesSource, fetchRegistryWithMeta } from './profile-resolver.js';
|
|
7
|
-
import { ensureHost, ensureBundle, getCachedHostVersions, compareVersions, DEFAULT_KEEP_VERSIONS } from './host-manager.js';
|
|
7
|
+
import { resolveProfile, listProfiles, setProfilesSource, fetchRegistryWithMeta, readProfilesSync } from './profile-resolver.js';
|
|
8
|
+
import { ensureHost, ensureBundle, getCachedHostVersions, getCachedBundleVersions, compareVersions, DEFAULT_KEEP_VERSIONS } from './host-manager.js';
|
|
8
9
|
import { launchHost, createHostState } from './host-launcher.js';
|
|
9
10
|
import { startLiveMcpServer } from './live-mcp-server.js';
|
|
10
11
|
import { detectDotnetModel, printInProcessError } from './dotnet-detector.js';
|
|
11
12
|
import { detectRuntimeFromConfig, packFunctionApp } from './pack.js';
|
|
13
|
+
import { title, info, funcName, url as urlColor, success, error as errorColor, warning, dim, bold, highlightUrls } from './colors.js';
|
|
12
14
|
|
|
13
15
|
const FNX_HOME = join(homedir(), '.fnx');
|
|
14
16
|
const VERSION_CHECK_FILE = join(FNX_HOME, 'version-check.json');
|
|
@@ -30,38 +32,83 @@ async function findOpenPort(start, maxRetries = 10) {
|
|
|
30
32
|
return start; // fall through — let the host report the error
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
function hasHelp(args) {
|
|
36
|
+
return args.includes('-h') || args.includes('--help');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the function app directory.
|
|
41
|
+
* 1. If --app-path is given, use it — but verify host.json exists.
|
|
42
|
+
* 2. Otherwise check cwd for host.json.
|
|
43
|
+
* 3. Fall back to cwd/src if it contains host.json.
|
|
44
|
+
* 4. Error with actionable message if nothing found.
|
|
45
|
+
*/
|
|
46
|
+
function resolveAppPath(args) {
|
|
47
|
+
const explicit = getFlag(args, '--app-path');
|
|
48
|
+
if (explicit) {
|
|
49
|
+
const resolved = resolvePath(explicit);
|
|
50
|
+
if (!existsSync(join(resolved, 'host.json'))) {
|
|
51
|
+
console.error(errorColor(`Error: No host.json found in ${resolved}`));
|
|
52
|
+
console.error(` The --app-path must point to a directory containing host.json.`);
|
|
53
|
+
console.error(dim(` Example: fnx start --app-path ./my-function-app`));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const cwd = process.cwd();
|
|
60
|
+
if (existsSync(join(cwd, 'host.json'))) {
|
|
61
|
+
return cwd;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const srcDir = join(cwd, 'src');
|
|
65
|
+
if (existsSync(join(srcDir, 'host.json'))) {
|
|
66
|
+
console.log(info(` Using function app at ${dim('./src')} (found host.json there)`));
|
|
67
|
+
return srcDir;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.error(errorColor(`Error: No function app found.`));
|
|
71
|
+
console.error(` Could not find host.json in the current directory or ./src.`);
|
|
72
|
+
console.error(dim(` Use --app-path <dir> to specify the function app location.`));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
33
76
|
export async function main(args) {
|
|
34
77
|
const cmd = args[0];
|
|
35
78
|
|
|
36
79
|
if (cmd === '-h' || cmd === '--help' || cmd === 'help' || !cmd) {
|
|
37
|
-
|
|
80
|
+
await printHelpWithVersionInfo();
|
|
38
81
|
process.exit(cmd ? 0 : 1);
|
|
39
82
|
}
|
|
40
83
|
|
|
41
84
|
if (cmd === '-v' || cmd === '--version') {
|
|
42
85
|
const pkg = await getFnxPackage();
|
|
43
|
-
console.log(`fnx v${pkg.version}`);
|
|
86
|
+
console.log(title(`fnx v${pkg.version}`));
|
|
44
87
|
process.exit(0);
|
|
45
88
|
}
|
|
46
89
|
|
|
47
90
|
if (cmd === 'templates-mcp') {
|
|
91
|
+
if (hasHelp(args.slice(1))) { printTemplatesMcpHelp(); return; }
|
|
48
92
|
await startTemplatesMcp();
|
|
49
93
|
return;
|
|
50
94
|
}
|
|
51
95
|
|
|
52
96
|
if (cmd === 'warmup') {
|
|
97
|
+
if (hasHelp(args.slice(1))) { printWarmupHelp(); return; }
|
|
53
98
|
const { warmup } = await import('./warmup.js');
|
|
54
99
|
await warmup(args.slice(1));
|
|
55
100
|
return;
|
|
56
101
|
}
|
|
57
102
|
|
|
58
103
|
if (cmd === 'sync') {
|
|
104
|
+
if (hasHelp(args.slice(1))) { printSyncHelp(); return; }
|
|
59
105
|
await runSync(args.slice(1));
|
|
60
106
|
return;
|
|
61
107
|
}
|
|
62
108
|
|
|
63
109
|
if (cmd === 'pack') {
|
|
64
|
-
|
|
110
|
+
if (hasHelp(args.slice(1))) { printPackHelp(); return; }
|
|
111
|
+
const scriptRoot = resolveAppPath(args);
|
|
65
112
|
const runtime = getFlag(args, '--runtime') || await detectRuntimeFromConfig(scriptRoot);
|
|
66
113
|
const outputPath = getFlag(args, '--output');
|
|
67
114
|
const noBuild = args.includes('--no-build');
|
|
@@ -70,18 +117,20 @@ export async function main(args) {
|
|
|
70
117
|
}
|
|
71
118
|
|
|
72
119
|
if (cmd !== 'start') {
|
|
73
|
-
console.error(`Unknown command: ${cmd}\n`);
|
|
120
|
+
console.error(errorColor(`Unknown command: ${cmd}\n`));
|
|
74
121
|
printHelp();
|
|
75
122
|
process.exit(1);
|
|
76
123
|
}
|
|
77
124
|
|
|
125
|
+
if (hasHelp(args.slice(1))) { printStartHelp(); return; }
|
|
126
|
+
|
|
78
127
|
await maybeWarnForCliUpgrade();
|
|
79
128
|
|
|
80
|
-
const scriptRoot =
|
|
129
|
+
const scriptRoot = resolveAppPath(args);
|
|
81
130
|
const requestedPort = parseInt(getFlag(args, '--port') || '7071');
|
|
82
131
|
const port = await findOpenPort(requestedPort);
|
|
83
132
|
if (port !== requestedPort) {
|
|
84
|
-
console.log(` Port ${requestedPort} in use, using ${port} instead.`);
|
|
133
|
+
console.log(warning(` Port ${requestedPort} in use, using ${port} instead.`));
|
|
85
134
|
}
|
|
86
135
|
const mcpPort = getFlag(args, '--mcp-port') || String(port + 1);
|
|
87
136
|
const verbose = args.includes('--verbose');
|
|
@@ -113,8 +162,8 @@ export async function main(args) {
|
|
|
113
162
|
if (!sku) {
|
|
114
163
|
sku = 'flex';
|
|
115
164
|
skuSource = 'default';
|
|
116
|
-
console.log(`No --sku specified, defaulting to '${sku}'.`);
|
|
117
|
-
console.log(`Tip: Use --sku <name> to target a specific SKU. Run --sku list to see options.\n`);
|
|
165
|
+
console.log(info(`No --sku specified, defaulting to '${sku}'.`));
|
|
166
|
+
console.log(dim(`Tip: Use --sku <name> to target a specific SKU. Run --sku list to see options.\n`));
|
|
118
167
|
}
|
|
119
168
|
|
|
120
169
|
if (sku === 'list') {
|
|
@@ -124,9 +173,9 @@ export async function main(args) {
|
|
|
124
173
|
|
|
125
174
|
// 1. Resolve profile
|
|
126
175
|
if (skuSource !== 'default') {
|
|
127
|
-
console.log(`Resolving SKU profile: ${sku} (from ${skuSource})...`);
|
|
176
|
+
console.log(title(`Resolving SKU profile: ${sku} (from ${skuSource})...`));
|
|
128
177
|
} else {
|
|
129
|
-
console.log(`Resolving SKU profile: ${sku}...`);
|
|
178
|
+
console.log(title(`Resolving SKU profile: ${sku}...`));
|
|
130
179
|
}
|
|
131
180
|
|
|
132
181
|
const { registry, source } = await fetchRegistryWithMeta();
|
|
@@ -137,13 +186,13 @@ export async function main(args) {
|
|
|
137
186
|
}
|
|
138
187
|
|
|
139
188
|
profile.name = sku;
|
|
140
|
-
console.log(` Target SKU: ${profile.displayName}`);
|
|
141
|
-
console.log(` Host Version: ${profile.hostVersion}`);
|
|
142
|
-
console.log(` Extension Bundle: ${profile.extensionBundleVersion}`);
|
|
189
|
+
console.log(` ${dim('Target SKU:')} ${info(profile.displayName)}`);
|
|
190
|
+
console.log(` ${dim('Host Version:')} ${info(profile.hostVersion)}`);
|
|
191
|
+
console.log(` ${dim('Extension Bundle:')} ${info(profile.extensionBundleVersion)}`);
|
|
143
192
|
if (profile.maxExtensionBundleVersion) {
|
|
144
|
-
console.log(` Max Bundle Cap: ${profile.maxExtensionBundleVersion}`);
|
|
193
|
+
console.log(` ${dim('Max Bundle Cap:')} ${info(profile.maxExtensionBundleVersion)}`);
|
|
145
194
|
}
|
|
146
|
-
console.log(` Profile Source: ${source}`);
|
|
195
|
+
console.log(` ${dim('Profile Source:')} ${info(source)}`);
|
|
147
196
|
console.log();
|
|
148
197
|
|
|
149
198
|
// Early validation: merge config and check runtime before downloading anything
|
|
@@ -155,7 +204,7 @@ export async function main(args) {
|
|
|
155
204
|
const workerRuntime = mergedValues.FUNCTIONS_WORKER_RUNTIME;
|
|
156
205
|
|
|
157
206
|
if (!workerRuntime) {
|
|
158
|
-
console.error('Error: FUNCTIONS_WORKER_RUNTIME not set in app.config.json or local.settings.json');
|
|
207
|
+
console.error(errorColor('Error: FUNCTIONS_WORKER_RUNTIME not set in app.config.json or local.settings.json'));
|
|
159
208
|
process.exit(1);
|
|
160
209
|
}
|
|
161
210
|
|
|
@@ -173,14 +222,14 @@ export async function main(args) {
|
|
|
173
222
|
|
|
174
223
|
// 2. Ensure host is downloaded
|
|
175
224
|
const hostDir = await ensureHost(profile, { keepVersions: DEFAULT_KEEP_VERSIONS });
|
|
176
|
-
console.log(` Host path: ${hostDir}`);
|
|
225
|
+
console.log(` ${dim('Host path:')} ${info(hostDir)}`);
|
|
177
226
|
|
|
178
227
|
// 3. Pre-download the correct extension bundle for this SKU
|
|
179
228
|
// This resolves the exact version from CDN index, capped by maxExtensionBundleVersion,
|
|
180
229
|
// and downloads it so the host finds it cached and never fetches a wrong version.
|
|
181
230
|
const resolvedBundleVersion = await ensureBundle(profile, { keepVersions: DEFAULT_KEEP_VERSIONS });
|
|
182
231
|
if (resolvedBundleVersion) {
|
|
183
|
-
console.log(` Bundle resolved: ${resolvedBundleVersion}`);
|
|
232
|
+
console.log(` ${dim('Bundle resolved:')} ${info(resolvedBundleVersion)}`);
|
|
184
233
|
}
|
|
185
234
|
console.log();
|
|
186
235
|
|
|
@@ -191,8 +240,8 @@ export async function main(args) {
|
|
|
191
240
|
startLiveMcpServer(hostState, parseInt(mcpPort))
|
|
192
241
|
.then((server) => { hostState._mcpServer = server; })
|
|
193
242
|
.catch((err) => {
|
|
194
|
-
console.error(` ⚠️ MCP server failed to start on port ${mcpPort}: ${err.message}`);
|
|
195
|
-
console.error(` Use --no-mcp to disable, or --mcp-port <port> to change port.`);
|
|
243
|
+
console.error(warning(` ⚠️ MCP server failed to start on port ${mcpPort}: ${err.message}`));
|
|
244
|
+
console.error(dim(` Use --no-mcp to disable, or --mcp-port <port> to change port.`));
|
|
196
245
|
});
|
|
197
246
|
// Don't await — host startup should not depend on MCP server
|
|
198
247
|
}
|
|
@@ -246,17 +295,17 @@ async function runSync(args) {
|
|
|
246
295
|
const profile = await resolveProfile(sku);
|
|
247
296
|
profile.name = sku;
|
|
248
297
|
|
|
249
|
-
console.log(`Syncing SKU '${sku}' (${profile.displayName})...`);
|
|
298
|
+
console.log(title(`Syncing SKU '${sku}' (${profile.displayName})...`));
|
|
250
299
|
if (target === 'all' || target === 'host') {
|
|
251
300
|
await ensureHost(profile, { force, keepVersions: keep });
|
|
252
|
-
console.log(' ✓ Host synchronized.');
|
|
301
|
+
console.log(success(' ✓ Host synchronized.'));
|
|
253
302
|
}
|
|
254
303
|
if (target === 'all' || target === 'extensions') {
|
|
255
304
|
const bundle = await ensureBundle(profile, { force, keepVersions: keep });
|
|
256
|
-
console.log(` ✓ Extensions synchronized (${bundle || 'cached'}).`);
|
|
305
|
+
console.log(success(` ✓ Extensions synchronized (${bundle || 'cached'}).`));
|
|
257
306
|
}
|
|
258
307
|
|
|
259
|
-
console.log(` Retention policy: keep latest ${keep} version(s).`);
|
|
308
|
+
console.log(dim(` Retention policy: keep latest ${keep} version(s).`));
|
|
260
309
|
}
|
|
261
310
|
|
|
262
311
|
function printHostDriftWarning(targetHostVersion) {
|
|
@@ -267,11 +316,11 @@ function printHostDriftWarning(targetHostVersion) {
|
|
|
267
316
|
if (!highest) return;
|
|
268
317
|
|
|
269
318
|
if (compareVersions(targetHostVersion, highest) > 0) {
|
|
270
|
-
console.log(` ℹ️ New host available: ${targetHostVersion} (local latest: ${highest}).`);
|
|
271
|
-
console.log(' Run `fnx sync` or `fnx sync host` to download it.\n');
|
|
319
|
+
console.log(info(` ℹ️ New host available: ${targetHostVersion} (local latest: ${highest}).`));
|
|
320
|
+
console.log(dim(' Run `fnx sync` or `fnx sync host` to download it.\n'));
|
|
272
321
|
} else if (compareVersions(targetHostVersion, highest) < 0) {
|
|
273
|
-
console.log(` ⚠️ Host rollback detected: local ${highest}, catalog ${targetHostVersion}.`);
|
|
274
|
-
console.log(' Run `fnx sync` or `fnx sync host` to align with the supported version.\n');
|
|
322
|
+
console.log(warning(` ⚠️ Host rollback detected: local ${highest}, catalog ${targetHostVersion}.`));
|
|
323
|
+
console.log(dim(' Run `fnx sync` or `fnx sync host` to align with the supported version.\n'));
|
|
275
324
|
}
|
|
276
325
|
}
|
|
277
326
|
|
|
@@ -306,8 +355,8 @@ async function maybeWarnForCliUpgrade() {
|
|
|
306
355
|
}
|
|
307
356
|
|
|
308
357
|
function printUpgradeTip(latestVersion) {
|
|
309
|
-
console.log(` ℹ️ A newer fnx version is available (${latestVersion}).`);
|
|
310
|
-
console.log(' Run `npm i -g @vrdmr/fnx-test@latest` to upgrade.\n');
|
|
358
|
+
console.log(info(` ℹ️ A newer fnx version is available (${latestVersion}).`));
|
|
359
|
+
console.log(dim(' Run `npm i -g @vrdmr/fnx-test@latest` to upgrade.\n'));
|
|
311
360
|
}
|
|
312
361
|
|
|
313
362
|
async function getFnxPackage() {
|
|
@@ -343,113 +392,215 @@ export async function readJsonFile(filePath) {
|
|
|
343
392
|
}
|
|
344
393
|
}
|
|
345
394
|
|
|
395
|
+
async function printHelpWithVersionInfo() {
|
|
396
|
+
const pkg = await getFnxPackage();
|
|
397
|
+
const cachedHosts = getCachedHostVersions().sort(compareVersions);
|
|
398
|
+
const cachedBundles = getCachedBundleVersions().sort(compareVersions);
|
|
399
|
+
|
|
400
|
+
// Build SKU → host version map from cached/bundled profiles (no network)
|
|
401
|
+
const registry = readProfilesSync();
|
|
402
|
+
const hostToSkus = {};
|
|
403
|
+
if (registry?.profiles) {
|
|
404
|
+
for (const [sku, p] of Object.entries(registry.profiles)) {
|
|
405
|
+
const v = p.hostVersion;
|
|
406
|
+
if (!hostToSkus[v]) hostToSkus[v] = [];
|
|
407
|
+
hostToSkus[v].push(sku);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Fire-and-forget: refresh profile cache in background.
|
|
412
|
+
// Won't block -h — process.exit() will terminate regardless.
|
|
413
|
+
fetchRegistryWithMeta().catch(() => {});
|
|
414
|
+
|
|
415
|
+
console.log(`
|
|
416
|
+
${bold(title('Azure Functions Local Emulator (fnx — Phoenix Emulate)'))}
|
|
417
|
+
${dim('fnx Version:')} ${title(pkg.version)}`);
|
|
418
|
+
|
|
419
|
+
if (cachedHosts.length) {
|
|
420
|
+
console.log(`${dim('Cached Hosts:')} ${cachedHosts.map((v) => {
|
|
421
|
+
const skus = hostToSkus[v];
|
|
422
|
+
return skus ? `${info(v)} ${dim('(' + skus.join(', ') + ')')}` : info(v);
|
|
423
|
+
}).join(', ')}`);
|
|
424
|
+
} else {
|
|
425
|
+
console.log(`${dim('Cached Hosts:')} ${dim('(none — run fnx warmup)')}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (cachedBundles.length) {
|
|
429
|
+
console.log(`${dim('Cached Bundles:')} ${cachedBundles.map((v) => info(v)).join(', ')}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
console.log();
|
|
433
|
+
printHelp();
|
|
434
|
+
}
|
|
435
|
+
|
|
346
436
|
function printHelp() {
|
|
437
|
+
console.log(`${title('Usage:')} fnx <command> [options]
|
|
438
|
+
|
|
439
|
+
${title('Commands:')}
|
|
440
|
+
${funcName('start')} Launch the Azure Functions host runtime for a specific SKU.
|
|
441
|
+
${funcName('sync')} Sync cached host/extensions with current catalog profile.
|
|
442
|
+
${funcName('pack')} Package a Functions app into a deployment zip.
|
|
443
|
+
${funcName('warmup')} Pre-download host binaries and extension bundles.
|
|
444
|
+
${funcName('templates-mcp')} Start the Azure Functions templates MCP server (stdio).
|
|
445
|
+
|
|
446
|
+
Run ${dim('fnx <command> -h')} for command-specific options.
|
|
447
|
+
|
|
448
|
+
${title('Common Options:')}
|
|
449
|
+
${success('--sku')} <name> Target SKU to emulate (default: flex).
|
|
450
|
+
Use ${success('--sku list')} to see all available SKUs.
|
|
451
|
+
${success('--verbose')} Show all host output (unfiltered).
|
|
452
|
+
${success('-v')}, ${success('--version')} Display the version of fnx.
|
|
453
|
+
${success('-h')}, ${success('--help')} Display this help information.
|
|
454
|
+
|
|
455
|
+
${title('Start Options:')} ${dim('(fnx start)')}
|
|
456
|
+
${success('--app-path')} <dir> Path to the function app directory (default: cwd).
|
|
457
|
+
${success('--port')} <port> Port for the host HTTP listener (default: 7071).
|
|
458
|
+
${success('--mcp-port')} <port> Port for the live MCP server (default: host port + 1).
|
|
459
|
+
${success('--no-mcp')} Disable the live MCP server.
|
|
460
|
+
${success('--no-azurite')} Skip automatic Azurite start.
|
|
461
|
+
|
|
462
|
+
${title('Sync Options:')} ${dim('(fnx sync [host|extensions])')}
|
|
463
|
+
${success('--keep')} <n> Keep latest N versions in cache (default: 2).
|
|
464
|
+
${success('--force')} Re-download even if already cached.
|
|
465
|
+
|
|
466
|
+
${title('Pack Options:')} ${dim('(fnx pack)')}
|
|
467
|
+
${success('--app-path')} <dir> Path to the function app directory (default: cwd).
|
|
468
|
+
${success('--runtime')} <name> Runtime identifier (default: auto-detected from config).
|
|
469
|
+
${success('--output')} <file> Output zip path (default: <app-name>.zip).
|
|
470
|
+
${success('--no-build')} Skip build steps for java/dotnet-isolated.
|
|
471
|
+
|
|
472
|
+
${title('Available SKUs:')}
|
|
473
|
+
${funcName('flex')} Flex Consumption (latest host, default)
|
|
474
|
+
${funcName('linux-premium')} Linux Premium / Elastic Premium
|
|
475
|
+
${funcName('windows-consumption')} Windows Consumption (classic)
|
|
476
|
+
${funcName('windows-dedicated')} Windows Dedicated (App Service Plan)
|
|
477
|
+
${funcName('linux-consumption')} Linux Consumption (retiring)
|
|
478
|
+
|
|
479
|
+
${title('Examples:')}
|
|
480
|
+
fnx start Start with default SKU (flex)
|
|
481
|
+
fnx start --sku windows-consumption Emulate Windows Consumption
|
|
482
|
+
fnx start --sku flex --port 8080 Custom port
|
|
483
|
+
fnx pack --app-path ./my-app Package function app as zip
|
|
484
|
+
fnx sync host --force Force re-download host binary
|
|
485
|
+
fnx warmup --all Pre-download all SKUs
|
|
486
|
+
fnx templates-mcp Start templates MCP server
|
|
487
|
+
|
|
488
|
+
${dim('Advanced Options:')}
|
|
489
|
+
${success('--profiles')} <src> Override SKU profiles source. Can be a URL, local file path,
|
|
490
|
+
or inline JSON. Default: CDN → cached → bundled.
|
|
491
|
+
Set FUNC_PROFILES_URL env var for persistent override.`.trim());
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function printStartHelp() {
|
|
495
|
+
console.log(`
|
|
496
|
+
${bold(title('fnx start'))} — Launch the Azure Functions host runtime.
|
|
497
|
+
|
|
498
|
+
${title('Usage:')} fnx start [options]
|
|
499
|
+
|
|
500
|
+
${title('Options:')}
|
|
501
|
+
${success('--sku')} <name> Target SKU to emulate (default: flex). Use ${success('--sku list')} to see options.
|
|
502
|
+
${success('--app-path')} <dir> Path to the function app directory (default: cwd).
|
|
503
|
+
${success('--port')} <port> Port for the host HTTP listener (default: 7071).
|
|
504
|
+
${success('--mcp-port')} <port> Port for the live MCP server (default: host port + 1).
|
|
505
|
+
${success('--verbose')} Show all host output (unfiltered).
|
|
506
|
+
${success('--no-mcp')} Disable the live MCP server.
|
|
507
|
+
${success('--no-azurite')} Skip automatic Azurite start.
|
|
508
|
+
${success('-h')}, ${success('--help')} Show this help message.
|
|
509
|
+
|
|
510
|
+
${title('Examples:')}
|
|
511
|
+
fnx start Start with default SKU (flex)
|
|
512
|
+
fnx start --sku flex --port 8080 Custom port
|
|
513
|
+
fnx start --sku windows-consumption Emulate Windows Consumption
|
|
514
|
+
fnx start --verbose Show all host output`.trim());
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function printSyncHelp() {
|
|
518
|
+
console.log(`
|
|
519
|
+
${bold(title('fnx sync'))} — Sync cached host/extensions with current catalog profile.
|
|
520
|
+
|
|
521
|
+
${title('Usage:')} fnx sync [host|extensions] [options]
|
|
522
|
+
|
|
523
|
+
${title('Options:')}
|
|
524
|
+
${success('--sku')} <name> Target SKU to sync (default: flex). Use ${success('--sku list')} to see options.
|
|
525
|
+
${success('--keep')} <n> Keep latest N versions in cache (default: 2).
|
|
526
|
+
${success('--force')} Re-download even if already cached.
|
|
527
|
+
${success('-h')}, ${success('--help')} Show this help message.
|
|
528
|
+
|
|
529
|
+
${title('Examples:')}
|
|
530
|
+
fnx sync Sync host and extensions for default SKU
|
|
531
|
+
fnx sync host Sync host only
|
|
532
|
+
fnx sync extensions Sync extensions only
|
|
533
|
+
fnx sync --force Force re-download`.trim());
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function printPackHelp() {
|
|
347
537
|
console.log(`
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
--
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
--
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
Side-by-side comparison:
|
|
417
|
-
# Terminal 1: Run as Flex Consumption
|
|
418
|
-
fnx start --sku flex --port 7071
|
|
419
|
-
|
|
420
|
-
# Terminal 2: Run as Windows Consumption (different host version)
|
|
421
|
-
fnx start --sku windows-consumption --port 7072
|
|
422
|
-
|
|
423
|
-
# Compare behavior across SKUs with the same function app!
|
|
424
|
-
|
|
425
|
-
MCP server (for VS Code Copilot / AI assistants):
|
|
426
|
-
fnx templates-mcp Start templates MCP server (stdio)
|
|
427
|
-
fnx start Also starts live MCP server on port+1
|
|
428
|
-
fnx start --mcp-port 9000 Live MCP server on custom port
|
|
429
|
-
fnx start --no-mcp Disable live MCP server
|
|
430
|
-
|
|
431
|
-
# .vscode/mcp.json — templates only (stdio):
|
|
432
|
-
# {
|
|
433
|
-
# "servers": {
|
|
434
|
-
# "azure-functions-templates": {
|
|
435
|
-
# "type": "stdio",
|
|
436
|
-
# "command": "fnx",
|
|
437
|
-
# "args": ["templates-mcp"]
|
|
438
|
-
# }
|
|
439
|
-
# }
|
|
440
|
-
# }
|
|
441
|
-
#
|
|
442
|
-
# .vscode/mcp.json — live host data (when fnx start is running):
|
|
443
|
-
# {
|
|
444
|
-
# "servers": {
|
|
445
|
-
# "fnx-functions-debug": {
|
|
446
|
-
# "type": "http",
|
|
447
|
-
# "url": "http://127.0.0.1:7072/mcp"
|
|
448
|
-
# }
|
|
449
|
-
# }
|
|
450
|
-
# }
|
|
451
|
-
|
|
452
|
-
Supported runtimes: node, python, java, powershell, dotnet-isolated
|
|
453
|
-
(.NET in-process / Microsoft.NET.Sdk.Functions is not supported — isolated worker model only)
|
|
454
|
-
`.trim());
|
|
538
|
+
${bold(title('fnx pack'))} — Package a Functions app into a deployment zip.
|
|
539
|
+
|
|
540
|
+
${title('Usage:')} fnx pack [options]
|
|
541
|
+
|
|
542
|
+
${title('Options:')}
|
|
543
|
+
${success('--app-path')} <dir> Path to the function app directory (default: cwd).
|
|
544
|
+
${success('--runtime')} <name> Runtime identifier (default: auto-detected from config).
|
|
545
|
+
${success('--output')} <file> Output zip path (default: <app-name>.zip).
|
|
546
|
+
${success('--no-build')} Skip build steps for java/dotnet-isolated.
|
|
547
|
+
${success('-h')}, ${success('--help')} Show this help message.
|
|
548
|
+
|
|
549
|
+
${title('Supported runtimes:')} node, python, java, powershell, dotnet-isolated
|
|
550
|
+
|
|
551
|
+
${title('Examples:')}
|
|
552
|
+
fnx pack Package current directory
|
|
553
|
+
fnx pack --app-path ./my-app Package a specific app
|
|
554
|
+
fnx pack --runtime python --no-build Skip build step`.trim());
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function printWarmupHelp() {
|
|
558
|
+
console.log(`
|
|
559
|
+
${bold(title('fnx warmup'))} — Pre-download host binaries and extension bundles for offline use.
|
|
560
|
+
|
|
561
|
+
${title('Usage:')} fnx warmup [options]
|
|
562
|
+
|
|
563
|
+
${title('Options:')}
|
|
564
|
+
${success('--sku')} <name> Target SKU to warm (default: flex). Use ${success('--sku list')} to see options.
|
|
565
|
+
${success('--all')} Warm ALL available SKUs (useful for CI/build agents).
|
|
566
|
+
${success('--dry-run')} Show what would be downloaded without actually downloading.
|
|
567
|
+
${success('--force')} Re-download even if already cached.
|
|
568
|
+
${success('-h')}, ${success('--help')} Show this help message.
|
|
569
|
+
|
|
570
|
+
${title('Environment Variables:')}
|
|
571
|
+
FNX_SKIP_DOWNLOAD=1 Skip warmup entirely (useful for CI/Docker).
|
|
572
|
+
FNX_DEFAULT_SKU=<name> Warm a specific SKU instead of flex.
|
|
573
|
+
|
|
574
|
+
${title('Examples:')}
|
|
575
|
+
fnx warmup Pre-download default SKU (flex)
|
|
576
|
+
fnx warmup --all Warm ALL SKUs
|
|
577
|
+
fnx warmup --dry-run Show what would be downloaded
|
|
578
|
+
fnx warmup --force Re-download even if cached`.trim());
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function printTemplatesMcpHelp() {
|
|
582
|
+
console.log(`
|
|
583
|
+
${bold(title('fnx templates-mcp'))} — Start the Azure Functions templates MCP server.
|
|
584
|
+
|
|
585
|
+
${title('Usage:')} fnx templates-mcp
|
|
586
|
+
|
|
587
|
+
Starts a stdio-based MCP server that provides Azure Functions templates
|
|
588
|
+
and SKU profile information to AI assistants (VS Code Copilot, etc.).
|
|
589
|
+
|
|
590
|
+
${title('Features:')}
|
|
591
|
+
• 68 templates across 4 languages (JavaScript, TypeScript, Python, C#)
|
|
592
|
+
• SKU profile listing and resolution tools
|
|
593
|
+
• Drop-in replacement for manvir-templates-mcp-server
|
|
594
|
+
|
|
595
|
+
${title('VS Code Configuration:')}
|
|
596
|
+
Add to .vscode/mcp.json:
|
|
597
|
+
{
|
|
598
|
+
"servers": {
|
|
599
|
+
"azure-functions-templates": {
|
|
600
|
+
"type": "stdio",
|
|
601
|
+
"command": "fnx",
|
|
602
|
+
"args": ["templates-mcp"]
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}`.trim());
|
|
455
606
|
}
|
package/lib/colors.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// lib/colors.js — zero-dependency ANSI color helper
|
|
2
|
+
// Matches func start theme from OutputTheme.cs
|
|
3
|
+
|
|
4
|
+
const enabled = !process.env.NO_COLOR && process.stdout.isTTY !== false;
|
|
5
|
+
|
|
6
|
+
const codes = {
|
|
7
|
+
reset: '\x1b[0m',
|
|
8
|
+
bold: '\x1b[1m',
|
|
9
|
+
red: '\x1b[31m',
|
|
10
|
+
green: '\x1b[32m',
|
|
11
|
+
yellow: '\x1b[33m',
|
|
12
|
+
cyan: '\x1b[36m',
|
|
13
|
+
gray: '\x1b[90m',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const c = (code) => (str) => enabled ? `${code}${str}${codes.reset}` : str;
|
|
17
|
+
|
|
18
|
+
export const title = c(codes.cyan); // DarkCyan — banner, section headers
|
|
19
|
+
export const info = c(codes.cyan); // DarkCyan — [fnx] prefixed lines
|
|
20
|
+
export const funcName = c(codes.yellow); // DarkYellow — function names
|
|
21
|
+
export const url = c(codes.green); // DarkGreen — URLs
|
|
22
|
+
export const success = c(codes.green); // DarkGreen — ✓ success
|
|
23
|
+
export const error = c(codes.red); // Red — errors, ✗
|
|
24
|
+
export const warning = c(codes.yellow); // DarkYellow — ⚠️ warnings
|
|
25
|
+
export const verbose = c(codes.green); // DarkGreen — debug/verbose
|
|
26
|
+
export const dim = c(codes.gray); // DarkGray — low-priority
|
|
27
|
+
export const bold = c(codes.bold); // Bold — emphasis
|
|
28
|
+
|
|
29
|
+
// Highlight URLs within a string
|
|
30
|
+
export function highlightUrls(str) {
|
|
31
|
+
return str.replace(/(https?:\/\/[^\s,)]+)/g, (m) => url(m));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { enabled, codes };
|
package/lib/host-launcher.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createInterface } from 'node:readline';
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { getHostExeName } from './host-manager.js';
|
|
7
7
|
import { ensureAzurite, stopAzurite } from './azurite-manager.js';
|
|
8
|
+
import { title, info, funcName, url as urlColor, success, error as errorColor, warning, dim, highlightUrls } from './colors.js';
|
|
8
9
|
|
|
9
10
|
// ─── Shared host state (consumed by live MCP server) ─────────────────────
|
|
10
11
|
// This object is populated by the log filter and exposed for the MCP server.
|
|
@@ -259,15 +260,15 @@ function createLogFilter(verbose, hostState) {
|
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
if (httpFunctions.length > 0 || nonHttpFunctions.length > 0) {
|
|
262
|
-
console.log('\nFunctions:\n');
|
|
263
|
+
console.log(title('\nFunctions:\n'));
|
|
263
264
|
for (const fn of httpFunctions) {
|
|
264
|
-
console.log(`\t${fn.name}: [${fn.methods}] ${baseUrl}/${fn.route}`);
|
|
265
|
+
console.log(`\t${funcName(fn.name)}: [${fn.methods}] ${urlColor(`${baseUrl}/${fn.route}`)}`);
|
|
265
266
|
}
|
|
266
267
|
for (const fn of nonHttpFunctions) {
|
|
267
|
-
console.log(`\t${fn.name}: ${fn.triggerType}`);
|
|
268
|
+
console.log(`\t${funcName(fn.name)}: ${fn.triggerType}`);
|
|
268
269
|
}
|
|
269
270
|
if (!verbose) {
|
|
270
|
-
console.log('\nFor detailed output, run fnx with --verbose flag.');
|
|
271
|
+
console.log(dim('\nFor detailed output, run fnx with --verbose flag.'));
|
|
271
272
|
}
|
|
272
273
|
console.log();
|
|
273
274
|
}
|
|
@@ -277,6 +278,24 @@ function createLogFilter(verbose, hostState) {
|
|
|
277
278
|
return { processLine, extractFunctionInfo, extractListeningUrl, isSuppressed };
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
function colorizeHostOutput(line) {
|
|
282
|
+
// Executing/Executed function invocations
|
|
283
|
+
const execMatch = line.match(/^(.*?)(Execut(?:ing|ed) 'Functions\.)(\w+)('.*?)(\(Succeeded.*?\)|\(Failed.*?\))?(.*)$/);
|
|
284
|
+
if (execMatch) {
|
|
285
|
+
let result = execMatch[1] + execMatch[2] + funcName(execMatch[3]) + execMatch[4];
|
|
286
|
+
if (execMatch[5]) {
|
|
287
|
+
result += execMatch[5].startsWith('(Succeeded') ? success(execMatch[5]) : errorColor(execMatch[5]);
|
|
288
|
+
}
|
|
289
|
+
result += execMatch[6] || '';
|
|
290
|
+
return highlightUrls(result);
|
|
291
|
+
}
|
|
292
|
+
// "Application started" line
|
|
293
|
+
if (line.includes('Application started')) {
|
|
294
|
+
return success(line);
|
|
295
|
+
}
|
|
296
|
+
return highlightUrls(line);
|
|
297
|
+
}
|
|
298
|
+
|
|
280
299
|
export { createLogFilter };
|
|
281
300
|
|
|
282
301
|
export async function launchHost(hostDir, opts) {
|
|
@@ -341,8 +360,8 @@ export async function launchHost(hostDir, opts) {
|
|
|
341
360
|
}
|
|
342
361
|
} catch { /* non-fatal */ }
|
|
343
362
|
} else {
|
|
344
|
-
console.error('⚠️ Python runtime requested but no compatible python (3.9-3.13) found.');
|
|
345
|
-
console.error(' Set "PythonPath" in app.config.json or FNX_PYTHON_PATH env var.');
|
|
363
|
+
console.error(warning('⚠️ Python runtime requested but no compatible python (3.9-3.13) found.'));
|
|
364
|
+
console.error(dim(' Set "PythonPath" in app.config.json or FNX_PYTHON_PATH env var.'));
|
|
346
365
|
}
|
|
347
366
|
}
|
|
348
367
|
|
|
@@ -350,11 +369,11 @@ export async function launchHost(hostDir, opts) {
|
|
|
350
369
|
const azuriteProc = await ensureAzurite(opts.mergedValues, { noAzurite: opts.noAzurite });
|
|
351
370
|
|
|
352
371
|
console.log();
|
|
353
|
-
console.log('Azure Functions Local Emulator (fnx — Phoenix Emulate)');
|
|
354
|
-
console.log(
|
|
355
|
-
console.log(
|
|
372
|
+
console.log(title('Azure Functions Local Emulator (fnx — Phoenix Emulate)'));
|
|
373
|
+
console.log(`${dim('Emulator Version:')} ${info('0.1.0')}`);
|
|
374
|
+
console.log(`${dim('Host Version:')} ${info(`${opts.profile.hostVersion} (${opts.profile.displayName})`)}`);
|
|
356
375
|
if (opts.workerRuntime === 'python' && env['languageWorkers__python__defaultExecutablePath']) {
|
|
357
|
-
console.log(
|
|
376
|
+
console.log(`${dim('Python:')} ${info(`${env['languageWorkers__python__defaultExecutablePath']} (${env['FUNCTIONS_WORKER_RUNTIME_VERSION'] || 'unknown'})`)}`);
|
|
358
377
|
}
|
|
359
378
|
console.log();
|
|
360
379
|
|
|
@@ -387,7 +406,7 @@ export async function launchHost(hostDir, opts) {
|
|
|
387
406
|
|
|
388
407
|
const output = filter.processLine(line);
|
|
389
408
|
if (output) {
|
|
390
|
-
console.log(output);
|
|
409
|
+
console.log(colorizeHostOutput(output));
|
|
391
410
|
}
|
|
392
411
|
});
|
|
393
412
|
}
|
|
@@ -410,8 +429,8 @@ export async function launchHost(hostDir, opts) {
|
|
|
410
429
|
|
|
411
430
|
return new Promise((resolve, reject) => {
|
|
412
431
|
child.on('error', (err) => {
|
|
413
|
-
console.error(`\nFailed to start host: ${err.message}`);
|
|
414
|
-
console.error(`Host executable: ${hostExe}`);
|
|
432
|
+
console.error(errorColor(`\nFailed to start host: ${err.message}`));
|
|
433
|
+
console.error(dim(`Host executable: ${hostExe}`));
|
|
415
434
|
hostState.state = 'Error';
|
|
416
435
|
reject(err);
|
|
417
436
|
});
|
|
@@ -419,9 +438,9 @@ export async function launchHost(hostDir, opts) {
|
|
|
419
438
|
child.on('exit', (code, signal) => {
|
|
420
439
|
hostState.state = 'Stopped';
|
|
421
440
|
if (signal) {
|
|
422
|
-
console.log(`\nHost terminated by signal: ${signal}`);
|
|
441
|
+
console.log(dim(`\nHost terminated by signal: ${signal}`));
|
|
423
442
|
} else if (code !== 0) {
|
|
424
|
-
console.error(`\nHost exited with code: ${code}`);
|
|
443
|
+
console.error(errorColor(`\nHost exited with code: ${code}`));
|
|
425
444
|
}
|
|
426
445
|
resolve({ code, hostState });
|
|
427
446
|
});
|
package/lib/host-manager.js
CHANGED
|
@@ -6,6 +6,7 @@ import { homedir, platform } from 'node:os';
|
|
|
6
6
|
import { createWriteStream } from 'node:fs';
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
8
|
import { arch } from 'node:os';
|
|
9
|
+
import { info, success, warning, dim } from './colors.js';
|
|
9
10
|
|
|
10
11
|
const FNX_HOME = join(homedir(), '.fnx');
|
|
11
12
|
const HOST_CACHE = join(FNX_HOME, 'hosts');
|
|
@@ -125,7 +126,7 @@ export async function ensureHost(profile, { force = false, keepVersions = DEFAUL
|
|
|
125
126
|
const hostExe = join(hostDir, getHostExeName());
|
|
126
127
|
|
|
127
128
|
if (!force && existsSync(hostExe)) {
|
|
128
|
-
console.log(' Host cached, skipping download.');
|
|
129
|
+
console.log(success(' Host cached, skipping download.'));
|
|
129
130
|
await updateVersionMetadata(HOST_META_FILE, profile.hostVersion, {
|
|
130
131
|
lastUsedAt: toIsoNow(),
|
|
131
132
|
sku: profile.name || profile.displayName,
|
|
@@ -150,7 +151,7 @@ export async function ensureHost(profile, { force = false, keepVersions = DEFAUL
|
|
|
150
151
|
);
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
console.log(` Downloading host ${profile.hostVersion} for ${rid}...`);
|
|
154
|
+
console.log(info(` Downloading host ${profile.hostVersion} for ${rid}...`));
|
|
154
155
|
|
|
155
156
|
await mkdir(hostDir, { recursive: true });
|
|
156
157
|
const tempZip = join(hostDir, '_download.zip');
|
|
@@ -196,7 +197,7 @@ export async function ensureHost(profile, { force = false, keepVersions = DEFAUL
|
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
199
|
|
|
199
|
-
console.log(' Host ready.');
|
|
200
|
+
console.log(success(' Host ready.'));
|
|
200
201
|
} finally {
|
|
201
202
|
try { await rm(tempZip); } catch { /* ignore */ }
|
|
202
203
|
}
|
|
@@ -242,7 +243,7 @@ async function patchWorkerConfigs(hostDir) {
|
|
|
242
243
|
const old = config.description.defaultExecutablePath;
|
|
243
244
|
config.description.defaultExecutablePath = bestPython;
|
|
244
245
|
await writeFile(workerConfig, JSON.stringify(config, null, 4));
|
|
245
|
-
console.log(` Patched python worker config: ${old} → ${bestPython}`);
|
|
246
|
+
console.log(dim(` Patched python worker config: ${old} → ${bestPython}`));
|
|
246
247
|
}
|
|
247
248
|
} catch { /* non-fatal */ }
|
|
248
249
|
}
|
|
@@ -289,7 +290,7 @@ export async function ensureBundle(profile, { force = false, keepVersions = DEFA
|
|
|
289
290
|
const maxVersion = profile.maxExtensionBundleVersion;
|
|
290
291
|
|
|
291
292
|
// Fetch CDN index to get all available versions
|
|
292
|
-
console.log(' Resolving extension bundle...');
|
|
293
|
+
console.log(info(' Resolving extension bundle...'));
|
|
293
294
|
let allVersions;
|
|
294
295
|
try {
|
|
295
296
|
const indexUrl = `${BUNDLE_CDN}/${BUNDLE_ID}/index.json`;
|
|
@@ -298,7 +299,7 @@ export async function ensureBundle(profile, { force = false, keepVersions = DEFA
|
|
|
298
299
|
allVersions = await res.json();
|
|
299
300
|
} catch (err) {
|
|
300
301
|
// If CDN is unreachable, check if we have any cached version that fits
|
|
301
|
-
console.log(` ⚠️ Bundle index fetch failed (${err.message}), checking cache...`);
|
|
302
|
+
console.log(warning(` ⚠️ Bundle index fetch failed (${err.message}), checking cache...`));
|
|
302
303
|
return findCachedBundle(bundleDir, range, maxVersion);
|
|
303
304
|
}
|
|
304
305
|
|
|
@@ -313,7 +314,7 @@ export async function ensureBundle(profile, { force = false, keepVersions = DEFA
|
|
|
313
314
|
|
|
314
315
|
const versionDir = join(bundleDir, bestVersion);
|
|
315
316
|
if (!force && existsSync(join(versionDir, 'bundle.json'))) {
|
|
316
|
-
console.log(` Bundle ${bestVersion} cached.`);
|
|
317
|
+
console.log(success(` Bundle ${bestVersion} cached.`));
|
|
317
318
|
await updateVersionMetadata(BUNDLE_META_FILE, bestVersion, {
|
|
318
319
|
lastUsedAt: toIsoNow(),
|
|
319
320
|
sku: profile.name || profile.displayName,
|
|
@@ -329,7 +330,7 @@ export async function ensureBundle(profile, { force = false, keepVersions = DEFA
|
|
|
329
330
|
|
|
330
331
|
// Download and extract
|
|
331
332
|
const zipUrl = `${BUNDLE_CDN}/${BUNDLE_ID}/${bestVersion}/${BUNDLE_ID}.${bestVersion}_any-any.zip`;
|
|
332
|
-
console.log(` Downloading bundle ${bestVersion}...`);
|
|
333
|
+
console.log(info(` Downloading bundle ${bestVersion}...`));
|
|
333
334
|
|
|
334
335
|
await mkdir(versionDir, { recursive: true });
|
|
335
336
|
const tempZip = join(versionDir, '_bundle.zip');
|
|
@@ -364,7 +365,7 @@ export async function ensureBundle(profile, { force = false, keepVersions = DEFA
|
|
|
364
365
|
} else {
|
|
365
366
|
execSync(`unzip -o -q "${tempZip}" -d "${versionDir}"`, { stdio: 'pipe' });
|
|
366
367
|
}
|
|
367
|
-
console.log(` Bundle ${bestVersion} ready.`);
|
|
368
|
+
console.log(success(` Bundle ${bestVersion} ready.`));
|
|
368
369
|
} finally {
|
|
369
370
|
try { await rm(tempZip); } catch { /* ignore */ }
|
|
370
371
|
}
|
|
@@ -385,7 +386,7 @@ function findCachedBundle(bundleDir, range, maxVersion) {
|
|
|
385
386
|
const cached = readdirSync(bundleDir).filter(d => existsSync(join(bundleDir, d, 'bundle.json')));
|
|
386
387
|
const best = findBestBundleVersion(cached, range, maxVersion);
|
|
387
388
|
if (best) {
|
|
388
|
-
console.log(` Using cached bundle ${best}.`);
|
|
389
|
+
console.log(success(` Using cached bundle ${best}.`));
|
|
389
390
|
}
|
|
390
391
|
return best;
|
|
391
392
|
}
|
|
@@ -395,4 +396,10 @@ export function getCachedHostVersions() {
|
|
|
395
396
|
return readdirSync(HOST_CACHE).filter((v) => existsSync(join(HOST_CACHE, v, getHostExeName())));
|
|
396
397
|
}
|
|
397
398
|
|
|
399
|
+
export function getCachedBundleVersions() {
|
|
400
|
+
const bundleDir = join(BUNDLE_CACHE, BUNDLE_ID);
|
|
401
|
+
if (!existsSync(bundleDir)) return [];
|
|
402
|
+
return readdirSync(bundleDir).filter((d) => existsSync(join(bundleDir, d, 'bundle.json')));
|
|
403
|
+
}
|
|
404
|
+
|
|
398
405
|
export { getHostExeName, getPlatformRid, compareVersions, parseVersion, DEFAULT_KEEP_VERSIONS };
|
package/lib/live-mcp-server.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { createServer as createHttpServer } from 'node:http';
|
|
19
|
+
import { url as urlColor, warning } from './colors.js';
|
|
19
20
|
|
|
20
21
|
// ─── Tool registration (called per session) ─────────────────────────
|
|
21
22
|
|
|
@@ -341,9 +342,9 @@ export async function startLiveMcpServer(hostState, mcpPort) {
|
|
|
341
342
|
httpServer.removeListener('error', onError);
|
|
342
343
|
// Runtime errors after startup
|
|
343
344
|
httpServer.on('error', (err) => {
|
|
344
|
-
console.error(` ⚠️ MCP server error: ${err.message}`);
|
|
345
|
+
console.error(warning(` ⚠️ MCP server error: ${err.message}`));
|
|
345
346
|
});
|
|
346
|
-
console.log(` Functions Debug MCP Server: http://127.0.0.1:${port}/mcp`);
|
|
347
|
+
console.log(` Functions Debug MCP Server: ${urlColor(`http://127.0.0.1:${port}/mcp`)}`);
|
|
347
348
|
resolve(httpServer);
|
|
348
349
|
});
|
|
349
350
|
}
|
package/lib/profile-resolver.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
3
|
import { join, resolve as resolvePath, isAbsolute } from 'node:path';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -110,3 +111,17 @@ export async function listProfiles() {
|
|
|
110
111
|
}
|
|
111
112
|
console.log(`\n Last updated: ${registry.updatedAt}`);
|
|
112
113
|
}
|
|
114
|
+
|
|
115
|
+
/** Synchronous read: cached profiles → bundled fallback. No network. */
|
|
116
|
+
export function readProfilesSync() {
|
|
117
|
+
try {
|
|
118
|
+
if (existsSync(CACHE_FILE)) {
|
|
119
|
+
return JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
|
|
120
|
+
}
|
|
121
|
+
} catch { /* fall through */ }
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(readFileSync(BUNDLED_PROFILES_PATH, 'utf-8'));
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
package/lib/warmup.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { resolveProfile, listProfiles, fetchRegistryWithMeta } from './profile-resolver.js';
|
|
6
6
|
import { ensureHost, ensureBundle, getHostExeName, getPlatformRid } from './host-manager.js';
|
|
7
|
+
import { title, info, success, warning, dim } from './colors.js';
|
|
7
8
|
|
|
8
9
|
const FNX_DIR = join(homedir(), '.fnx');
|
|
9
10
|
const META_FILE = join(FNX_DIR, '_meta.json');
|
|
@@ -57,9 +58,9 @@ async function runWarmup(args) {
|
|
|
57
58
|
const rid = getPlatformRid();
|
|
58
59
|
|
|
59
60
|
console.log();
|
|
60
|
-
console.log(`fnx warmup — pre-downloading assets for ${all ? 'ALL SKUs' : 'offline use'}`);
|
|
61
|
+
console.log(title(`fnx warmup — pre-downloading assets for ${all ? 'ALL SKUs' : 'offline use'}`));
|
|
61
62
|
console.log();
|
|
62
|
-
console.log(` Platform: ${rid}`);
|
|
63
|
+
console.log(` ${dim('Platform:')} ${info(rid)}`);
|
|
63
64
|
|
|
64
65
|
const meta = await loadMeta();
|
|
65
66
|
|
|
@@ -68,10 +69,10 @@ async function runWarmup(args) {
|
|
|
68
69
|
console.log();
|
|
69
70
|
const profile = await resolveProfile(skuName);
|
|
70
71
|
|
|
71
|
-
console.log(` Target SKU: ${profile.displayName} (${skuName})`);
|
|
72
|
-
console.log(` Host Version: ${profile.hostVersion}`);
|
|
72
|
+
console.log(` ${dim('Target SKU:')} ${info(`${profile.displayName} (${skuName})`)}`);
|
|
73
|
+
console.log(` ${dim('Host Version:')} ${info(profile.hostVersion)}`);
|
|
73
74
|
if (profile.maxExtensionBundleVersion) {
|
|
74
|
-
console.log(` Bundle Range: ${profile.extensionBundleVersion} (max: ${profile.maxExtensionBundleVersion})`);
|
|
75
|
+
console.log(` ${dim('Bundle Range:')} ${info(`${profile.extensionBundleVersion} (max: ${profile.maxExtensionBundleVersion})`)}`);
|
|
75
76
|
}
|
|
76
77
|
console.log();
|
|
77
78
|
|
|
@@ -99,9 +100,9 @@ async function runWarmup(args) {
|
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
console.log();
|
|
102
|
-
console.log(` ✓ fnx start --sku ${skuName} will work offline.`);
|
|
103
|
+
console.log(success(` ✓ fnx start --sku ${skuName} will work offline.`));
|
|
103
104
|
} catch (err) {
|
|
104
|
-
console.error(` ⚠️ Warmup failed for ${skuName}: ${err.message}`);
|
|
105
|
+
console.error(warning(` ⚠️ Warmup failed for ${skuName}: ${err.message}`));
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
@@ -113,7 +114,7 @@ async function runWarmup(args) {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
console.log();
|
|
116
|
-
console.log(' Done.');
|
|
117
|
+
console.log(success(' Done.'));
|
|
117
118
|
console.log();
|
|
118
119
|
}
|
|
119
120
|
|