@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.
@@ -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 cachedBin = join(AZURITE_INSTALL_DIR, 'node_modules', '.bin', 'azurite');
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 which = execSync('which azurite', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
70
- if (which) return which;
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
- const installed = join(AZURITE_INSTALL_DIR, 'node_modules', '.bin', 'azurite');
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
- azuriteProcess = spawn(azuriteBin, azuriteArgs, {
162
- stdio: 'ignore',
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, readdir } from 'node:fs/promises';
7
- import { join, resolve as resolvePath } from 'node:path';
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
- async function autoCreateFromLocalSettings(appPath, localSettingsPath) {
267
- const localSettings = await readJsonFile(localSettingsPath);
268
- if (!localSettings?.Values) {
269
- console.error(errorColor(' local.settings.json has no Values section.'));
270
- process.exit(1);
271
- }
272
-
273
- const values = localSettings.Values;
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 in env or values
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 from local settings (non-standard but some users set it)
290
- if (localSettings.TargetSku) {
291
- config.local = { targetSku: localSettings.TargetSku };
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 nonSecretKeys) {
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] = values[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 cachedBin = join(homedir(), '.fnx', 'tools', 'azurite', 'node_modules', '.bin', 'azurite');
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
- execSync('which azurite', { stdio: ['pipe', 'pipe', 'ignore'] });
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
+ }