@vrdmr/fnx-test 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -0
- package/bin/fnx +3 -0
- package/bin/fnx-template-mcp +21 -0
- package/lib/azurite-manager.js +197 -0
- package/lib/cli.js +307 -0
- package/lib/dotnet-detector.js +60 -0
- package/lib/host-launcher.js +415 -0
- package/lib/host-manager.js +280 -0
- package/lib/live-mcp-server.js +382 -0
- package/lib/mcp-server.js +144 -0
- package/lib/mcp-tools/sku.js +136 -0
- package/lib/mcp-tools/templates.js +140 -0
- package/lib/profile-resolver.js +118 -0
- package/lib/warmup.js +203 -0
- package/package.json +36 -0
- package/profiles/sku-profiles.json +87 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# fnx — SKU-aware Azure Functions Local Emulator
|
|
2
|
+
|
|
3
|
+
Run Azure Functions locally with the **exact host version** your target SKU uses in production. No more "works locally, breaks in Azure."
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @vrdmr/fnx-test
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @vrdmr/fnx-test start --sku flex --scriptroot ./my-app
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# List available SKUs
|
|
21
|
+
fnx start --sku list
|
|
22
|
+
|
|
23
|
+
# Run your function app with the Flex Consumption host
|
|
24
|
+
fnx start --sku flex --scriptroot ./my-function-app
|
|
25
|
+
|
|
26
|
+
# Run with Windows Consumption host
|
|
27
|
+
fnx start --sku windows-consumption --scriptroot ./my-function-app
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## What It Does
|
|
31
|
+
|
|
32
|
+
`fnx` downloads and caches the **self-contained host binary** for your target SKU, then launches your function app against it. Each SKU maps to a specific host version, extension bundle version, and runtime configuration — matching what runs in Azure.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
fnx start --sku flex
|
|
36
|
+
├── Resolves SKU profile (host v4.1047.100, bundle v4.30.0)
|
|
37
|
+
├── Downloads host binary (cached at ~/.fnx/hosts/)
|
|
38
|
+
├── Downloads extension bundle (cached at ~/.fnx/bundles/)
|
|
39
|
+
├── Auto-starts Azurite if needed (storage triggers)
|
|
40
|
+
└── Launches host → your functions are live
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Available SKUs
|
|
44
|
+
|
|
45
|
+
| SKU | Description |
|
|
46
|
+
|-----|-------------|
|
|
47
|
+
| `flex` | Flex Consumption (latest host, latest bundle) |
|
|
48
|
+
| `linux-premium` | Linux Premium (EP1/EP2/EP3) |
|
|
49
|
+
| `linux-consumption` | Linux Consumption |
|
|
50
|
+
| `windows-consumption` | Windows Consumption |
|
|
51
|
+
| `windows-dedicated` | Windows Dedicated (App Service Plan) |
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
fnx start --sku <sku> --scriptroot <path> # Run function app
|
|
57
|
+
fnx start --sku list # List available SKUs
|
|
58
|
+
fnx warmup [--sku <sku>] [--all] # Pre-download host + bundle
|
|
59
|
+
fnx templates-mcp # Start MCP server for AI agents
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## MCP Server (for AI Agents)
|
|
63
|
+
|
|
64
|
+
fnx includes an MCP server that exposes Azure Functions templates to AI coding assistants:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
// .vscode/mcp.json
|
|
68
|
+
{
|
|
69
|
+
"servers": {
|
|
70
|
+
"azure-functions-templates": {
|
|
71
|
+
"type": "stdio",
|
|
72
|
+
"command": "npx",
|
|
73
|
+
"args": ["@vrdmr/fnx-test", "templates-mcp"]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or use the direct entrypoint for faster cold starts:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"servers": {
|
|
84
|
+
"azure-functions-templates": {
|
|
85
|
+
"type": "stdio",
|
|
86
|
+
"command": "npx",
|
|
87
|
+
"args": ["fnx-template-mcp"]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Features
|
|
94
|
+
|
|
95
|
+
- **SKU-aware** — match the exact host version your target Azure environment runs
|
|
96
|
+
- **Zero dependencies** — pure Node.js 18+, no native modules
|
|
97
|
+
- **Offline-capable** — bundled profiles + cached hosts work without network
|
|
98
|
+
- **Auto Azurite** — storage emulator starts automatically for blob/queue/timer triggers
|
|
99
|
+
- **.NET isolated only** — blocks in-process .NET projects with migration guidance
|
|
100
|
+
- **MCP integration** — AI agents can discover templates and SKU profiles
|
|
101
|
+
|
|
102
|
+
## Requirements
|
|
103
|
+
|
|
104
|
+
- Node.js 18+
|
|
105
|
+
- ~200MB disk per host version (cached at `~/.fnx/`)
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
package/bin/fnx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone MCP entrypoint for Azure Functions templates.
|
|
4
|
+
* Bypasses cli.js entirely — never imports host-manager, host-launcher,
|
|
5
|
+
* or any module that triggers host downloads.
|
|
6
|
+
*
|
|
7
|
+
* Usage: npx fnx-template-mcp (stdio transport)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { runStdioMcpServer } from '../lib/mcp-server.js';
|
|
11
|
+
import { getTemplateTools } from '../lib/mcp-tools/templates.js';
|
|
12
|
+
import { getSkuTools } from '../lib/mcp-tools/sku.js';
|
|
13
|
+
|
|
14
|
+
const templateTools = await getTemplateTools();
|
|
15
|
+
const skuTools = getSkuTools();
|
|
16
|
+
|
|
17
|
+
await runStdioMcpServer({
|
|
18
|
+
name: 'fnx-templates-mcp',
|
|
19
|
+
version: '0.1.0',
|
|
20
|
+
tools: [...templateTools, ...skuTools],
|
|
21
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawn, execSync } from 'node:child_process';
|
|
2
|
+
import { createConnection } from 'node:net';
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
|
|
7
|
+
const BLOB_PORT = 10000;
|
|
8
|
+
const QUEUE_PORT = 10001;
|
|
9
|
+
const TABLE_PORT = 10002;
|
|
10
|
+
const AZURITE_INSTALL_DIR = join(homedir(), '.fnx', 'tools', 'azurite');
|
|
11
|
+
|
|
12
|
+
let azuriteProcess = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Determine whether Azurite is needed based on AzureWebJobsStorage value.
|
|
16
|
+
* Returns true for "UseDevelopmentStorage=true", empty string, or missing key.
|
|
17
|
+
*/
|
|
18
|
+
function needsAzurite(mergedValues) {
|
|
19
|
+
const connStr = mergedValues?.AzureWebJobsStorage;
|
|
20
|
+
if (!connStr || connStr === '') return true;
|
|
21
|
+
if (connStr === 'UseDevelopmentStorage=true') return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* TCP probe — resolves true if a connection can be established on the given port.
|
|
27
|
+
*/
|
|
28
|
+
function isPortInUse(port, host = '127.0.0.1', timeoutMs = 1000) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const socket = createConnection({ port, host });
|
|
31
|
+
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
|
32
|
+
socket.on('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
|
33
|
+
socket.on('error', () => { clearTimeout(timer); socket.destroy(); resolve(false); });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wait until a TCP port becomes reachable (up to timeoutMs).
|
|
39
|
+
*/
|
|
40
|
+
async function waitForTcp(port, { host = '127.0.0.1', timeoutMs = 15000, intervalMs = 300 } = {}) {
|
|
41
|
+
const deadline = Date.now() + timeoutMs;
|
|
42
|
+
while (Date.now() < deadline) {
|
|
43
|
+
if (await isPortInUse(port, host, 500)) return true;
|
|
44
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if Azurite default ports are already in use.
|
|
51
|
+
*/
|
|
52
|
+
async function isAzuriteRunning() {
|
|
53
|
+
return await isPortInUse(BLOB_PORT);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find an existing azurite binary (global install, npx, or cached in ~/.fnx/tools/azurite).
|
|
58
|
+
* Returns the path/command or null.
|
|
59
|
+
*/
|
|
60
|
+
function findAzurite() {
|
|
61
|
+
// 1. Check the fnx tools cache first
|
|
62
|
+
const cachedBin = join(AZURITE_INSTALL_DIR, 'node_modules', '.bin', 'azurite');
|
|
63
|
+
if (existsSync(cachedBin)) return cachedBin;
|
|
64
|
+
|
|
65
|
+
// 2. Check global PATH
|
|
66
|
+
try {
|
|
67
|
+
const which = execSync('which azurite', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
|
|
68
|
+
if (which) return which;
|
|
69
|
+
} catch { /* not found */ }
|
|
70
|
+
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Install azurite into ~/.fnx/tools/azurite/ if not already present.
|
|
76
|
+
*/
|
|
77
|
+
function installAzurite() {
|
|
78
|
+
console.log('[fnx] Installing Azurite to ~/.fnx/tools/azurite/ (first-time only)...');
|
|
79
|
+
mkdirSync(AZURITE_INSTALL_DIR, { recursive: true });
|
|
80
|
+
|
|
81
|
+
// Initialize a minimal package.json if missing so npm install works
|
|
82
|
+
const pkgPath = join(AZURITE_INSTALL_DIR, 'package.json');
|
|
83
|
+
if (!existsSync(pkgPath)) {
|
|
84
|
+
writeFileSync(pkgPath, JSON.stringify({ name: 'fnx-azurite-cache', private: true }, null, 2));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
execSync('npm install azurite --save --loglevel=error', {
|
|
89
|
+
cwd: AZURITE_INSTALL_DIR,
|
|
90
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
91
|
+
timeout: 120_000,
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error('[fnx] Failed to install Azurite. Install manually: npm install -g azurite');
|
|
95
|
+
console.error(` ${err.message}`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const installed = join(AZURITE_INSTALL_DIR, 'node_modules', '.bin', 'azurite');
|
|
100
|
+
if (existsSync(installed)) {
|
|
101
|
+
console.log('[fnx] Azurite installed successfully.');
|
|
102
|
+
return installed;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Find azurite binary, installing if necessary.
|
|
109
|
+
*/
|
|
110
|
+
function findOrInstallAzurite() {
|
|
111
|
+
let bin = findAzurite();
|
|
112
|
+
if (bin) return bin;
|
|
113
|
+
return installAzurite();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Main entry point: ensure Azurite is available and running if needed.
|
|
118
|
+
* Returns the child process (caller kills on exit), or null if not started.
|
|
119
|
+
*/
|
|
120
|
+
export async function ensureAzurite(mergedValues, opts = {}) {
|
|
121
|
+
if (opts.noAzurite) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!needsAzurite(mergedValues)) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const storageVal = mergedValues?.AzureWebJobsStorage || '(empty)';
|
|
130
|
+
console.log(`[fnx] Detected AzureWebJobsStorage=${storageVal}`);
|
|
131
|
+
|
|
132
|
+
// Check if Azurite is already running
|
|
133
|
+
if (await isAzuriteRunning()) {
|
|
134
|
+
console.log('[fnx] Using existing Azurite instance on default ports.');
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Find or install azurite
|
|
139
|
+
const azuriteBin = findOrInstallAzurite();
|
|
140
|
+
if (!azuriteBin) {
|
|
141
|
+
console.error('[fnx] ⚠️ Azurite not available. Storage triggers may fail.');
|
|
142
|
+
console.error(' Install with: npm install -g azurite');
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log('[fnx] Starting Azurite storage emulator...');
|
|
147
|
+
|
|
148
|
+
const azuriteArgs = [
|
|
149
|
+
'--blobHost', '127.0.0.1', '--blobPort', String(BLOB_PORT),
|
|
150
|
+
'--queueHost', '127.0.0.1', '--queuePort', String(QUEUE_PORT),
|
|
151
|
+
'--tableHost', '127.0.0.1', '--tablePort', String(TABLE_PORT),
|
|
152
|
+
'--silent',
|
|
153
|
+
'--location', join(homedir(), '.fnx', 'azurite-data'),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
// Ensure data directory exists
|
|
157
|
+
mkdirSync(join(homedir(), '.fnx', 'azurite-data'), { recursive: true });
|
|
158
|
+
|
|
159
|
+
azuriteProcess = spawn(azuriteBin, azuriteArgs, {
|
|
160
|
+
stdio: 'ignore',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
azuriteProcess.on('error', (err) => {
|
|
164
|
+
console.error(`[fnx] Azurite failed to start: ${err.message}`);
|
|
165
|
+
azuriteProcess = null;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
azuriteProcess.on('exit', (code) => {
|
|
169
|
+
if (code && code !== 0) {
|
|
170
|
+
console.error(`[fnx] Azurite exited unexpectedly with code ${code}.`);
|
|
171
|
+
}
|
|
172
|
+
azuriteProcess = null;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Wait for Azurite to be ready
|
|
176
|
+
const ready = await waitForTcp(BLOB_PORT, { timeoutMs: 15000 });
|
|
177
|
+
if (!ready) {
|
|
178
|
+
console.error('[fnx] ⚠️ Azurite did not become ready in time. Storage triggers may fail.');
|
|
179
|
+
return azuriteProcess;
|
|
180
|
+
}
|
|
181
|
+
|
|
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}`);
|
|
185
|
+
|
|
186
|
+
return azuriteProcess;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Stop the managed Azurite process.
|
|
191
|
+
*/
|
|
192
|
+
export function stopAzurite() {
|
|
193
|
+
if (azuriteProcess) {
|
|
194
|
+
try { azuriteProcess.kill(); } catch { /* already dead */ }
|
|
195
|
+
azuriteProcess = null;
|
|
196
|
+
}
|
|
197
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { resolve as resolvePath, dirname, join } from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { resolveProfile, listProfiles, setProfilesSource } from './profile-resolver.js';
|
|
5
|
+
import { ensureHost, ensureBundle } from './host-manager.js';
|
|
6
|
+
import { launchHost, createHostState } from './host-launcher.js';
|
|
7
|
+
import { startLiveMcpServer } from './live-mcp-server.js';
|
|
8
|
+
import { detectDotnetModel, printInProcessError } from './dotnet-detector.js';
|
|
9
|
+
|
|
10
|
+
export async function main(args) {
|
|
11
|
+
const cmd = args[0];
|
|
12
|
+
|
|
13
|
+
if (cmd === '-h' || cmd === '--help' || cmd === 'help' || !cmd) {
|
|
14
|
+
printHelp();
|
|
15
|
+
process.exit(cmd ? 0 : 1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (cmd === '-v' || cmd === '--version') {
|
|
19
|
+
const { readFileSync } = await import('node:fs');
|
|
20
|
+
const { fileURLToPath } = await import('node:url');
|
|
21
|
+
const { dirname, join } = await import('node:path');
|
|
22
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const pkg = JSON.parse(readFileSync(join(dir, '..', 'package.json'), 'utf-8'));
|
|
24
|
+
console.log(`fnx v${pkg.version}`);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (cmd === 'templates-mcp') {
|
|
29
|
+
await startTemplatesMcp();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (cmd === 'warmup') {
|
|
34
|
+
const { warmup } = await import('./warmup.js');
|
|
35
|
+
await warmup(args.slice(1));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (cmd !== 'start') {
|
|
40
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
41
|
+
printHelp();
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const scriptRoot = getFlag(args, '--scriptroot') || process.cwd();
|
|
46
|
+
const port = getFlag(args, '--port') || '7071';
|
|
47
|
+
const mcpPort = getFlag(args, '--mcp-port') || String(parseInt(port) + 1);
|
|
48
|
+
const verbose = args.includes('--verbose');
|
|
49
|
+
const noMcp = args.includes('--no-mcp');
|
|
50
|
+
const noAzurite = args.includes('--no-azurite');
|
|
51
|
+
const profilesSource = getFlag(args, '--profiles');
|
|
52
|
+
|
|
53
|
+
// Set profiles source before any profile resolution
|
|
54
|
+
if (profilesSource) {
|
|
55
|
+
setProfilesSource(profilesSource);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Read config files early (needed for SKU resolution and env vars)
|
|
59
|
+
const appConfig = await readJsonFile(resolvePath(scriptRoot, 'app.config.json'));
|
|
60
|
+
const localSettings = await readJsonFile(resolvePath(scriptRoot, 'local.settings.json'));
|
|
61
|
+
|
|
62
|
+
// Resolve SKU: CLI flag > app.config.json > local.settings.json > default "flex"
|
|
63
|
+
let sku = getFlag(args, '--sku');
|
|
64
|
+
let skuSource = 'CLI flag';
|
|
65
|
+
|
|
66
|
+
if (!sku && appConfig?.TargetSku) {
|
|
67
|
+
sku = appConfig.TargetSku;
|
|
68
|
+
skuSource = 'app.config.json';
|
|
69
|
+
}
|
|
70
|
+
if (!sku && localSettings?.TargetSku) {
|
|
71
|
+
sku = localSettings.TargetSku;
|
|
72
|
+
skuSource = 'local.settings.json';
|
|
73
|
+
}
|
|
74
|
+
if (!sku) {
|
|
75
|
+
sku = 'flex';
|
|
76
|
+
skuSource = 'default';
|
|
77
|
+
console.log(`No --sku specified, defaulting to '${sku}'.`);
|
|
78
|
+
console.log(`Tip: Use --sku <name> to target a specific SKU. Run --sku list to see options.\n`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (sku === 'list') {
|
|
82
|
+
await listProfiles();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 1. Resolve profile
|
|
87
|
+
if (skuSource !== 'default') {
|
|
88
|
+
console.log(`Resolving SKU profile: ${sku} (from ${skuSource})...`);
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`Resolving SKU profile: ${sku}...`);
|
|
91
|
+
}
|
|
92
|
+
const profile = await resolveProfile(sku);
|
|
93
|
+
console.log(` Target SKU: ${profile.displayName}`);
|
|
94
|
+
console.log(` Host Version: ${profile.hostVersion}`);
|
|
95
|
+
console.log(` Extension Bundle: ${profile.extensionBundleVersion}`);
|
|
96
|
+
if (profile.maxExtensionBundleVersion) {
|
|
97
|
+
console.log(` Max Bundle Cap: ${profile.maxExtensionBundleVersion}`);
|
|
98
|
+
}
|
|
99
|
+
console.log();
|
|
100
|
+
|
|
101
|
+
// 2. Ensure host is downloaded
|
|
102
|
+
const hostDir = await ensureHost(profile);
|
|
103
|
+
console.log(` Host path: ${hostDir}`);
|
|
104
|
+
|
|
105
|
+
// 3. Pre-download the correct extension bundle for this SKU
|
|
106
|
+
// This resolves the exact version from CDN index, capped by maxExtensionBundleVersion,
|
|
107
|
+
// and downloads it so the host finds it cached and never fetches a wrong version.
|
|
108
|
+
const resolvedBundleVersion = await ensureBundle(profile);
|
|
109
|
+
if (resolvedBundleVersion) {
|
|
110
|
+
console.log(` Bundle resolved: ${resolvedBundleVersion}`);
|
|
111
|
+
}
|
|
112
|
+
console.log();
|
|
113
|
+
|
|
114
|
+
// 4. Merge config: app.config.json Values + local.settings.json Values
|
|
115
|
+
const mergedValues = {
|
|
116
|
+
...(appConfig?.Values || {}),
|
|
117
|
+
...(localSettings?.Values || {}),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const workerRuntime = mergedValues.FUNCTIONS_WORKER_RUNTIME;
|
|
121
|
+
|
|
122
|
+
if (!workerRuntime) {
|
|
123
|
+
console.error('Error: FUNCTIONS_WORKER_RUNTIME not set in app.config.json or local.settings.json');
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// F9: .NET isolated worker only — block in-process projects with guidance
|
|
128
|
+
const dotnetRuntimes = ['dotnet', 'dotnet-isolated'];
|
|
129
|
+
if (dotnetRuntimes.includes(workerRuntime)) {
|
|
130
|
+
const detection = await detectDotnetModel(resolvePath(scriptRoot));
|
|
131
|
+
if (detection.isInProcess) {
|
|
132
|
+
printInProcessError(detection.csprojPath);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. Create shared host state and start live MCP server
|
|
138
|
+
const hostState = createHostState();
|
|
139
|
+
|
|
140
|
+
if (!noMcp) {
|
|
141
|
+
startLiveMcpServer(hostState, parseInt(mcpPort)).catch((err) => {
|
|
142
|
+
console.error(` ⚠️ MCP server failed to start on port ${mcpPort}: ${err.message}`);
|
|
143
|
+
console.error(` Use --no-mcp to disable, or --mcp-port <port> to change port.`);
|
|
144
|
+
});
|
|
145
|
+
// Don't await — host startup should not depend on MCP server
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 6. Launch host
|
|
149
|
+
// Pin the bundle version to what we pre-downloaded, so the host uses exactly that.
|
|
150
|
+
// If bundle resolution failed (offline, no match), fall back to the clamped range.
|
|
151
|
+
let effectiveBundleVersion;
|
|
152
|
+
if (resolvedBundleVersion) {
|
|
153
|
+
// Exact pin: host will find this version cached and use it
|
|
154
|
+
effectiveBundleVersion = `[${resolvedBundleVersion}, ${resolvedBundleVersion}]`;
|
|
155
|
+
} else {
|
|
156
|
+
// Fallback: clamp range if maxExtensionBundleVersion is set
|
|
157
|
+
effectiveBundleVersion = profile.extensionBundleVersion;
|
|
158
|
+
if (profile.maxExtensionBundleVersion) {
|
|
159
|
+
const maxParts = profile.maxExtensionBundleVersion.split('.').map(Number);
|
|
160
|
+
const ceilVersion = `${maxParts[0]}.${maxParts[1]}.${(maxParts[2] || 0) + 1}`;
|
|
161
|
+
const lowerBound = effectiveBundleVersion.match(/^\[([^\],]+)/);
|
|
162
|
+
if (lowerBound) {
|
|
163
|
+
effectiveBundleVersion = `[${lowerBound[1]}, ${ceilVersion})`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await launchHost(hostDir, {
|
|
169
|
+
scriptRoot: resolvePath(scriptRoot),
|
|
170
|
+
port,
|
|
171
|
+
workerRuntime,
|
|
172
|
+
extensionBundleVersion: effectiveBundleVersion,
|
|
173
|
+
mergedValues,
|
|
174
|
+
profile,
|
|
175
|
+
verbose,
|
|
176
|
+
hostState,
|
|
177
|
+
noAzurite,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function startTemplatesMcp() {
|
|
182
|
+
const { runStdioMcpServer } = await import('./mcp-server.js');
|
|
183
|
+
const { getTemplateTools } = await import('./mcp-tools/templates.js');
|
|
184
|
+
const { getSkuTools } = await import('./mcp-tools/sku.js');
|
|
185
|
+
|
|
186
|
+
const templateTools = await getTemplateTools();
|
|
187
|
+
const skuTools = getSkuTools();
|
|
188
|
+
|
|
189
|
+
await runStdioMcpServer({
|
|
190
|
+
name: 'fnx-templates-mcp',
|
|
191
|
+
version: '0.1.0',
|
|
192
|
+
tools: [...templateTools, ...skuTools],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function getFlag(args, flag) {
|
|
197
|
+
const idx = args.indexOf(flag);
|
|
198
|
+
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function readJsonFile(filePath) {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(await readFile(filePath, 'utf-8'));
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function printHelp() {
|
|
210
|
+
console.log(`
|
|
211
|
+
Azure Functions Local Emulator (fnx — Phoenix Emulate)
|
|
212
|
+
SKU-aware host runtime for local development.
|
|
213
|
+
|
|
214
|
+
Usage: fnx <action> [-/--options]
|
|
215
|
+
|
|
216
|
+
Actions:
|
|
217
|
+
start Launch the Azure Functions host runtime for a specific SKU.
|
|
218
|
+
Downloads and caches the correct host version automatically.
|
|
219
|
+
warmup Pre-download host binaries and extension bundles for offline use.
|
|
220
|
+
Runs automatically as postinstall hook. Use --dry-run to preview.
|
|
221
|
+
templates-mcp Start the Azure Functions templates MCP server (stdio transport).
|
|
222
|
+
Drop-in replacement for manvir-templates-mcp-server.
|
|
223
|
+
Provides 68 templates across 4 languages via MCP protocol.
|
|
224
|
+
|
|
225
|
+
Options:
|
|
226
|
+
--sku <name> Target SKU to emulate. Determines which host version runs.
|
|
227
|
+
Resolution order: CLI flag → app.config.json → local.settings.json → default (flex).
|
|
228
|
+
Use --sku list to see all available SKUs.
|
|
229
|
+
--scriptroot Path to the function app directory. Defaults to the current directory.
|
|
230
|
+
Must contain host.json and either app.config.json or local.settings.json.
|
|
231
|
+
--port <port> Port for the host HTTP listener. Default: 7071.
|
|
232
|
+
--mcp-port <p> Port for the live MCP server. Default: host port + 1 (7072).
|
|
233
|
+
--no-mcp Disable the live MCP server (host-only mode).
|
|
234
|
+
--no-azurite Skip automatic Azurite start (for users who manage Azurite separately).
|
|
235
|
+
--profiles <src> SKU profiles source. Can be:
|
|
236
|
+
• A URL (http/https) to a profiles JSON endpoint
|
|
237
|
+
• A local file path to a profiles JSON file
|
|
238
|
+
• Inline JSON string (e.g. '{"profiles":{...}}')
|
|
239
|
+
Default: FUNC_PROFILES_URL env var, or http://localhost:4566/api/profiles.
|
|
240
|
+
--verbose Show all host output (unfiltered). Default: clean output only.
|
|
241
|
+
-v, --version Display the version of fnx.
|
|
242
|
+
-h, --help Display this help information.
|
|
243
|
+
|
|
244
|
+
Available SKUs:
|
|
245
|
+
flex Azure Functions Flex Consumption (latest host, default)
|
|
246
|
+
linux-premium Linux Premium / Elastic Premium
|
|
247
|
+
windows-consumption Windows Consumption (classic)
|
|
248
|
+
windows-dedicated Windows Dedicated (App Service Plan)
|
|
249
|
+
linux-consumption Linux Consumption (retiring)
|
|
250
|
+
|
|
251
|
+
Configuration:
|
|
252
|
+
app.config.json Non-secret app settings (committed to source control).
|
|
253
|
+
Contains TargetSku and Values (e.g. FUNCTIONS_WORKER_RUNTIME).
|
|
254
|
+
local.settings.json Secrets and connection strings (git-ignored).
|
|
255
|
+
Values here override app.config.json Values.
|
|
256
|
+
|
|
257
|
+
Config values from both files are merged and injected as environment
|
|
258
|
+
variables into the host process. local.settings.json values take precedence.
|
|
259
|
+
|
|
260
|
+
Examples:
|
|
261
|
+
fnx start Start with default SKU (flex) in current directory
|
|
262
|
+
fnx start --sku flex Emulate Flex Consumption
|
|
263
|
+
fnx start --sku windows-consumption Emulate Windows Consumption (older host version)
|
|
264
|
+
fnx start --sku list List all available SKU profiles with host versions
|
|
265
|
+
fnx start --sku flex --port 8080 Start on a custom port
|
|
266
|
+
fnx start --scriptroot ./my-app Start from a specific function app directory
|
|
267
|
+
|
|
268
|
+
Side-by-side comparison:
|
|
269
|
+
# Terminal 1: Run as Flex Consumption
|
|
270
|
+
fnx start --sku flex --port 7071
|
|
271
|
+
|
|
272
|
+
# Terminal 2: Run as Windows Consumption (different host version)
|
|
273
|
+
fnx start --sku windows-consumption --port 7072
|
|
274
|
+
|
|
275
|
+
# Compare behavior across SKUs with the same function app!
|
|
276
|
+
|
|
277
|
+
MCP server (for VS Code Copilot / AI assistants):
|
|
278
|
+
fnx templates-mcp Start templates MCP server (stdio)
|
|
279
|
+
fnx start Also starts live MCP server on port+1
|
|
280
|
+
fnx start --mcp-port 9000 Live MCP server on custom port
|
|
281
|
+
fnx start --no-mcp Disable live MCP server
|
|
282
|
+
|
|
283
|
+
# .vscode/mcp.json — templates only (stdio):
|
|
284
|
+
# {
|
|
285
|
+
# "servers": {
|
|
286
|
+
# "azure-functions-templates": {
|
|
287
|
+
# "type": "stdio",
|
|
288
|
+
# "command": "fnx",
|
|
289
|
+
# "args": ["templates-mcp"]
|
|
290
|
+
# }
|
|
291
|
+
# }
|
|
292
|
+
# }
|
|
293
|
+
#
|
|
294
|
+
# .vscode/mcp.json — live host data (when fnx start is running):
|
|
295
|
+
# {
|
|
296
|
+
# "servers": {
|
|
297
|
+
# "fnx-live": {
|
|
298
|
+
# "type": "http",
|
|
299
|
+
# "url": "http://127.0.0.1:7072/mcp"
|
|
300
|
+
# }
|
|
301
|
+
# }
|
|
302
|
+
# }
|
|
303
|
+
|
|
304
|
+
Supported runtimes: node, python, java, powershell, dotnet-isolated
|
|
305
|
+
(.NET in-process / Microsoft.NET.Sdk.Functions is not supported — isolated worker model only)
|
|
306
|
+
`.trim());
|
|
307
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MIGRATION_URL = 'https://learn.microsoft.com/azure/azure-functions/migrate-dotnet-to-isolated-model';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scan scriptRoot for .csproj files and determine .NET hosting model.
|
|
8
|
+
* Returns: { isDotnet: boolean, isInProcess: boolean, csprojPath: string|null }
|
|
9
|
+
*
|
|
10
|
+
* Detection:
|
|
11
|
+
* - Microsoft.Azure.Functions.Worker.Sdk → isolated (supported)
|
|
12
|
+
* - Microsoft.NET.Sdk.Functions → in-process (blocked)
|
|
13
|
+
*/
|
|
14
|
+
export async function detectDotnetModel(scriptRoot) {
|
|
15
|
+
let files;
|
|
16
|
+
try {
|
|
17
|
+
files = await readdir(scriptRoot);
|
|
18
|
+
} catch {
|
|
19
|
+
return { isDotnet: false, isInProcess: false, csprojPath: null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const csprojFiles = files.filter(f => f.endsWith('.csproj'));
|
|
23
|
+
if (csprojFiles.length === 0) {
|
|
24
|
+
return { isDotnet: false, isInProcess: false, csprojPath: null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const file of csprojFiles) {
|
|
28
|
+
const fullPath = join(scriptRoot, file);
|
|
29
|
+
let content;
|
|
30
|
+
try {
|
|
31
|
+
content = await readFile(fullPath, 'utf-8');
|
|
32
|
+
} catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check for in-process SDK (Microsoft.NET.Sdk.Functions)
|
|
37
|
+
if (content.includes('Microsoft.NET.Sdk.Functions')) {
|
|
38
|
+
return { isDotnet: true, isInProcess: true, csprojPath: fullPath };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check for isolated worker SDK
|
|
42
|
+
if (content.includes('Microsoft.Azure.Functions.Worker.Sdk')) {
|
|
43
|
+
return { isDotnet: true, isInProcess: false, csprojPath: fullPath };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// .csproj found but no known Functions SDK — not a Functions .NET project
|
|
48
|
+
return { isDotnet: false, isInProcess: false, csprojPath: null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function printInProcessError(csprojPath) {
|
|
52
|
+
console.error(`Error: fnx does not support the in-process hosting model.
|
|
53
|
+
|
|
54
|
+
Your project uses Microsoft.NET.Sdk.Functions (in-process).
|
|
55
|
+
fnx only supports the isolated worker model (Microsoft.Azure.Functions.Worker.Sdk).
|
|
56
|
+
|
|
57
|
+
Detected in: ${csprojPath}
|
|
58
|
+
|
|
59
|
+
To migrate: ${MIGRATION_URL}`);
|
|
60
|
+
}
|