@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 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 { readFileSync } = await import('node:fs');
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
- const profile = await resolveProfile(sku);
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
- // 2. Ensure host is downloaded
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
- // 5. Create shared host state and start live MCP server
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
@@ -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 HOST_CACHE = join(homedir(), '.fnx', 'hosts');
10
- const BUNDLE_CACHE = join(homedir(), '.fnx', 'bundles');
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
- export async function ensureHost(profile, { force = false } = {}) {
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 { getHostExeName, getPlatformRid };
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
+ }
@@ -1,11 +1,10 @@
1
- import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
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 fetchRegistry() {
30
- // If an explicit source was provided (--profiles flag or inline JSON), use it directly
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
- const json = await res.text();
43
- await mkdir(CACHE_DIR, { recursive: true });
44
- await writeFile(CACHE_FILE, json);
45
- return JSON.parse(json);
46
- }
47
- } catch { /* fall through to error */ }
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 resolution chain: env var → cache → CDN stale cache bundled
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 mkdir(CACHE_DIR, { recursive: true });
77
- await writeFile(CACHE_FILE, json);
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 at all */ }
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 profilesPath = new URL('../profiles/sku-profiles.json', import.meta.url).pathname;
147
- const registry = JSON.parse(await readFile(profilesPath, 'utf-8'));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrdmr/fnx-test",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "SKU-aware Azure Functions local emulator",
5
5
  "type": "module",
6
6
  "bin": {