@vrdmr/fnx-test 0.4.2 → 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/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,
@@ -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
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Interactive prompts for fnx init
3
+ *
4
+ * Uses Node.js readline for cross-platform terminal interaction.
5
+ * No external dependencies.
6
+ */
7
+
8
+ import { createInterface } from 'node:readline';
9
+ import { success, dim, bold, funcName } from '../colors.js';
10
+
11
+ /**
12
+ * Create a readline interface for prompts
13
+ */
14
+ function createPrompt() {
15
+ return createInterface({
16
+ input: process.stdin,
17
+ output: process.stdout,
18
+ });
19
+ }
20
+
21
+ /**
22
+ * Prompt user to select from a list of options
23
+ * @param {string} question - Question to display
24
+ * @param {Array<{value: string, label: string}>} options - Available options
25
+ * @returns {Promise<string>} Selected value
26
+ */
27
+ async function selectPrompt(question, options) {
28
+ const rl = createPrompt();
29
+
30
+ // Handle readline errors (e.g., stdin closed)
31
+ rl.on('error', () => {
32
+ rl.close();
33
+ process.exit(1);
34
+ });
35
+
36
+ console.log(bold(question));
37
+ options.forEach((opt, i) => {
38
+ console.log(` ${dim(`[${i + 1}]`)} ${opt.label}`);
39
+ });
40
+
41
+ return new Promise((resolve) => {
42
+ const ask = () => {
43
+ rl.question(`\n ${dim('Enter number (1-' + options.length + '):')} `, (answer) => {
44
+ const num = parseInt(answer.trim(), 10);
45
+ if (num >= 1 && num <= options.length) {
46
+ rl.close();
47
+ console.log(success(` ✓ ${options[num - 1].label}\n`));
48
+ resolve(options[num - 1].value);
49
+ } else {
50
+ console.log(dim(' Invalid selection, try again.'));
51
+ ask();
52
+ }
53
+ });
54
+ };
55
+ ask();
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Prompt for text input
61
+ * @param {string} question - Question to display
62
+ * @param {string} defaultValue - Default value if empty
63
+ * @returns {Promise<string>} User input
64
+ */
65
+ async function textPrompt(question, defaultValue = '') {
66
+ const rl = createPrompt();
67
+ const defaultHint = defaultValue ? ` ${dim(`(default: ${defaultValue})`)}` : '';
68
+
69
+ // Handle readline errors
70
+ rl.on('error', () => {
71
+ rl.close();
72
+ process.exit(1);
73
+ });
74
+
75
+ return new Promise((resolve) => {
76
+ rl.question(`${bold(question)}${defaultHint}: `, (answer) => {
77
+ rl.close();
78
+ const value = answer.trim() || defaultValue;
79
+ console.log(success(` ✓ ${value}\n`));
80
+ resolve(value);
81
+ });
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Prompt for runtime selection
87
+ * @param {Object} manifest - Template manifest
88
+ * @returns {Promise<string>} Selected runtime (normalized)
89
+ */
90
+ export async function promptRuntime(manifest) {
91
+ // Get unique languages from manifest (manifest uses 'language' field)
92
+ const languages = new Set();
93
+ for (const template of manifest.templates) {
94
+ if (template.language) languages.add(template.language);
95
+ }
96
+
97
+ // Build options with display names and template counts
98
+ const languageCounts = {};
99
+ for (const template of manifest.templates) {
100
+ if (!template.language) continue;
101
+ languageCounts[template.language] = (languageCounts[template.language] || 0) + 1;
102
+ }
103
+
104
+ // Map manifest language names to internal runtime identifiers
105
+ // Manifest uses: CSharp, Java, JavaScript, PowerShell, Python, TypeScript, ARM, Bicep, Terraform
106
+ const languageToRuntime = {
107
+ 'Python': 'python',
108
+ 'JavaScript': 'node',
109
+ 'TypeScript': 'node', // Node.js covers both JS/TS
110
+ 'CSharp': 'dotnet-isolated',
111
+ 'Java': 'java',
112
+ 'PowerShell': 'powershell',
113
+ };
114
+
115
+ const runtimeDisplayMap = {
116
+ 'python': 'Python',
117
+ 'node': 'Node.js (TypeScript/JavaScript)',
118
+ 'dotnet-isolated': '.NET (C#)',
119
+ 'java': 'Java',
120
+ 'powershell': 'PowerShell',
121
+ };
122
+
123
+ // Filter to supported runtimes and aggregate counts
124
+ const runtimeCounts = {};
125
+ for (const [lang, count] of Object.entries(languageCounts)) {
126
+ const runtime = languageToRuntime[lang];
127
+ if (runtime) {
128
+ runtimeCounts[runtime] = (runtimeCounts[runtime] || 0) + count;
129
+ }
130
+ }
131
+
132
+ // Prioritize common runtimes
133
+ const priorityOrder = ['python', 'node', 'dotnet-isolated', 'java', 'powershell'];
134
+ const sortedRuntimes = Object.keys(runtimeCounts).sort((a, b) => {
135
+ const aIdx = priorityOrder.indexOf(a);
136
+ const bIdx = priorityOrder.indexOf(b);
137
+ if (aIdx === -1 && bIdx === -1) return a.localeCompare(b);
138
+ if (aIdx === -1) return 1;
139
+ if (bIdx === -1) return -1;
140
+ return aIdx - bIdx;
141
+ });
142
+
143
+ const options = sortedRuntimes.map(rt => ({
144
+ value: rt,
145
+ label: runtimeDisplayMap[rt] || rt,
146
+ }));
147
+
148
+ return selectPrompt('Select a runtime:', options);
149
+ }
150
+
151
+ /**
152
+ * Prompt for Node.js language variant (TypeScript or JavaScript)
153
+ * @returns {Promise<string>} 'typescript' or 'javascript'
154
+ */
155
+ export async function promptNodeLanguage() {
156
+ const options = [
157
+ { value: 'typescript', label: `TypeScript ${dim('(recommended)')}` },
158
+ { value: 'javascript', label: 'JavaScript' },
159
+ ];
160
+
161
+ return selectPrompt('Select Node.js language:', options);
162
+ }
163
+
164
+ /**
165
+ * Prompt for trigger selection
166
+ * @param {Array} templates - Filtered templates for the selected runtime
167
+ * @param {string[]} priorityOrder - Resource types in priority order
168
+ * @returns {Promise<Object>} Selected template
169
+ */
170
+ export async function promptTrigger(templates, priorityOrder) {
171
+ // Filter to only priority 0 templates for initial display
172
+ const p0Templates = templates.filter(t => t.priority === 0 || t.priority === undefined);
173
+ const allTemplates = templates;
174
+
175
+ // Group P0 templates by resource type (manifest uses 'resource' field for trigger type)
176
+ const resourceGroups = {};
177
+ for (const template of p0Templates) {
178
+ // Only include triggers, not input/output bindings
179
+ if (template.bindingType !== 'trigger') continue;
180
+
181
+ const resource = template.resource || 'other';
182
+ if (!resourceGroups[resource]) resourceGroups[resource] = [];
183
+ resourceGroups[resource].push(template);
184
+ }
185
+
186
+ // Sort resources by priority
187
+ const sortedResources = Object.keys(resourceGroups).sort((a, b) => {
188
+ const aIdx = priorityOrder.indexOf(a.toLowerCase());
189
+ const bIdx = priorityOrder.indexOf(b.toLowerCase());
190
+ if (aIdx === -1 && bIdx === -1) return a.localeCompare(b);
191
+ if (aIdx === -1) return 1;
192
+ if (bIdx === -1) return -1;
193
+ return aIdx - bIdx;
194
+ });
195
+
196
+ // Build flat list of P0 templates, grouped by resource
197
+ const options = [];
198
+ for (const resource of sortedResources) {
199
+ const group = resourceGroups[resource];
200
+ // Sort templates within group alphabetically
201
+ group.sort((a, b) => (a.displayName || a.id).localeCompare(b.displayName || b.id));
202
+ for (const template of group) {
203
+ options.push({
204
+ value: template,
205
+ label: `${funcName(template.displayName || template.id)} ${dim(`(${resource})`)}`,
206
+ });
207
+ }
208
+ }
209
+
210
+ // Count all trigger templates for "More..." option
211
+ const allTriggers = allTemplates.filter(t => t.bindingType === 'trigger');
212
+ const hasMore = allTriggers.length > options.length;
213
+
214
+ // Add "More..." option if there are additional templates
215
+ if (hasMore) {
216
+ options.push({
217
+ value: '__MORE__',
218
+ label: `${bold('More...')} ${dim(`— Show all ${allTriggers.length} templates`)}`,
219
+ });
220
+ }
221
+
222
+ if (options.length === 0) {
223
+ console.log(dim(' No trigger templates found for this runtime.\n'));
224
+ return null;
225
+ }
226
+
227
+ const selected = await selectPrompt('Select a template:', options);
228
+
229
+ // If "More..." selected, show full list
230
+ if (selected === '__MORE__') {
231
+ return promptTriggerAll(allTriggers, priorityOrder);
232
+ }
233
+
234
+ return selected;
235
+ }
236
+
237
+ /**
238
+ * Show all templates when "More..." is selected
239
+ * @param {Array} templates - All trigger templates
240
+ * @param {string[]} priorityOrder - Resource priority order (unused, kept for API compat)
241
+ * @returns {Promise<object>} Selected template
242
+ */
243
+ async function promptTriggerAll(templates, priorityOrder) {
244
+ // Sort by priority only, keeping manifest order within each priority level
245
+ const sorted = [...templates].sort((a, b) => {
246
+ const pA = a.priority ?? 0;
247
+ const pB = b.priority ?? 0;
248
+ return pA - pB;
249
+ });
250
+
251
+ const options = sorted.map(template => ({
252
+ value: template,
253
+ label: `${funcName(template.displayName || template.id)} ${dim(`(${template.resource || 'other'})`)}`,
254
+ }));
255
+
256
+ console.log(dim(`\n Showing all ${options.length} templates:\n`));
257
+ return selectPrompt('Select a template:', options);
258
+ }
259
+
260
+ /**
261
+ * Prompt for project name
262
+ * @param {string} targetDir - Target directory path
263
+ * @returns {Promise<string>} Project name
264
+ */
265
+ export async function promptProjectName(targetDir) {
266
+ const { basename } = await import('node:path');
267
+ const defaultName = basename(targetDir) || 'my-function-app';
268
+ return textPrompt('Project name', defaultName);
269
+ }
270
+
271
+ /**
272
+ * Prompt for SKU selection
273
+ * @returns {Promise<string>} Selected SKU
274
+ */
275
+ export async function promptSku() {
276
+ const options = [
277
+ { value: 'flex', label: `Flex Consumption ${dim('(recommended, serverless)')}` },
278
+ { value: 'premium', label: `Premium ${dim('(always-warm, VNet integration)')}` },
279
+ { value: 'dedicated', label: `Dedicated ${dim('(App Service Plan)')}` },
280
+ ];
281
+
282
+ return selectPrompt('Select target SKU:', options);
283
+ }