@vrdmr/fnx-test 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/azurite-manager.js +18 -9
- package/lib/cli.js +12 -0
- package/lib/config.js +82 -22
- package/lib/doctor.js +5 -3
- package/lib/init/manifest.js +231 -0
- package/lib/init/prompts.js +283 -0
- package/lib/init/scaffold.js +669 -0
- package/lib/init.js +399 -0
- package/lib/pack.js +45 -12
- package/lib/runtimes.js +249 -0
- package/package.json +1 -1
package/lib/azurite-manager.js
CHANGED
|
@@ -60,14 +60,18 @@ async function isAzuriteRunning() {
|
|
|
60
60
|
* Returns the path/command or null.
|
|
61
61
|
*/
|
|
62
62
|
function findAzurite() {
|
|
63
|
-
// 1. Check the fnx tools cache first
|
|
64
|
-
const
|
|
63
|
+
// 1. Check the fnx tools cache first (Windows uses .cmd shims)
|
|
64
|
+
const isWin = process.platform === 'win32';
|
|
65
|
+
const cachedBin = join(AZURITE_INSTALL_DIR, 'node_modules', '.bin', isWin ? 'azurite.cmd' : 'azurite');
|
|
65
66
|
if (existsSync(cachedBin)) return cachedBin;
|
|
66
67
|
|
|
67
|
-
// 2. Check global PATH
|
|
68
|
+
// 2. Check global PATH (use 'where' on Windows, 'which' on Unix)
|
|
68
69
|
try {
|
|
69
|
-
const
|
|
70
|
-
|
|
70
|
+
const whichCmd = isWin ? 'where azurite' : 'which azurite';
|
|
71
|
+
const result = execSync(whichCmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
|
|
72
|
+
// 'where' on Windows may return multiple lines; take the first
|
|
73
|
+
const firstLine = result.split(/\r?\n/)[0];
|
|
74
|
+
if (firstLine) return firstLine;
|
|
71
75
|
} catch { /* not found */ }
|
|
72
76
|
|
|
73
77
|
return null;
|
|
@@ -98,7 +102,9 @@ function installAzurite() {
|
|
|
98
102
|
return null;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
|
|
105
|
+
// Use .cmd on Windows (npm creates .cmd shims for bin entries)
|
|
106
|
+
const binName = process.platform === 'win32' ? 'azurite.cmd' : 'azurite';
|
|
107
|
+
const installed = join(AZURITE_INSTALL_DIR, 'node_modules', '.bin', binName);
|
|
102
108
|
if (existsSync(installed)) {
|
|
103
109
|
console.log(info('[fnx] Azurite installed successfully.'));
|
|
104
110
|
return installed;
|
|
@@ -158,9 +164,12 @@ export async function ensureAzurite(mergedValues, opts = {}) {
|
|
|
158
164
|
// Ensure data directory exists
|
|
159
165
|
mkdirSync(join(homedir(), '.fnx', 'azurite-data'), { recursive: true });
|
|
160
166
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
// On Windows, use shell:true so cmd.exe can resolve .cmd shims from PATH-based paths
|
|
168
|
+
const spawnOptions = process.platform === 'win32'
|
|
169
|
+
? { stdio: 'ignore', shell: true }
|
|
170
|
+
: { stdio: 'ignore' };
|
|
171
|
+
|
|
172
|
+
azuriteProcess = spawn(azuriteBin, azuriteArgs, spawnOptions);
|
|
164
173
|
weStartedAzurite = true;
|
|
165
174
|
|
|
166
175
|
azuriteProcess.on('error', (err) => {
|
package/lib/cli.js
CHANGED
|
@@ -112,6 +112,17 @@ export async function main(args) {
|
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
if (cmd === 'init') {
|
|
116
|
+
if (hasHelp(args.slice(1))) {
|
|
117
|
+
const { printInitHelp } = await import('./init.js');
|
|
118
|
+
printInitHelp();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const { runInit } = await import('./init.js');
|
|
122
|
+
await runInit(args.slice(1));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
115
126
|
if (cmd === 'doctor') {
|
|
116
127
|
if (hasHelp(args.slice(1))) { printDoctorHelp(); return; }
|
|
117
128
|
const { runDoctor } = await import('./doctor.js');
|
|
@@ -469,6 +480,7 @@ function printHelp() {
|
|
|
469
480
|
console.log(`${title('Usage:')} fnx <command> [options]
|
|
470
481
|
|
|
471
482
|
${title('Commands:')}
|
|
483
|
+
${funcName('init')} Initialize a new Azure Functions project.
|
|
472
484
|
${funcName('start')} Launch the Azure Functions host runtime for a specific SKU.
|
|
473
485
|
${funcName('doctor')} Validate project setup and diagnose common issues.
|
|
474
486
|
${funcName('sync')} Sync cached host/extensions with current catalog profile.
|
package/lib/config.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// Reads app-config.yaml (primary) or auto-creates it from local.settings.json.
|
|
4
4
|
// Validates against config-schema.js, checks for secrets, manages .gitignore protection.
|
|
5
5
|
|
|
6
|
-
import { readFile, writeFile, access
|
|
7
|
-
import { join
|
|
6
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
8
|
import { createInterface } from 'node:readline';
|
|
9
9
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
10
10
|
|
|
@@ -134,6 +134,51 @@ export async function migrateConfig(appPath) {
|
|
|
134
134
|
return autoCreateFromLocalSettings(appPath, localSettingsPath);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Create app-config.yaml from local.settings.json (if exists) with CLI overrides.
|
|
139
|
+
* Used by fnx init to generate config after template download.
|
|
140
|
+
* @param {string} appPath - Directory containing local.settings.json
|
|
141
|
+
* @param {Object} overrides - { runtime, version, sku } from CLI flags
|
|
142
|
+
* @param {Object} options - { silent: boolean } suppress output
|
|
143
|
+
* @returns {Promise<boolean>} true if file was created, false if already exists
|
|
144
|
+
*/
|
|
145
|
+
export async function createAppConfig(appPath, overrides = {}, options = {}) {
|
|
146
|
+
const appConfigPath = join(appPath, APP_CONFIG_FILE);
|
|
147
|
+
|
|
148
|
+
// Skip if already exists
|
|
149
|
+
if (await fileExists(appConfigPath)) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const localSettingsPath = join(appPath, LOCAL_SETTINGS_FILE);
|
|
154
|
+
let localSettings = {};
|
|
155
|
+
|
|
156
|
+
if (await fileExists(localSettingsPath)) {
|
|
157
|
+
try {
|
|
158
|
+
localSettings = await readJsonFile(localSettingsPath);
|
|
159
|
+
} catch {
|
|
160
|
+
// Ignore parse errors, proceed with overrides only
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Build config using shared function (overrides take precedence)
|
|
165
|
+
const config = buildConfigFromLocalSettings(localSettings, overrides);
|
|
166
|
+
|
|
167
|
+
// Ensure EnableWorkerIndexing is set
|
|
168
|
+
config.configurations = config.configurations || {};
|
|
169
|
+
config.configurations.AzureWebJobsFeatureFlags =
|
|
170
|
+
config.configurations.AzureWebJobsFeatureFlags || 'EnableWorkerIndexing';
|
|
171
|
+
|
|
172
|
+
// Write app-config.yaml
|
|
173
|
+
await writeFile(appConfigPath, generateYaml(config), 'utf-8');
|
|
174
|
+
|
|
175
|
+
if (!options.silent) {
|
|
176
|
+
console.log(successColor(` ✓ Created app-config.yaml`));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
137
182
|
/**
|
|
138
183
|
* Validate app-config.yaml (standalone command).
|
|
139
184
|
* Returns { valid, errors, warnings, secrets }.
|
|
@@ -263,47 +308,62 @@ async function readJsonFile(filePath) {
|
|
|
263
308
|
}
|
|
264
309
|
}
|
|
265
310
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Build config object from local.settings.json values.
|
|
313
|
+
* Extracts runtime, version, sku, and allowed configurations (excluding secrets).
|
|
314
|
+
* @param {Object} localSettings - Parsed local.settings.json object
|
|
315
|
+
* @param {Object} overrides - Optional overrides { runtime, version, sku }
|
|
316
|
+
* @returns {Object} Config object ready for generateYaml()
|
|
317
|
+
*/
|
|
318
|
+
export function buildConfigFromLocalSettings(localSettings, overrides = {}) {
|
|
319
|
+
const values = localSettings?.Values || {};
|
|
274
320
|
const secrets = detectSecretsInFlatValues(values);
|
|
275
|
-
const nonSecretKeys = Object.keys(values).filter(k => !secrets.has(k));
|
|
276
321
|
|
|
277
322
|
// Build structured config
|
|
278
323
|
const config = {};
|
|
279
324
|
|
|
280
|
-
// Extract runtime
|
|
281
|
-
const runtime = values.FUNCTIONS_WORKER_RUNTIME;
|
|
325
|
+
// Extract runtime (CLI override takes precedence)
|
|
326
|
+
const runtime = overrides.runtime || values.FUNCTIONS_WORKER_RUNTIME;
|
|
282
327
|
if (runtime) {
|
|
283
328
|
config.runtime = { name: runtime };
|
|
284
|
-
// Check for runtime version
|
|
285
|
-
const version = values.FUNCTIONS_WORKER_RUNTIME_VERSION;
|
|
329
|
+
// Check for runtime version (CLI override takes precedence)
|
|
330
|
+
const version = overrides.version || values.FUNCTIONS_WORKER_RUNTIME_VERSION;
|
|
286
331
|
if (version) config.runtime.version = version;
|
|
287
332
|
}
|
|
288
333
|
|
|
289
|
-
// Extract targetSku
|
|
290
|
-
|
|
291
|
-
|
|
334
|
+
// Extract targetSku (CLI override takes precedence)
|
|
335
|
+
const sku = overrides.sku || localSettings.TargetSku;
|
|
336
|
+
if (sku) {
|
|
337
|
+
config.local = { targetSku: sku };
|
|
292
338
|
}
|
|
293
339
|
|
|
294
340
|
// Remaining non-secret, non-structured values → configurations
|
|
295
341
|
const structuredEnvVars = new Set(Object.values(STRUCTURED_FIELDS).map(s => s.envVar));
|
|
296
342
|
const configEntries = {};
|
|
297
|
-
for (const key of
|
|
343
|
+
for (const [key, val] of Object.entries(values)) {
|
|
298
344
|
if (structuredEnvVars.has(key)) continue; // Already mapped structurally
|
|
345
|
+
if (secrets.has(key)) continue; // Skip secrets
|
|
299
346
|
if (ALLOWED_CONFIGURATIONS.has(key)) {
|
|
300
|
-
configEntries[key] =
|
|
347
|
+
configEntries[key] = val;
|
|
301
348
|
}
|
|
302
349
|
}
|
|
303
350
|
if (Object.keys(configEntries).length > 0) {
|
|
304
351
|
config.configurations = configEntries;
|
|
305
352
|
}
|
|
306
353
|
|
|
354
|
+
return config;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function autoCreateFromLocalSettings(appPath, localSettingsPath) {
|
|
358
|
+
const localSettings = await readJsonFile(localSettingsPath);
|
|
359
|
+
if (!localSettings?.Values) {
|
|
360
|
+
console.error(errorColor(' local.settings.json has no Values section.'));
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const config = buildConfigFromLocalSettings(localSettings);
|
|
365
|
+
const secrets = detectSecretsInFlatValues(localSettings.Values);
|
|
366
|
+
|
|
307
367
|
// Write app-config.yaml
|
|
308
368
|
const yaml = generateYaml(config);
|
|
309
369
|
const appConfigPath = join(appPath, APP_CONFIG_FILE);
|
|
@@ -372,7 +432,7 @@ async function interactiveCreate(appPath) {
|
|
|
372
432
|
return config;
|
|
373
433
|
}
|
|
374
434
|
|
|
375
|
-
function generateYaml(config) {
|
|
435
|
+
export function generateYaml(config) {
|
|
376
436
|
const lines = [
|
|
377
437
|
'# Azure Functions App Configuration',
|
|
378
438
|
'# Commit this to source control. Do NOT put secrets here.',
|
|
@@ -413,7 +473,7 @@ function generateYaml(config) {
|
|
|
413
473
|
* Detect which keys in a flat values map are secrets (for migration).
|
|
414
474
|
* Returns a Set of secret key names.
|
|
415
475
|
*/
|
|
416
|
-
function detectSecretsInFlatValues(values) {
|
|
476
|
+
export function detectSecretsInFlatValues(values) {
|
|
417
477
|
const secretKeys = new Set();
|
|
418
478
|
const secretKeyPatterns = [
|
|
419
479
|
/ConnectionString$/i,
|
package/lib/doctor.js
CHANGED
|
@@ -182,14 +182,16 @@ async function checkAzurite() {
|
|
|
182
182
|
return { name: 'Azurite', status: 'pass', message: 'Running on default ports (10000–10002)' };
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
// Check if azurite binary is available
|
|
186
|
-
const
|
|
185
|
+
// Check if azurite binary is available (Windows uses .cmd shims)
|
|
186
|
+
const isWin = process.platform === 'win32';
|
|
187
|
+
const cachedBin = join(homedir(), '.fnx', 'tools', 'azurite', 'node_modules', '.bin', isWin ? 'azurite.cmd' : 'azurite');
|
|
187
188
|
if (existsSync(cachedBin)) {
|
|
188
189
|
return { name: 'Azurite', status: 'warn', message: 'Installed but not running — fnx start will auto-launch it', fix: 'Azurite will start automatically when needed' };
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
try {
|
|
192
|
-
|
|
193
|
+
const whichCmd = isWin ? 'where azurite' : 'which azurite';
|
|
194
|
+
execSync(whichCmd, { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
193
195
|
return { name: 'Azurite', status: 'warn', message: 'Installed globally but not running — fnx start will auto-launch it' };
|
|
194
196
|
} catch { /* not found */ }
|
|
195
197
|
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest fetching and caching for fnx init
|
|
3
|
+
*
|
|
4
|
+
* - Fetches template manifest from CDN
|
|
5
|
+
* - Caches to ~/.fnx/cache/manifest.json with ETag
|
|
6
|
+
* - 24-hour TTL for cached manifests
|
|
7
|
+
* - Falls back to bundled manifest if CDN unavailable
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const FNX_CACHE_DIR = join(homedir(), '.fnx', 'cache');
|
|
18
|
+
const MANIFEST_CACHE_FILE = join(FNX_CACHE_DIR, 'manifest.json');
|
|
19
|
+
const MANIFEST_META_FILE = join(FNX_CACHE_DIR, 'manifest-meta.json');
|
|
20
|
+
const BUNDLED_MANIFEST_FILE = join(__dirname, '..', '..', 'templates', 'manifest.json');
|
|
21
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
22
|
+
|
|
23
|
+
// Allowed GitHub organizations for template repositories
|
|
24
|
+
const ALLOWED_ORGS = ['azure', 'azure-samples'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Filter templates to only include those from trusted Azure orgs
|
|
28
|
+
* @param {Array} templates - Array of template objects
|
|
29
|
+
* @param {string} defaultRepoUrl - Default repository URL from manifest
|
|
30
|
+
* @param {boolean} verbose - Log filtering info
|
|
31
|
+
* @returns {Array} Filtered templates
|
|
32
|
+
*/
|
|
33
|
+
function filterTrustedTemplates(templates, defaultRepoUrl, verbose) {
|
|
34
|
+
const original = templates.length;
|
|
35
|
+
const filtered = templates.filter(template => {
|
|
36
|
+
const repoUrl = template.repositoryUrl || defaultRepoUrl || '';
|
|
37
|
+
const match = repoUrl.match(/^https:\/\/github\.com\/([^/]+)\//i);
|
|
38
|
+
if (!match) return false;
|
|
39
|
+
const owner = match[1].toLowerCase();
|
|
40
|
+
return ALLOWED_ORGS.includes(owner);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (verbose && filtered.length < original) {
|
|
44
|
+
console.log(` Filtered ${original - filtered.length} templates from untrusted sources`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return filtered;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fetch manifest from URL with caching and bundled fallback
|
|
52
|
+
* @param {string} url - Manifest URL
|
|
53
|
+
* @param {Object} options - Options
|
|
54
|
+
* @param {boolean} options.verbose - Show detailed logging
|
|
55
|
+
* @returns {Promise<{templates: Array}>} Parsed manifest
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchManifest(url, options = {}) {
|
|
58
|
+
const { verbose } = options;
|
|
59
|
+
|
|
60
|
+
// Check if cached manifest is still valid
|
|
61
|
+
const cached = await loadCachedManifest();
|
|
62
|
+
if (cached && !isExpired(cached.meta)) {
|
|
63
|
+
if (verbose) {
|
|
64
|
+
const age = Math.round((Date.now() - cached.meta.fetchedAt) / 1000 / 60);
|
|
65
|
+
console.log(` Cache hit: manifest cached ${age} minutes ago`);
|
|
66
|
+
}
|
|
67
|
+
// Filter cached manifest
|
|
68
|
+
cached.manifest.templates = filterTrustedTemplates(cached.manifest.templates, cached.manifest.repositoryUrl, verbose);
|
|
69
|
+
return cached.manifest;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fetch from network with conditional request if we have ETag
|
|
73
|
+
const headers = {};
|
|
74
|
+
if (cached?.meta?.etag) {
|
|
75
|
+
headers['If-None-Match'] = cached.meta.etag;
|
|
76
|
+
if (verbose) console.log(` Cache stale, checking with ETag...`);
|
|
77
|
+
} else {
|
|
78
|
+
if (verbose) console.log(` No cache, fetching from CDN...`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(url, { headers });
|
|
83
|
+
|
|
84
|
+
// 304 Not Modified — use cached version
|
|
85
|
+
if (response.status === 304 && cached) {
|
|
86
|
+
if (verbose) console.log(` 304 Not Modified, using cache`);
|
|
87
|
+
await saveCacheMeta({ ...cached.meta, fetchedAt: Date.now() });
|
|
88
|
+
// Filter cached manifest
|
|
89
|
+
cached.manifest.templates = filterTrustedTemplates(cached.manifest.templates, cached.manifest.repositoryUrl, verbose);
|
|
90
|
+
return cached.manifest;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const manifest = await response.json();
|
|
98
|
+
|
|
99
|
+
// Validate manifest structure
|
|
100
|
+
if (!manifest || !Array.isArray(manifest.templates)) {
|
|
101
|
+
throw new Error('Invalid manifest format: missing templates array');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Filter to only trusted Azure orgs (proactive security)
|
|
105
|
+
manifest.templates = filterTrustedTemplates(manifest.templates, manifest.repositoryUrl, verbose);
|
|
106
|
+
|
|
107
|
+
const etag = response.headers.get('etag');
|
|
108
|
+
|
|
109
|
+
if (verbose) console.log(` Fetched fresh manifest from CDN`);
|
|
110
|
+
|
|
111
|
+
// Cache the manifest
|
|
112
|
+
await cacheManifest(manifest, { etag, fetchedAt: Date.now(), url });
|
|
113
|
+
|
|
114
|
+
return manifest;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// Network error — fall back to cache if available
|
|
117
|
+
if (cached) {
|
|
118
|
+
if (verbose) console.log(` Network error, using stale cache: ${err.message}`);
|
|
119
|
+
// Filter cached manifest too
|
|
120
|
+
cached.manifest.templates = filterTrustedTemplates(cached.manifest.templates, cached.manifest.repositoryUrl, verbose);
|
|
121
|
+
return cached.manifest;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Fall back to bundled manifest
|
|
125
|
+
const bundled = await loadBundledManifest();
|
|
126
|
+
if (bundled) {
|
|
127
|
+
if (verbose) console.log(` CDN unavailable, using bundled manifest`);
|
|
128
|
+
// Filter bundled manifest too
|
|
129
|
+
bundled.templates = filterTrustedTemplates(bundled.templates, bundled.repositoryUrl, verbose);
|
|
130
|
+
return bundled;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
throw new Error(`Failed to fetch manifest: ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Load cached manifest and metadata
|
|
139
|
+
*/
|
|
140
|
+
async function loadCachedManifest() {
|
|
141
|
+
try {
|
|
142
|
+
if (!existsSync(MANIFEST_CACHE_FILE) || !existsSync(MANIFEST_META_FILE)) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const [manifestJson, metaJson] = await Promise.all([
|
|
147
|
+
readFile(MANIFEST_CACHE_FILE, 'utf-8'),
|
|
148
|
+
readFile(MANIFEST_META_FILE, 'utf-8'),
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
manifest: JSON.parse(manifestJson),
|
|
153
|
+
meta: JSON.parse(metaJson),
|
|
154
|
+
};
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Load bundled manifest from package
|
|
162
|
+
*/
|
|
163
|
+
async function loadBundledManifest() {
|
|
164
|
+
try {
|
|
165
|
+
if (!existsSync(BUNDLED_MANIFEST_FILE)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const content = await readFile(BUNDLED_MANIFEST_FILE, 'utf-8');
|
|
169
|
+
return JSON.parse(content);
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Check if cached manifest has expired
|
|
177
|
+
*/
|
|
178
|
+
function isExpired(meta) {
|
|
179
|
+
if (!meta?.fetchedAt) return true;
|
|
180
|
+
return Date.now() - meta.fetchedAt > CACHE_TTL_MS;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Cache manifest to disk
|
|
185
|
+
*/
|
|
186
|
+
async function cacheManifest(manifest, meta) {
|
|
187
|
+
await mkdir(FNX_CACHE_DIR, { recursive: true });
|
|
188
|
+
await Promise.all([
|
|
189
|
+
writeFile(MANIFEST_CACHE_FILE, JSON.stringify(manifest, null, 2)),
|
|
190
|
+
writeFile(MANIFEST_META_FILE, JSON.stringify(meta, null, 2)),
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Update just the cache metadata (e.g., to extend TTL on 304)
|
|
196
|
+
*/
|
|
197
|
+
async function saveCacheMeta(meta) {
|
|
198
|
+
await mkdir(FNX_CACHE_DIR, { recursive: true });
|
|
199
|
+
await writeFile(MANIFEST_META_FILE, JSON.stringify(meta, null, 2));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get available runtimes from manifest
|
|
204
|
+
* @param {Object} manifest
|
|
205
|
+
* @returns {string[]} Unique runtime values
|
|
206
|
+
*/
|
|
207
|
+
export function getAvailableRuntimes(manifest) {
|
|
208
|
+
const runtimes = new Set();
|
|
209
|
+
for (const template of manifest.templates) {
|
|
210
|
+
if (template.runtime) {
|
|
211
|
+
runtimes.add(template.runtime);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return Array.from(runtimes);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get available triggers from manifest for a given runtime
|
|
219
|
+
* @param {Object} manifest
|
|
220
|
+
* @param {string} runtime
|
|
221
|
+
* @returns {string[]} Unique trigger types
|
|
222
|
+
*/
|
|
223
|
+
export function getAvailableTriggers(manifest, runtime) {
|
|
224
|
+
const triggers = new Set();
|
|
225
|
+
for (const template of manifest.templates) {
|
|
226
|
+
if (template.runtime === runtime && template.trigger) {
|
|
227
|
+
triggers.add(template.trigger);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return Array.from(triggers);
|
|
231
|
+
}
|