@vrdmr/fnx-test 0.2.0 → 0.2.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 +12 -0
- package/lib/cli.js +151 -26
- package/lib/host-manager.js +133 -15
- package/lib/pack.js +140 -0
- package/lib/profile-resolver.js +29 -35
- package/lib/warmup.js +3 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,9 +56,21 @@ fnx start --sku flex
|
|
|
56
56
|
fnx start --sku <sku> --scriptroot <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
|
+
fnx sync [host|extensions] [--sku <sku>] # Reconcile cache with latest catalog / rollback
|
|
59
60
|
fnx templates-mcp # Start MCP server for AI agents
|
|
61
|
+
fnx pack --scriptroot <path> # Package function app as deployment zip
|
|
60
62
|
```
|
|
61
63
|
|
|
64
|
+
|
|
65
|
+
## Upgrades, Rollbacks, and Cache Retention
|
|
66
|
+
|
|
67
|
+
- On `fnx start`, `fnx warmup`, and `fnx sync`, fnx attempts to refresh the SKU catalog from CDN first.
|
|
68
|
+
- If catalog host version is newer than your local cache, fnx highlights that and recommends `fnx sync` (or `fnx sync host`).
|
|
69
|
+
- If catalog host version is lower than a locally cached version (service rollback), fnx warns and recommends syncing back to the supported host.
|
|
70
|
+
- `fnx sync` defaults to keeping only the latest 2 host and bundle versions to avoid cache bloat (`--keep <n>` to override).
|
|
71
|
+
- fnx also checks npm for newer fnx CLI releases and prints an upgrade tip when available.
|
|
72
|
+
- Version comparisons use numeric dot-segment precedence (e.g. `4.1047.100 > 4.1046.999`) with support for `v` prefixes and pre-release suffixes.
|
|
73
|
+
|
|
62
74
|
## MCP Server (for AI Agents)
|
|
63
75
|
|
|
64
76
|
fnx includes an MCP server that exposes Azure Functions templates to AI coding assistants:
|
package/lib/cli.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { resolve as resolvePath, dirname, join } from 'node:path';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { createServer } from 'node:net';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { resolveProfile, listProfiles, setProfilesSource } from './profile-resolver.js';
|
|
6
|
-
import { ensureHost, ensureBundle } from './host-manager.js';
|
|
6
|
+
import { resolveProfile, listProfiles, setProfilesSource, fetchRegistryWithMeta } from './profile-resolver.js';
|
|
7
|
+
import { ensureHost, ensureBundle, getCachedHostVersions, compareVersions, DEFAULT_KEEP_VERSIONS } from './host-manager.js';
|
|
7
8
|
import { launchHost, createHostState } from './host-launcher.js';
|
|
8
9
|
import { startLiveMcpServer } from './live-mcp-server.js';
|
|
9
10
|
import { detectDotnetModel, printInProcessError } from './dotnet-detector.js';
|
|
11
|
+
import { detectRuntimeFromConfig, packFunctionApp } from './pack.js';
|
|
12
|
+
|
|
13
|
+
const FNX_HOME = join(homedir(), '.fnx');
|
|
14
|
+
const VERSION_CHECK_FILE = join(FNX_HOME, 'version-check.json');
|
|
15
|
+
const VERSION_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
10
16
|
|
|
11
17
|
function isPortFree(port) {
|
|
12
18
|
return new Promise((resolve) => {
|
|
@@ -33,11 +39,7 @@ export async function main(args) {
|
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
if (cmd === '-v' || cmd === '--version') {
|
|
36
|
-
const
|
|
37
|
-
const { fileURLToPath } = await import('node:url');
|
|
38
|
-
const { dirname, join } = await import('node:path');
|
|
39
|
-
const dir = dirname(fileURLToPath(import.meta.url));
|
|
40
|
-
const pkg = JSON.parse(readFileSync(join(dir, '..', 'package.json'), 'utf-8'));
|
|
42
|
+
const pkg = await getFnxPackage();
|
|
41
43
|
console.log(`fnx v${pkg.version}`);
|
|
42
44
|
process.exit(0);
|
|
43
45
|
}
|
|
@@ -53,12 +55,28 @@ export async function main(args) {
|
|
|
53
55
|
return;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
if (cmd === 'sync') {
|
|
59
|
+
await runSync(args.slice(1));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (cmd === 'pack') {
|
|
64
|
+
const scriptRoot = getFlag(args, '--scriptroot') || process.cwd();
|
|
65
|
+
const runtime = getFlag(args, '--runtime') || await detectRuntimeFromConfig(scriptRoot);
|
|
66
|
+
const outputPath = getFlag(args, '--output');
|
|
67
|
+
const noBuild = args.includes('--no-build');
|
|
68
|
+
await packFunctionApp({ scriptRoot, runtime, outputPath, noBuild });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
if (cmd !== 'start') {
|
|
57
73
|
console.error(`Unknown command: ${cmd}\n`);
|
|
58
74
|
printHelp();
|
|
59
75
|
process.exit(1);
|
|
60
76
|
}
|
|
61
77
|
|
|
78
|
+
await maybeWarnForCliUpgrade();
|
|
79
|
+
|
|
62
80
|
const scriptRoot = getFlag(args, '--scriptroot') || process.cwd();
|
|
63
81
|
const requestedPort = parseInt(getFlag(args, '--port') || '7071');
|
|
64
82
|
const port = await findOpenPort(requestedPort);
|
|
@@ -110,29 +128,25 @@ export async function main(args) {
|
|
|
110
128
|
} else {
|
|
111
129
|
console.log(`Resolving SKU profile: ${sku}...`);
|
|
112
130
|
}
|
|
113
|
-
|
|
131
|
+
|
|
132
|
+
const { registry, source } = await fetchRegistryWithMeta();
|
|
133
|
+
const profile = registry.profiles[sku];
|
|
134
|
+
if (!profile) {
|
|
135
|
+
const valid = Object.keys(registry.profiles).join(', ');
|
|
136
|
+
throw new Error(`Unknown SKU '${sku}'. Available: ${valid}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
profile.name = sku;
|
|
114
140
|
console.log(` Target SKU: ${profile.displayName}`);
|
|
115
141
|
console.log(` Host Version: ${profile.hostVersion}`);
|
|
116
142
|
console.log(` Extension Bundle: ${profile.extensionBundleVersion}`);
|
|
117
143
|
if (profile.maxExtensionBundleVersion) {
|
|
118
144
|
console.log(` Max Bundle Cap: ${profile.maxExtensionBundleVersion}`);
|
|
119
145
|
}
|
|
146
|
+
console.log(` Profile Source: ${source}`);
|
|
120
147
|
console.log();
|
|
121
148
|
|
|
122
|
-
//
|
|
123
|
-
const hostDir = await ensureHost(profile);
|
|
124
|
-
console.log(` Host path: ${hostDir}`);
|
|
125
|
-
|
|
126
|
-
// 3. Pre-download the correct extension bundle for this SKU
|
|
127
|
-
// This resolves the exact version from CDN index, capped by maxExtensionBundleVersion,
|
|
128
|
-
// and downloads it so the host finds it cached and never fetches a wrong version.
|
|
129
|
-
const resolvedBundleVersion = await ensureBundle(profile);
|
|
130
|
-
if (resolvedBundleVersion) {
|
|
131
|
-
console.log(` Bundle resolved: ${resolvedBundleVersion}`);
|
|
132
|
-
}
|
|
133
|
-
console.log();
|
|
134
|
-
|
|
135
|
-
// 4. Merge config: app.config.json Values + local.settings.json Values
|
|
149
|
+
// Early validation: merge config and check runtime before downloading anything
|
|
136
150
|
const mergedValues = {
|
|
137
151
|
...(appConfig?.Values || {}),
|
|
138
152
|
...(localSettings?.Values || {}),
|
|
@@ -155,7 +169,22 @@ export async function main(args) {
|
|
|
155
169
|
}
|
|
156
170
|
}
|
|
157
171
|
|
|
158
|
-
|
|
172
|
+
printHostDriftWarning(profile.hostVersion);
|
|
173
|
+
|
|
174
|
+
// 2. Ensure host is downloaded
|
|
175
|
+
const hostDir = await ensureHost(profile, { keepVersions: DEFAULT_KEEP_VERSIONS });
|
|
176
|
+
console.log(` Host path: ${hostDir}`);
|
|
177
|
+
|
|
178
|
+
// 3. Pre-download the correct extension bundle for this SKU
|
|
179
|
+
// This resolves the exact version from CDN index, capped by maxExtensionBundleVersion,
|
|
180
|
+
// and downloads it so the host finds it cached and never fetches a wrong version.
|
|
181
|
+
const resolvedBundleVersion = await ensureBundle(profile, { keepVersions: DEFAULT_KEEP_VERSIONS });
|
|
182
|
+
if (resolvedBundleVersion) {
|
|
183
|
+
console.log(` Bundle resolved: ${resolvedBundleVersion}`);
|
|
184
|
+
}
|
|
185
|
+
console.log();
|
|
186
|
+
|
|
187
|
+
// 4. Create shared host state and start live MCP server
|
|
159
188
|
const hostState = createHostState();
|
|
160
189
|
|
|
161
190
|
if (!noMcp) {
|
|
@@ -201,6 +230,91 @@ export async function main(args) {
|
|
|
201
230
|
});
|
|
202
231
|
}
|
|
203
232
|
|
|
233
|
+
async function runSync(args) {
|
|
234
|
+
const target = ['host', 'extensions'].includes(args[0]) ? args[0] : 'all';
|
|
235
|
+
const force = args.includes('--force');
|
|
236
|
+
const keep = parseInt(getFlag(args, '--keep') || String(DEFAULT_KEEP_VERSIONS), 10);
|
|
237
|
+
const sku = getFlag(args, '--sku') || 'flex';
|
|
238
|
+
const profilesSource = getFlag(args, '--profiles');
|
|
239
|
+
if (profilesSource) setProfilesSource(profilesSource);
|
|
240
|
+
|
|
241
|
+
if (sku === 'list') {
|
|
242
|
+
await listProfiles();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const profile = await resolveProfile(sku);
|
|
247
|
+
profile.name = sku;
|
|
248
|
+
|
|
249
|
+
console.log(`Syncing SKU '${sku}' (${profile.displayName})...`);
|
|
250
|
+
if (target === 'all' || target === 'host') {
|
|
251
|
+
await ensureHost(profile, { force, keepVersions: keep });
|
|
252
|
+
console.log(' ✓ Host synchronized.');
|
|
253
|
+
}
|
|
254
|
+
if (target === 'all' || target === 'extensions') {
|
|
255
|
+
const bundle = await ensureBundle(profile, { force, keepVersions: keep });
|
|
256
|
+
console.log(` ✓ Extensions synchronized (${bundle || 'cached'}).`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
console.log(` Retention policy: keep latest ${keep} version(s).`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function printHostDriftWarning(targetHostVersion) {
|
|
263
|
+
const cached = getCachedHostVersions();
|
|
264
|
+
if (cached.length === 0) return;
|
|
265
|
+
|
|
266
|
+
const highest = [...cached].sort(compareVersions).pop();
|
|
267
|
+
if (!highest) return;
|
|
268
|
+
|
|
269
|
+
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');
|
|
272
|
+
} 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');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function maybeWarnForCliUpgrade() {
|
|
279
|
+
try {
|
|
280
|
+
const pkg = await getFnxPackage();
|
|
281
|
+
const current = pkg.version;
|
|
282
|
+
const cached = await readJsonFile(VERSION_CHECK_FILE);
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
|
|
285
|
+
if (cached?.checkedAt && now - new Date(cached.checkedAt).getTime() < VERSION_CHECK_TTL_MS) {
|
|
286
|
+
if (cached.latestVersion && compareVersions(cached.latestVersion, current) > 0) {
|
|
287
|
+
printUpgradeTip(cached.latestVersion);
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/latest`;
|
|
293
|
+
const res = await fetch(registryUrl);
|
|
294
|
+
if (!res.ok) return;
|
|
295
|
+
const latest = await res.json();
|
|
296
|
+
|
|
297
|
+
await mkdir(FNX_HOME, { recursive: true });
|
|
298
|
+
await writeFile(VERSION_CHECK_FILE, JSON.stringify({ checkedAt: new Date().toISOString(), latestVersion: latest.version }, null, 2));
|
|
299
|
+
|
|
300
|
+
if (latest.version && compareVersions(latest.version, current) > 0) {
|
|
301
|
+
printUpgradeTip(latest.version);
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
// non-fatal: offline/private registry/etc.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
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');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function getFnxPackage() {
|
|
314
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
315
|
+
return JSON.parse(await readFile(join(dir, '..', 'package.json'), 'utf-8'));
|
|
316
|
+
}
|
|
317
|
+
|
|
204
318
|
async function startTemplatesMcp() {
|
|
205
319
|
const { runStdioMcpServer } = await import('./mcp-server.js');
|
|
206
320
|
const { getTemplateTools } = await import('./mcp-tools/templates.js');
|
|
@@ -239,6 +353,10 @@ Usage: fnx <action> [-/--options]
|
|
|
239
353
|
Actions:
|
|
240
354
|
start Launch the Azure Functions host runtime for a specific SKU.
|
|
241
355
|
Downloads and caches the correct host version automatically.
|
|
356
|
+
sync Sync cached host/extensions with current catalog profile.
|
|
357
|
+
Use: fnx sync, fnx sync host, fnx sync extensions.
|
|
358
|
+
pack Package a Functions app into a deployment zip (func pack equivalent).
|
|
359
|
+
Supports python, node, java, powershell, and dotnet-isolated.
|
|
242
360
|
warmup Pre-download host binaries and extension bundles for offline use.
|
|
243
361
|
Runs automatically as postinstall hook. Use --dry-run to preview.
|
|
244
362
|
templates-mcp Start the Azure Functions templates MCP server (stdio transport).
|
|
@@ -253,14 +371,20 @@ Options:
|
|
|
253
371
|
Must contain host.json and either app.config.json or local.settings.json.
|
|
254
372
|
--port <port> Port for the host HTTP listener. Default: 7071.
|
|
255
373
|
--mcp-port <p> Port for the live MCP server. Default: host port + 1 (7072).
|
|
256
|
-
--no-mcp Disable the live MCP server (host-only mode).
|
|
257
|
-
--no-azurite Skip automatic Azurite start (for users who manage Azurite separately).
|
|
258
374
|
--profiles <src> SKU profiles source. Can be:
|
|
259
375
|
• A URL (http/https) to a profiles JSON endpoint
|
|
260
376
|
• A local file path to a profiles JSON file
|
|
261
377
|
• Inline JSON string (e.g. '{"profiles":{...}}')
|
|
262
378
|
Default: FUNC_PROFILES_URL env var, or http://localhost:4566/api/profiles.
|
|
379
|
+
--keep <n> For sync only: keep latest N host/bundle versions in cache (default: 2).
|
|
380
|
+
--force For sync only: re-download assets even if already cached.
|
|
381
|
+
--no-mcp Disable the live MCP server (host-only mode).
|
|
382
|
+
--no-azurite Skip automatic Azurite start (for users who manage Azurite separately).
|
|
263
383
|
--verbose Show all host output (unfiltered). Default: clean output only.
|
|
384
|
+
--runtime <name> Runtime used by pack. If omitted, reads FUNCTIONS_WORKER_RUNTIME
|
|
385
|
+
from app.config.json/local.settings.json.
|
|
386
|
+
--output <file> Output zip path for pack. Default: <scriptroot-name>.zip.
|
|
387
|
+
--no-build Skip build steps for java/dotnet-isolated during pack.
|
|
264
388
|
-v, --version Display the version of fnx.
|
|
265
389
|
-h, --help Display this help information.
|
|
266
390
|
|
|
@@ -287,6 +411,7 @@ Examples:
|
|
|
287
411
|
fnx start --sku list List all available SKU profiles with host versions
|
|
288
412
|
fnx start --sku flex --port 8080 Start on a custom port
|
|
289
413
|
fnx start --scriptroot ./my-app Start from a specific function app directory
|
|
414
|
+
fnx pack --scriptroot ./my-app Package function app as zip deployment artifact
|
|
290
415
|
|
|
291
416
|
Side-by-side comparison:
|
|
292
417
|
# Terminal 1: Run as Flex Consumption
|
package/lib/host-manager.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { mkdir, chmod, rm, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
4
5
|
import { homedir, platform } from 'node:os';
|
|
5
6
|
import { createWriteStream } from 'node:fs';
|
|
6
7
|
import { execSync } from 'node:child_process';
|
|
7
8
|
import { arch } from 'node:os';
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
-
const
|
|
10
|
+
const FNX_HOME = join(homedir(), '.fnx');
|
|
11
|
+
const HOST_CACHE = join(FNX_HOME, 'hosts');
|
|
12
|
+
const BUNDLE_CACHE = join(FNX_HOME, 'bundles');
|
|
11
13
|
const BUNDLE_CDN = 'https://functionscdn.azureedge.net/public/ExtensionBundles';
|
|
12
14
|
const BUNDLE_ID = 'Microsoft.Azure.Functions.ExtensionBundle';
|
|
15
|
+
const HOST_META_FILE = join(HOST_CACHE, '.metadata.json');
|
|
16
|
+
const BUNDLE_META_FILE = join(BUNDLE_CACHE, '.metadata.json');
|
|
17
|
+
const DEFAULT_KEEP_VERSIONS = 2;
|
|
13
18
|
|
|
14
19
|
function getPlatformRid() {
|
|
15
20
|
const os = platform();
|
|
@@ -25,12 +30,108 @@ function getHostExeName() {
|
|
|
25
30
|
: 'Microsoft.Azure.WebJobs.Script.WebHost';
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
function parseVersion(version) {
|
|
34
|
+
// Accepts versions like 4.1047.100, 1.2.3-beta.1, v4.5.6.
|
|
35
|
+
// Non-numeric suffixes are ignored for numeric precedence.
|
|
36
|
+
const cleaned = String(version || '').trim().replace(/^v/i, '');
|
|
37
|
+
return cleaned.split('.').map((part) => {
|
|
38
|
+
const match = part.match(/^(\d+)/);
|
|
39
|
+
return match ? Number(match[1]) : 0;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function compareVersions(a, b) {
|
|
44
|
+
const pa = parseVersion(a);
|
|
45
|
+
const pb = parseVersion(b);
|
|
46
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
47
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
48
|
+
if (diff !== 0) return diff;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Stable tie-breaker when numeric parts are equal.
|
|
52
|
+
return String(a).localeCompare(String(b));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readMetadata(filePath) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(await readFile(filePath, 'utf-8'));
|
|
58
|
+
} catch {
|
|
59
|
+
return { versions: {} };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function writeMetadata(filePath, meta) {
|
|
64
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
65
|
+
await writeFile(filePath, JSON.stringify(meta, null, 2));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toIsoNow() {
|
|
69
|
+
return new Date().toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function updateVersionMetadata(filePath, version, updates) {
|
|
73
|
+
const meta = await readMetadata(filePath);
|
|
74
|
+
const existing = meta.versions[version] || {};
|
|
75
|
+
meta.versions[version] = {
|
|
76
|
+
...existing,
|
|
77
|
+
version,
|
|
78
|
+
...updates,
|
|
79
|
+
};
|
|
80
|
+
await writeMetadata(filePath, meta);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getSortedVersionEntries(meta) {
|
|
84
|
+
return Object.values(meta.versions || {}).sort((a, b) => compareVersions(b.version, a.version));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function pruneHostCache({ keep = DEFAULT_KEEP_VERSIONS, protect = [] } = {}) {
|
|
88
|
+
const meta = await readMetadata(HOST_META_FILE);
|
|
89
|
+
const protectedSet = new Set(protect);
|
|
90
|
+
const sorted = getSortedVersionEntries(meta);
|
|
91
|
+
|
|
92
|
+
for (let i = keep; i < sorted.length; i++) {
|
|
93
|
+
const entry = sorted[i];
|
|
94
|
+
if (protectedSet.has(entry.version)) continue;
|
|
95
|
+
const dir = join(HOST_CACHE, entry.version);
|
|
96
|
+
if (existsSync(dir)) {
|
|
97
|
+
await rm(dir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
delete meta.versions[entry.version];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await writeMetadata(HOST_META_FILE, meta);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function pruneBundleCache({ keep = DEFAULT_KEEP_VERSIONS, protect = [] } = {}) {
|
|
106
|
+
const meta = await readMetadata(BUNDLE_META_FILE);
|
|
107
|
+
const protectedSet = new Set(protect);
|
|
108
|
+
const sorted = getSortedVersionEntries(meta);
|
|
109
|
+
|
|
110
|
+
for (let i = keep; i < sorted.length; i++) {
|
|
111
|
+
const entry = sorted[i];
|
|
112
|
+
if (protectedSet.has(entry.version)) continue;
|
|
113
|
+
const dir = join(BUNDLE_CACHE, BUNDLE_ID, entry.version);
|
|
114
|
+
if (existsSync(dir)) {
|
|
115
|
+
await rm(dir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
delete meta.versions[entry.version];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await writeMetadata(BUNDLE_META_FILE, meta);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function ensureHost(profile, { force = false, keepVersions = DEFAULT_KEEP_VERSIONS } = {}) {
|
|
29
124
|
const hostDir = join(HOST_CACHE, profile.hostVersion);
|
|
30
125
|
const hostExe = join(hostDir, getHostExeName());
|
|
31
126
|
|
|
32
127
|
if (!force && existsSync(hostExe)) {
|
|
33
128
|
console.log(' Host cached, skipping download.');
|
|
129
|
+
await updateVersionMetadata(HOST_META_FILE, profile.hostVersion, {
|
|
130
|
+
lastUsedAt: toIsoNow(),
|
|
131
|
+
sku: profile.name || profile.displayName,
|
|
132
|
+
rid: getPlatformRid(),
|
|
133
|
+
dependencies: { extensionBundleVersion: profile.extensionBundleVersion },
|
|
134
|
+
});
|
|
34
135
|
return hostDir;
|
|
35
136
|
}
|
|
36
137
|
|
|
@@ -103,6 +204,15 @@ export async function ensureHost(profile, { force = false } = {}) {
|
|
|
103
204
|
// Patch worker configs: replace 'python' → 'python3' on Unix where python3 exists but python doesn't
|
|
104
205
|
await patchWorkerConfigs(hostDir);
|
|
105
206
|
|
|
207
|
+
await updateVersionMetadata(HOST_META_FILE, profile.hostVersion, {
|
|
208
|
+
downloadedAt: toIsoNow(),
|
|
209
|
+
lastUsedAt: toIsoNow(),
|
|
210
|
+
sku: profile.name || profile.displayName,
|
|
211
|
+
rid,
|
|
212
|
+
dependencies: { extensionBundleVersion: profile.extensionBundleVersion },
|
|
213
|
+
});
|
|
214
|
+
await pruneHostCache({ keep: keepVersions, protect: [profile.hostVersion] });
|
|
215
|
+
|
|
106
216
|
return hostDir;
|
|
107
217
|
}
|
|
108
218
|
|
|
@@ -152,16 +262,6 @@ function parseVersionRange(range) {
|
|
|
152
262
|
};
|
|
153
263
|
}
|
|
154
264
|
|
|
155
|
-
function compareVersions(a, b) {
|
|
156
|
-
const pa = a.split('.').map(Number);
|
|
157
|
-
const pb = b.split('.').map(Number);
|
|
158
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
159
|
-
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
160
|
-
if (diff !== 0) return diff;
|
|
161
|
-
}
|
|
162
|
-
return 0;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
265
|
function findBestBundleVersion(allVersions, range, maxVersion) {
|
|
166
266
|
const parsed = parseVersionRange(range);
|
|
167
267
|
if (!parsed) return null;
|
|
@@ -183,7 +283,7 @@ function findBestBundleVersion(allVersions, range, maxVersion) {
|
|
|
183
283
|
return candidates[candidates.length - 1]; // highest valid version
|
|
184
284
|
}
|
|
185
285
|
|
|
186
|
-
export async function ensureBundle(profile, { force = false } = {}) {
|
|
286
|
+
export async function ensureBundle(profile, { force = false, keepVersions = DEFAULT_KEEP_VERSIONS } = {}) {
|
|
187
287
|
const bundleDir = join(BUNDLE_CACHE, BUNDLE_ID);
|
|
188
288
|
const range = profile.extensionBundleVersion;
|
|
189
289
|
const maxVersion = profile.maxExtensionBundleVersion;
|
|
@@ -214,6 +314,11 @@ export async function ensureBundle(profile, { force = false } = {}) {
|
|
|
214
314
|
const versionDir = join(bundleDir, bestVersion);
|
|
215
315
|
if (!force && existsSync(join(versionDir, 'bundle.json'))) {
|
|
216
316
|
console.log(` Bundle ${bestVersion} cached.`);
|
|
317
|
+
await updateVersionMetadata(BUNDLE_META_FILE, bestVersion, {
|
|
318
|
+
lastUsedAt: toIsoNow(),
|
|
319
|
+
sku: profile.name || profile.displayName,
|
|
320
|
+
dependencies: { hostVersion: profile.hostVersion },
|
|
321
|
+
});
|
|
217
322
|
return bestVersion;
|
|
218
323
|
}
|
|
219
324
|
|
|
@@ -264,6 +369,14 @@ export async function ensureBundle(profile, { force = false } = {}) {
|
|
|
264
369
|
try { await rm(tempZip); } catch { /* ignore */ }
|
|
265
370
|
}
|
|
266
371
|
|
|
372
|
+
await updateVersionMetadata(BUNDLE_META_FILE, bestVersion, {
|
|
373
|
+
downloadedAt: toIsoNow(),
|
|
374
|
+
lastUsedAt: toIsoNow(),
|
|
375
|
+
sku: profile.name || profile.displayName,
|
|
376
|
+
dependencies: { hostVersion: profile.hostVersion },
|
|
377
|
+
});
|
|
378
|
+
await pruneBundleCache({ keep: keepVersions, protect: [bestVersion] });
|
|
379
|
+
|
|
267
380
|
return bestVersion;
|
|
268
381
|
}
|
|
269
382
|
|
|
@@ -277,4 +390,9 @@ function findCachedBundle(bundleDir, range, maxVersion) {
|
|
|
277
390
|
return best;
|
|
278
391
|
}
|
|
279
392
|
|
|
280
|
-
export
|
|
393
|
+
export function getCachedHostVersions() {
|
|
394
|
+
if (!existsSync(HOST_CACHE)) return [];
|
|
395
|
+
return readdirSync(HOST_CACHE).filter((v) => existsSync(join(HOST_CACHE, v, getHostExeName())));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export { getHostExeName, getPlatformRid, compareVersions, parseVersion, DEFAULT_KEEP_VERSIONS };
|
package/lib/pack.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
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';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const RUNTIME_ALIASES = new Map([
|
|
7
|
+
['node', 'node'],
|
|
8
|
+
['nodejs', 'node'],
|
|
9
|
+
['javascript', 'node'],
|
|
10
|
+
['typescript', 'node'],
|
|
11
|
+
['python', 'python'],
|
|
12
|
+
['py', 'python'],
|
|
13
|
+
['java', 'java'],
|
|
14
|
+
['powershell', 'powershell'],
|
|
15
|
+
['pwsh', 'powershell'],
|
|
16
|
+
['dotnet-isolated', 'dotnet-isolated'],
|
|
17
|
+
['dotnetisolated', 'dotnet-isolated'],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export const SUPPORTED_PACK_RUNTIMES = ['python', 'node', 'java', 'powershell', 'dotnet-isolated'];
|
|
21
|
+
|
|
22
|
+
function normalizeRuntime(input) {
|
|
23
|
+
return String(input || '').trim().toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolvePackRuntime(input) {
|
|
27
|
+
const normalized = normalizeRuntime(input);
|
|
28
|
+
|
|
29
|
+
if (!normalized) {
|
|
30
|
+
throw new Error('Missing runtime. Set FUNCTIONS_WORKER_RUNTIME or pass --runtime.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (normalized === 'dotnet') {
|
|
34
|
+
throw new Error('Only .NET isolated worker is supported for packing. Use dotnet-isolated runtime.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const runtime = RUNTIME_ALIASES.get(normalized);
|
|
38
|
+
if (!runtime) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Unsupported runtime '${input}'. Supported values: ${SUPPORTED_PACK_RUNTIMES.join(', ')}.`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return runtime;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runCommand(command, args, options = {}) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const child = spawn(command, args, {
|
|
50
|
+
cwd: options.cwd,
|
|
51
|
+
stdio: options.silent ? 'pipe' : 'inherit',
|
|
52
|
+
env: process.env,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let stderr = '';
|
|
56
|
+
if (child.stderr) {
|
|
57
|
+
child.stderr.on('data', (chunk) => {
|
|
58
|
+
stderr += chunk.toString();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
child.on('error', (err) => reject(err));
|
|
63
|
+
child.on('close', (code) => {
|
|
64
|
+
if (code === 0) return resolve();
|
|
65
|
+
reject(new Error(`${command} ${args.join(' ')} failed with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function ensureExists(pathToCheck) {
|
|
71
|
+
await access(pathToCheck, constants.F_OK);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function zipDirectory(sourceDir, outputZip) {
|
|
75
|
+
await runCommand('zip', ['-r', '-q', outputZip, '.'], { cwd: sourceDir });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function stageJavaBuild(scriptRoot) {
|
|
79
|
+
await runCommand('mvn', ['clean', 'package', '-DskipTests'], { cwd: scriptRoot });
|
|
80
|
+
const targetDir = resolvePath(scriptRoot, 'target', 'azure-functions');
|
|
81
|
+
await ensureExists(targetDir);
|
|
82
|
+
return targetDir;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function stageDotnetIsolatedBuild(scriptRoot, tempRoot) {
|
|
86
|
+
const publishDir = resolvePath(tempRoot, 'publish');
|
|
87
|
+
await runCommand('dotnet', ['publish', '--configuration', 'Release', '--output', publishDir], { cwd: scriptRoot });
|
|
88
|
+
await ensureExists(publishDir);
|
|
89
|
+
return publishDir;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function detectRuntimeFromConfig(scriptRoot) {
|
|
93
|
+
const appConfigPath = resolvePath(scriptRoot, 'app.config.json');
|
|
94
|
+
const localSettingsPath = resolvePath(scriptRoot, 'local.settings.json');
|
|
95
|
+
|
|
96
|
+
const parseIfExists = async (filePath) => {
|
|
97
|
+
try {
|
|
98
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
99
|
+
return JSON.parse(raw);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const appConfig = await parseIfExists(appConfigPath);
|
|
106
|
+
const localSettings = await parseIfExists(localSettingsPath);
|
|
107
|
+
return appConfig?.Values?.FUNCTIONS_WORKER_RUNTIME || localSettings?.Values?.FUNCTIONS_WORKER_RUNTIME || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function packFunctionApp({ scriptRoot, runtime, outputPath, noBuild = false }) {
|
|
111
|
+
const root = resolvePath(scriptRoot || process.cwd());
|
|
112
|
+
const resolvedRuntime = resolvePackRuntime(runtime);
|
|
113
|
+
const resolvedOutput = resolvePath(outputPath || `${basename(root)}.zip`);
|
|
114
|
+
|
|
115
|
+
const tempRoot = await mkdtemp(join(tmpdir(), 'fnx-pack-'));
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
let sourceDir = root;
|
|
119
|
+
|
|
120
|
+
if (!noBuild) {
|
|
121
|
+
if (resolvedRuntime === 'java') {
|
|
122
|
+
sourceDir = await stageJavaBuild(root);
|
|
123
|
+
} else if (resolvedRuntime === 'dotnet-isolated') {
|
|
124
|
+
sourceDir = await stageDotnetIsolatedBuild(root, tempRoot);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(`Packing runtime '${resolvedRuntime}' from ${sourceDir}`);
|
|
129
|
+
await zipDirectory(sourceDir, resolvedOutput);
|
|
130
|
+
console.log(`Created package: ${resolvedOutput}`);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
runtime: resolvedRuntime,
|
|
134
|
+
sourceDir,
|
|
135
|
+
outputPath: resolvedOutput,
|
|
136
|
+
};
|
|
137
|
+
} finally {
|
|
138
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
package/lib/profile-resolver.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join, resolve as resolvePath, isAbsolute } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
|
|
6
6
|
const CACHE_DIR = join(homedir(), '.fnx', 'profiles');
|
|
7
7
|
const CACHE_FILE = join(CACHE_DIR, 'sku-profiles.json');
|
|
8
|
-
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
9
8
|
|
|
10
9
|
const DEFAULT_CDN_URL = 'https://raw.githubusercontent.com/vrdmr/func-emulate/main/fnx/profiles/sku-profiles.json';
|
|
11
10
|
|
|
@@ -26,72 +25,67 @@ function isJsonString(str) {
|
|
|
26
25
|
return str.trimStart().startsWith('{');
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
async function
|
|
30
|
-
|
|
28
|
+
async function persistCache(rawJson) {
|
|
29
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
30
|
+
await writeFile(CACHE_FILE, rawJson);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function fetchRegistryWithMeta() {
|
|
34
|
+
// If an explicit source was provided (--profiles flag or inline JSON), use it directly.
|
|
31
35
|
if (profilesSource) {
|
|
32
|
-
// Inline JSON string
|
|
33
36
|
if (isJsonString(profilesSource)) {
|
|
34
|
-
return JSON.parse(profilesSource);
|
|
37
|
+
return { registry: JSON.parse(profilesSource), source: 'inline-json' };
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
// URL (http/https)
|
|
38
40
|
if (isUrl(profilesSource)) {
|
|
39
41
|
try {
|
|
40
42
|
const res = await fetch(profilesSource);
|
|
41
|
-
if (res.ok) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
throw new Error(`Cannot fetch profiles from: ${profilesSource}`);
|
|
43
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
44
|
+
const json = await res.text();
|
|
45
|
+
await persistCache(json);
|
|
46
|
+
return { registry: JSON.parse(json), source: 'remote', url: profilesSource };
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(`Cannot fetch profiles from: ${profilesSource}`);
|
|
49
|
+
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
// Local file path
|
|
52
52
|
const filePath = isAbsolute(profilesSource) ? profilesSource : resolvePath(process.cwd(), profilesSource);
|
|
53
53
|
try {
|
|
54
|
-
return JSON.parse(await readFile(filePath, 'utf-8'));
|
|
54
|
+
return { registry: JSON.parse(await readFile(filePath, 'utf-8')), source: 'local-file', path: filePath };
|
|
55
55
|
} catch (err) {
|
|
56
56
|
throw new Error(`Cannot read profiles file: ${filePath} (${err.message})`);
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Default
|
|
60
|
+
// Default behavior: always attempt CDN first to detect upgrades/rollbacks quickly,
|
|
61
|
+
// then fall back to cache, then bundled profiles.
|
|
61
62
|
const cdnUrl = process.env.FUNC_PROFILES_URL || DEFAULT_CDN_URL;
|
|
62
63
|
|
|
63
|
-
// 1. Try cache (if fresh)
|
|
64
|
-
try {
|
|
65
|
-
const cacheStat = await stat(CACHE_FILE);
|
|
66
|
-
if (Date.now() - cacheStat.mtimeMs < CACHE_TTL_MS) {
|
|
67
|
-
return JSON.parse(await readFile(CACHE_FILE, 'utf-8'));
|
|
68
|
-
}
|
|
69
|
-
} catch { /* no cache or stale */ }
|
|
70
|
-
|
|
71
|
-
// 2. Try CDN
|
|
72
64
|
try {
|
|
73
65
|
const res = await fetch(cdnUrl);
|
|
74
66
|
if (res.ok) {
|
|
75
67
|
const json = await res.text();
|
|
76
|
-
await
|
|
77
|
-
|
|
78
|
-
return JSON.parse(json);
|
|
68
|
+
await persistCache(json);
|
|
69
|
+
return { registry: JSON.parse(json), source: 'remote', url: cdnUrl };
|
|
79
70
|
}
|
|
80
71
|
} catch { /* CDN unreachable */ }
|
|
81
72
|
|
|
82
|
-
// 3. Try stale cache
|
|
83
73
|
try {
|
|
84
|
-
return JSON.parse(await readFile(CACHE_FILE, 'utf-8'));
|
|
85
|
-
} catch { /* no cache
|
|
74
|
+
return { registry: JSON.parse(await readFile(CACHE_FILE, 'utf-8')), source: 'cache' };
|
|
75
|
+
} catch { /* no cache */ }
|
|
86
76
|
|
|
87
|
-
// 4. Fall back to bundled profiles
|
|
88
77
|
try {
|
|
89
|
-
return JSON.parse(await readFile(BUNDLED_PROFILES_PATH, 'utf-8'));
|
|
78
|
+
return { registry: JSON.parse(await readFile(BUNDLED_PROFILES_PATH, 'utf-8')), source: 'bundled' };
|
|
90
79
|
} catch {
|
|
91
80
|
throw new Error('Cannot load SKU profiles: CDN unreachable, no cache, no bundled profiles.');
|
|
92
81
|
}
|
|
93
82
|
}
|
|
94
83
|
|
|
84
|
+
async function fetchRegistry() {
|
|
85
|
+
const { registry } = await fetchRegistryWithMeta();
|
|
86
|
+
return registry;
|
|
87
|
+
}
|
|
88
|
+
|
|
95
89
|
export async function resolveProfile(skuName) {
|
|
96
90
|
const registry = await fetchRegistry();
|
|
97
91
|
const profile = registry.profiles[skuName];
|
package/lib/warmup.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
2
2
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { resolveProfile, listProfiles } from './profile-resolver.js';
|
|
5
|
+
import { resolveProfile, listProfiles, fetchRegistryWithMeta } from './profile-resolver.js';
|
|
6
6
|
import { ensureHost, ensureBundle, getHostExeName, getPlatformRid } from './host-manager.js';
|
|
7
7
|
|
|
8
8
|
const FNX_DIR = join(homedir(), '.fnx');
|
|
@@ -143,9 +143,8 @@ function findAnyCachedBundle(bundleDir) {
|
|
|
143
143
|
|
|
144
144
|
async function getAllSkuNames() {
|
|
145
145
|
try {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
return Object.keys(registry.profiles);
|
|
146
|
+
const { registry } = await fetchRegistryWithMeta();
|
|
147
|
+
return Object.keys(registry.profiles || {});
|
|
149
148
|
} catch {
|
|
150
149
|
return ['flex', 'linux-premium', 'windows-consumption', 'windows-dedicated', 'linux-consumption'];
|
|
151
150
|
}
|