@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 +12 -0
- package/lib/config.js +82 -22
- 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/runtimes.js +249 -0
- package/package.json +1 -1
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,
|
|
@@ -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
|
+
}
|