@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.
@@ -0,0 +1,669 @@
1
+ /**
2
+ * Template downloading and project scaffolding for fnx init
3
+ *
4
+ * Download strategy (adapts based on git availability):
5
+ * | folderPath | git available | Action |
6
+ * |------------|---------------|------------------------------------------------|
7
+ * | "." | Yes | git clone --depth 1 |
8
+ * | "." | No | Download zip archive, extract |
9
+ * | "<path>" | Yes | git clone --filter=blob:none + sparse-checkout |
10
+ * | "<path>" | No | GitHub API file-by-file |
11
+ *
12
+ * Exports:
13
+ * - downloadTemplate(template, targetDir, manifest, options) — Download template files
14
+ * - generateConfigFiles(targetDir, options) — Generate app-config.yaml
15
+ * - printSuccessBanner(targetDir, projectName, sku, runtime) — Print success message
16
+ */
17
+
18
+ import { mkdir, writeFile, rm, rename, readdir, readFile } from 'node:fs/promises';
19
+ import { existsSync } from 'node:fs';
20
+ import { join, dirname, resolve, sep } from 'node:path';
21
+ import { spawn } from 'node:child_process';
22
+ import { randomUUID } from 'node:crypto';
23
+ import { title, info, success, dim, bold, funcName } from '../colors.js';
24
+ import { getDefaultVersion } from '../runtimes.js';
25
+ import { createAppConfig } from '../config.js';
26
+
27
+ /**
28
+ * Validate that a file path is within the target directory (prevent path traversal)
29
+ * @param {string} targetDir - Base directory
30
+ * @param {string} fileName - File name to validate
31
+ * @returns {string} Safe file path
32
+ * @throws {Error} If path traversal is detected
33
+ */
34
+ function safePath(targetDir, fileName) {
35
+ const filePath = join(targetDir, fileName);
36
+ const resolvedPath = resolve(filePath);
37
+ const resolvedTarget = resolve(targetDir);
38
+ if (!resolvedPath.startsWith(resolvedTarget + sep) && resolvedPath !== resolvedTarget) {
39
+ throw new Error(`Path traversal detected: ${fileName}`);
40
+ }
41
+ return filePath;
42
+ }
43
+
44
+ /**
45
+ * Check if git is available on the system
46
+ * @returns {Promise<boolean>}
47
+ */
48
+ async function hasGit() {
49
+ return new Promise((resolve) => {
50
+ const proc = spawn('git', ['--version'], { stdio: 'ignore', shell: true });
51
+ proc.on('close', (code) => resolve(code === 0));
52
+ proc.on('error', () => resolve(false));
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Run a git command and return success/failure
58
+ * @param {string[]} args - Git arguments
59
+ * @param {string} cwd - Working directory
60
+ * @param {boolean} verbose - Log output
61
+ * @returns {Promise<{success: boolean, output?: string}>}
62
+ */
63
+ function runGit(args, cwd, verbose = false) {
64
+ return new Promise((resolve) => {
65
+ const proc = spawn('git', args, { cwd, shell: true, stdio: verbose ? 'inherit' : 'pipe' });
66
+ let output = '';
67
+ if (!verbose && proc.stdout) {
68
+ proc.stdout.on('data', (d) => { output += d.toString(); });
69
+ }
70
+ if (!verbose && proc.stderr) {
71
+ proc.stderr.on('data', (d) => { output += d.toString(); });
72
+ }
73
+ proc.on('close', (code) => resolve({ success: code === 0, output }));
74
+ proc.on('error', (err) => resolve({ success: false, output: err.message }));
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Download template files from GitHub
80
+ * @param {Object} template - Template object from manifest
81
+ * @param {string} targetDir - Target directory
82
+ * @param {Object} manifest - Full manifest (for base URL)
83
+ * @param {Object} options - Options
84
+ * @returns {Promise<{success: boolean, filesDownloaded: number, error?: string}>}
85
+ */
86
+ export async function downloadTemplate(template, targetDir, manifest, options = {}) {
87
+ const { verbose } = options;
88
+
89
+ // Parse repository URL to get owner/repo (with null-safe access)
90
+ const repoUrl = template.repositoryUrl || manifest?.repositoryUrl || 'https://github.com/Azure/azure-functions-templates-mcp-server';
91
+ // Validate URL scheme and extract owner/repo
92
+ const repoMatch = repoUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)/);
93
+
94
+ if (!repoMatch) {
95
+ const error = `Could not parse repository URL: ${repoUrl}`;
96
+ if (verbose) console.log(dim(` Warning: ${error}`));
97
+ return { success: false, filesDownloaded: 0, error };
98
+ }
99
+
100
+ const [, owner, repo] = repoMatch;
101
+
102
+ // Security: Only allow Azure or Azure-Samples repos (defense against compromised manifest)
103
+ const allowedOrgs = ['azure', 'azure-samples'];
104
+ if (!allowedOrgs.includes(owner.toLowerCase())) {
105
+ const error = `Template references untrusted repository (${owner}/${repo}). Please report this issue.`;
106
+ if (verbose) console.log(dim(` Warning: ${error}`));
107
+ return { success: false, filesDownloaded: 0, error };
108
+ }
109
+
110
+ const folderPath = template.folderPath || '.';
111
+ const isWholeRepo = folderPath === '.';
112
+
113
+ if (verbose) {
114
+ console.log(dim(` Repository: ${owner}/${repo}`));
115
+ console.log(dim(` Folder: ${folderPath}`));
116
+ }
117
+
118
+ const gitAvailable = await hasGit();
119
+ if (verbose) {
120
+ console.log(dim(` Git available: ${gitAvailable ? 'yes' : 'no'}`));
121
+ }
122
+
123
+ let result;
124
+ try {
125
+ if (isWholeRepo) {
126
+ // Clone entire repo
127
+ if (gitAvailable) {
128
+ result = await cloneRepo(owner, repo, targetDir, verbose);
129
+ } else {
130
+ result = await downloadZip(owner, repo, targetDir, verbose);
131
+ }
132
+ } else {
133
+ // Download subfolder
134
+ if (gitAvailable) {
135
+ result = await sparseCheckout(owner, repo, folderPath, targetDir, verbose);
136
+ } else {
137
+ result = await downloadViaApi(owner, repo, folderPath, targetDir, verbose);
138
+ }
139
+ }
140
+ } catch (err) {
141
+ return { success: false, filesDownloaded: 0, error: err.message };
142
+ }
143
+
144
+ return result || { success: true, filesDownloaded: 0 };
145
+ }
146
+
147
+ /**
148
+ * Clone entire repo with --depth 1
149
+ * @returns {Promise<{success: boolean, filesDownloaded: number, error?: string}>}
150
+ */
151
+ async function cloneRepo(owner, repo, targetDir, verbose) {
152
+ const repoUrl = `https://github.com/${owner}/${repo}.git`;
153
+
154
+ if (verbose) console.log(dim(` Cloning ${repoUrl}...`));
155
+
156
+ // Clone into a temp directory first, then move contents
157
+ const tempDir = join(dirname(targetDir), `.fnx-clone-${Date.now()}-${randomUUID().slice(0, 8)}`);
158
+
159
+ const result = await runGit(['clone', '--depth', '1', repoUrl, tempDir], dirname(tempDir), verbose);
160
+
161
+ if (!result.success) {
162
+ if (verbose) console.log(dim(` Warning: git clone failed: ${result.output}`));
163
+ await rm(tempDir, { recursive: true, force: true });
164
+ return { success: false, filesDownloaded: 0, error: `git clone failed: ${result.output}` };
165
+ }
166
+
167
+ // Move contents from temp to target (excluding .git)
168
+ await mkdir(targetDir, { recursive: true });
169
+ const items = await readdir(tempDir);
170
+ let filesDownloaded = 0;
171
+ for (const item of items) {
172
+ if (item === '.git') continue;
173
+ const src = join(tempDir, item);
174
+ const dest = join(targetDir, item);
175
+ await rename(src, dest);
176
+ filesDownloaded++;
177
+ }
178
+
179
+ // Cleanup temp directory
180
+ await rm(tempDir, { recursive: true, force: true });
181
+
182
+ if (verbose) console.log(dim(` Clone complete`));
183
+ return { success: true, filesDownloaded };
184
+ }
185
+
186
+ /**
187
+ * Use git sparse-checkout to download only a subfolder
188
+ * Uses: git clone --filter=blob:none --no-checkout, then sparse-checkout
189
+ * @returns {Promise<{success: boolean, filesDownloaded: number, error?: string}>}
190
+ */
191
+ async function sparseCheckout(owner, repo, folderPath, targetDir, verbose) {
192
+ const repoUrl = `https://github.com/${owner}/${repo}.git`;
193
+
194
+ if (verbose) console.log(dim(` Sparse checkout: ${folderPath}...`));
195
+
196
+ const tempDir = join(dirname(targetDir), `.fnx-sparse-${Date.now()}-${randomUUID().slice(0, 8)}`);
197
+
198
+ // Clone with blob filter (no file content downloaded yet)
199
+ let result = await runGit(
200
+ ['clone', '--filter=blob:none', '--no-checkout', '--depth', '1', '--sparse', repoUrl, tempDir],
201
+ dirname(tempDir),
202
+ verbose
203
+ );
204
+ if (!result.success) {
205
+ if (verbose) console.log(dim(` Falling back to API download`));
206
+ await rm(tempDir, { recursive: true, force: true });
207
+ return downloadViaApi(owner, repo, folderPath, targetDir, verbose);
208
+ }
209
+
210
+ // Set sparse-checkout to the specific folder
211
+ result = await runGit(['sparse-checkout', 'set', folderPath], tempDir, verbose);
212
+ if (!result.success) {
213
+ await rm(tempDir, { recursive: true, force: true });
214
+ return downloadViaApi(owner, repo, folderPath, targetDir, verbose);
215
+ }
216
+
217
+ // Checkout to actually download the files
218
+ result = await runGit(['checkout'], tempDir, verbose);
219
+ if (!result.success) {
220
+ await rm(tempDir, { recursive: true, force: true });
221
+ return downloadViaApi(owner, repo, folderPath, targetDir, verbose);
222
+ }
223
+
224
+ // Move the subfolder contents to target
225
+ const sourceDir = join(tempDir, folderPath);
226
+ let filesDownloaded = 0;
227
+ if (existsSync(sourceDir)) {
228
+ await mkdir(targetDir, { recursive: true });
229
+ const items = await readdir(sourceDir);
230
+ for (const item of items) {
231
+ const src = join(sourceDir, item);
232
+ const dest = join(targetDir, item);
233
+ await rename(src, dest);
234
+ filesDownloaded++;
235
+ }
236
+ }
237
+
238
+ // Cleanup
239
+ await rm(tempDir, { recursive: true, force: true });
240
+
241
+ if (verbose) console.log(dim(` Sparse checkout complete`));
242
+ return { success: filesDownloaded > 0, filesDownloaded, error: filesDownloaded === 0 ? 'No files found in template folder' : undefined };
243
+ }
244
+
245
+ /**
246
+ * Download repo as zip and extract (fallback when git not available)
247
+ * Uses platform-specific extraction: PowerShell on Windows, unzip on Unix
248
+ */
249
+ async function downloadZip(owner, repo, targetDir, verbose) {
250
+ if (verbose) console.log(dim(` Downloading zip archive...`));
251
+
252
+ const tempDir = join(dirname(targetDir), `.fnx-zip-${Date.now()}-${randomUUID().slice(0, 8)}`);
253
+ const zipPath = join(tempDir, 'repo.zip');
254
+
255
+ try {
256
+ await mkdir(tempDir, { recursive: true });
257
+ } catch (err) {
258
+ return { success: false, filesDownloaded: 0, error: `Cannot create temp directory: ${err.message}` };
259
+ }
260
+
261
+ // Try main branch first, then master
262
+ let zipUrl = `https://github.com/${owner}/${repo}/archive/refs/heads/main.zip`;
263
+ let response;
264
+
265
+ try {
266
+ response = await fetch(zipUrl);
267
+ if (!response.ok) {
268
+ zipUrl = `https://github.com/${owner}/${repo}/archive/refs/heads/master.zip`;
269
+ response = await fetch(zipUrl);
270
+ }
271
+ } catch (err) {
272
+ if (verbose) console.log(dim(` Warning: fetch failed: ${err.message}`));
273
+ await rm(tempDir, { recursive: true, force: true });
274
+ return downloadViaApi(owner, repo, '.', targetDir, verbose);
275
+ }
276
+
277
+ if (!response.ok) {
278
+ if (verbose) console.log(dim(` Warning: Could not download zip: ${response.status}`));
279
+ await rm(tempDir, { recursive: true, force: true });
280
+ // Fallback to API
281
+ return downloadViaApi(owner, repo, '.', targetDir, verbose);
282
+ }
283
+
284
+ try {
285
+ // Save zip to temp file
286
+ const buffer = Buffer.from(await response.arrayBuffer());
287
+ await writeFile(zipPath, buffer);
288
+
289
+ // Extract using platform-specific command
290
+ const extractDir = join(tempDir, 'extracted');
291
+ await mkdir(extractDir, { recursive: true });
292
+
293
+ const isWindows = process.platform === 'win32';
294
+ let extractResult;
295
+
296
+ if (isWindows) {
297
+ // PowerShell Expand-Archive with -LiteralPath to avoid injection
298
+ // Escape single quotes by doubling them for PowerShell string safety
299
+ const safeZipPath = zipPath.replace(/'/g, "''");
300
+ const safeExtractDir = extractDir.replace(/'/g, "''");
301
+ extractResult = await runCommand(
302
+ 'powershell',
303
+ ['-NoProfile', '-Command', `Expand-Archive -LiteralPath '${safeZipPath}' -DestinationPath '${safeExtractDir}' -Force`],
304
+ tempDir,
305
+ verbose
306
+ );
307
+ } else {
308
+ // Unix unzip
309
+ extractResult = await runCommand('unzip', ['-q', zipPath, '-d', extractDir], tempDir, verbose);
310
+ }
311
+
312
+ if (!extractResult.success) {
313
+ if (verbose) console.log(dim(` Warning: Zip extraction failed, using API fallback`));
314
+ await rm(tempDir, { recursive: true, force: true });
315
+ return downloadViaApi(owner, repo, '.', targetDir, verbose);
316
+ }
317
+
318
+ // GitHub zips have a top-level folder like "repo-main/", move contents up
319
+ const extractedItems = await readdir(extractDir);
320
+ const repoFolder = extractedItems.find(item => item.startsWith(`${repo}-`));
321
+ const sourceDir = repoFolder ? join(extractDir, repoFolder) : extractDir;
322
+
323
+ // Move contents to target
324
+ await mkdir(targetDir, { recursive: true });
325
+ const items = await readdir(sourceDir);
326
+ for (const item of items) {
327
+ const src = join(sourceDir, item);
328
+ const dest = join(targetDir, item);
329
+ await rename(src, dest);
330
+ }
331
+
332
+ if (verbose) console.log(dim(` Zip extraction complete`));
333
+ } catch (err) {
334
+ if (verbose) console.log(dim(` Warning: Zip extraction failed: ${err.message}`));
335
+ await rm(tempDir, { recursive: true, force: true });
336
+ return downloadViaApi(owner, repo, '.', targetDir, verbose);
337
+ }
338
+
339
+ // Cleanup
340
+ await rm(tempDir, { recursive: true, force: true });
341
+
342
+ // Count files in target
343
+ const files = await readdir(targetDir);
344
+ return { success: files.length > 0, filesDownloaded: files.length };
345
+ }
346
+
347
+ /**
348
+ * Run a command and return success/failure
349
+ */
350
+ function runCommand(cmd, args, cwd, verbose = false) {
351
+ return new Promise((resolve) => {
352
+ const proc = spawn(cmd, args, { cwd, shell: true, stdio: verbose ? 'inherit' : 'pipe' });
353
+ let output = '';
354
+ if (!verbose && proc.stdout) {
355
+ proc.stdout.on('data', (d) => { output += d.toString(); });
356
+ }
357
+ if (!verbose && proc.stderr) {
358
+ proc.stderr.on('data', (d) => { output += d.toString(); });
359
+ }
360
+ proc.on('close', (code) => resolve({ success: code === 0, output }));
361
+ proc.on('error', (err) => resolve({ success: false, output: err.message }));
362
+ });
363
+ }
364
+
365
+ /**
366
+ * Download files via GitHub API (fallback method)
367
+ * @returns {Promise<{success: boolean, filesDownloaded: number, error?: string}>}
368
+ */
369
+ async function downloadViaApi(owner, repo, folderPath, targetDir, verbose) {
370
+ await mkdir(targetDir, { recursive: true });
371
+
372
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${folderPath === '.' ? '' : folderPath}`;
373
+
374
+ try {
375
+ const response = await fetch(apiUrl, {
376
+ headers: {
377
+ 'Accept': 'application/vnd.github.v3+json',
378
+ 'User-Agent': 'fnx-cli',
379
+ },
380
+ });
381
+
382
+ if (!response.ok) {
383
+ const error = `GitHub API error: ${response.status}`;
384
+ if (verbose) console.log(dim(` Warning: Could not fetch template listing: ${response.status}`));
385
+ return { success: false, filesDownloaded: 0, error };
386
+ }
387
+
388
+ const contents = await response.json();
389
+ let filesDownloaded = 0;
390
+
391
+ for (const item of contents) {
392
+ if (item.type === 'file') {
393
+ const filePath = safePath(targetDir, item.name);
394
+ const downloaded = await downloadFile(item.download_url, filePath);
395
+ if (downloaded) filesDownloaded++;
396
+ } else if (item.type === 'dir') {
397
+ const subDir = safePath(targetDir, item.name);
398
+ const dirResult = await downloadDirectory(owner, repo, item.path, subDir, verbose);
399
+ filesDownloaded += dirResult;
400
+ }
401
+ }
402
+
403
+ if (verbose && filesDownloaded > 0) {
404
+ console.log(dim(` Downloaded ${filesDownloaded} files via API`));
405
+ }
406
+
407
+ return { success: filesDownloaded > 0, filesDownloaded, error: filesDownloaded === 0 ? 'No files downloaded' : undefined };
408
+ } catch (err) {
409
+ if (verbose) console.log(dim(` Warning: Template download failed: ${err.message}`));
410
+ return { success: false, filesDownloaded: 0, error: err.message };
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Recursively download a directory from GitHub
416
+ */
417
+ async function downloadDirectory(owner, repo, path, targetDir, verbose) {
418
+ await mkdir(targetDir, { recursive: true });
419
+
420
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`;
421
+ let filesDownloaded = 0;
422
+
423
+ try {
424
+ const response = await fetch(apiUrl, {
425
+ headers: {
426
+ 'Accept': 'application/vnd.github.v3+json',
427
+ 'User-Agent': 'fnx-cli',
428
+ },
429
+ });
430
+
431
+ if (!response.ok) return 0;
432
+
433
+ const contents = await response.json();
434
+
435
+ for (const item of contents) {
436
+ if (item.type === 'file') {
437
+ const filePath = safePath(targetDir, item.name);
438
+ const downloaded = await downloadFile(item.download_url, filePath);
439
+ if (downloaded) filesDownloaded++;
440
+ } else if (item.type === 'dir') {
441
+ const subDir = safePath(targetDir, item.name);
442
+ filesDownloaded += await downloadDirectory(owner, repo, item.path, subDir, verbose);
443
+ }
444
+ }
445
+ } catch {
446
+ // Skip failed directories
447
+ }
448
+ return filesDownloaded;
449
+ }
450
+
451
+ /**
452
+ * Download a single file from URL
453
+ * @returns {Promise<boolean>} true if download succeeded
454
+ */
455
+ async function downloadFile(url, filePath) {
456
+ try {
457
+ const response = await fetch(url);
458
+ if (!response.ok) {
459
+ return false;
460
+ }
461
+ const content = await response.text();
462
+ await mkdir(dirname(filePath), { recursive: true });
463
+ await writeFile(filePath, content);
464
+ return true;
465
+ } catch {
466
+ return false;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Generate fnx-specific configuration files (app-config.yaml only)
472
+ * Other files (host.json, local.settings.json, etc.) come from template download
473
+ * @param {string} targetDir - Target directory
474
+ * @param {Object} options - Project options
475
+ */
476
+ export async function generateConfigFiles(targetDir, options) {
477
+ const { runtime, version, sku, verbose } = options;
478
+
479
+ // Replace template placeholders with runtime version
480
+ await replaceTemplatePlaceholders(targetDir, runtime, version, verbose);
481
+
482
+ // Map CLI runtime to worker runtime name
483
+ const workerRuntimeMap = {
484
+ 'python': 'python',
485
+ 'node': 'node',
486
+ 'typescript': 'node',
487
+ 'javascript': 'node',
488
+ 'dotnet-isolated': 'dotnet-isolated',
489
+ 'java': 'java',
490
+ 'powershell': 'powershell',
491
+ };
492
+ const runtimeName = workerRuntimeMap[runtime] || runtime;
493
+ const runtimeVersion = version || getDefaultVersion(runtime) || getDefaultVersion(runtimeName);
494
+
495
+ // Create app-config.yaml using shared config.js function
496
+ const created = await createAppConfig(targetDir, {
497
+ runtime: runtimeName,
498
+ version: runtimeVersion,
499
+ sku,
500
+ }, { silent: !verbose });
501
+
502
+ if (verbose && created) {
503
+ console.log(dim(` Generated: app-config.yaml`));
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Replaces template placeholders with the provided runtime version.
509
+ * For Java: replaces {{javaVersion}} with the provided version
510
+ * - For <java.version> (Maven compiler): converts "8" to "1.8"
511
+ * - For <javaVersion> (Azure runtime): keeps as-is (e.g., "8")
512
+ * For TypeScript/Node: replaces {{nodeVersion}} with the provided version
513
+ * @param {string} targetDir - Target directory
514
+ * @param {string} runtime - Runtime name
515
+ * @param {string|null} userVersion - User-specified version (null for default)
516
+ * @param {boolean} verbose - Log replacements
517
+ */
518
+ async function replaceTemplatePlaceholders(targetDir, runtime, userVersion, verbose) {
519
+ const version = userVersion || getDefaultVersion(runtime);
520
+ if (!version) return;
521
+
522
+ const normalizedRuntime = runtime.toLowerCase();
523
+
524
+ // Node.js / TypeScript: package.json
525
+ if (['node', 'typescript', 'javascript'].includes(normalizedRuntime)) {
526
+ const packageJsonPath = join(targetDir, 'package.json');
527
+ if (existsSync(packageJsonPath)) {
528
+ try {
529
+ let content = await readFile(packageJsonPath, 'utf-8');
530
+ if (content.includes('{{nodeVersion}}')) {
531
+ content = content.replace(/\{\{nodeVersion\}\}/g, version);
532
+ await writeFile(packageJsonPath, content);
533
+ if (verbose) console.log(dim(` Replaced {{nodeVersion}} with ${version} in package.json`));
534
+ }
535
+ } catch (err) {
536
+ if (verbose) console.log(dim(` Warning: Could not process package.json: ${err.message}`));
537
+ }
538
+ }
539
+
540
+ // TypeScript: Generate tsconfig.json if missing (some templates don't include it)
541
+ // Only generate if this is a TypeScript project (has .ts files or typescript in package.json)
542
+ const tsconfigPath = join(targetDir, 'tsconfig.json');
543
+ if (!existsSync(tsconfigPath)) {
544
+ const srcDir = join(targetDir, 'src');
545
+ const hasTypeScriptFiles = existsSync(srcDir) &&
546
+ (await readdir(srcDir, { recursive: true }).catch(() => []))
547
+ .some(f => f.endsWith('.ts'));
548
+ const packageJsonPath2 = join(targetDir, 'package.json');
549
+ const hasTypeScriptDep = existsSync(packageJsonPath2) &&
550
+ (await readFile(packageJsonPath2, 'utf-8').catch(() => ''))
551
+ .includes('"typescript"');
552
+
553
+ if (normalizedRuntime === 'typescript' || hasTypeScriptFiles || hasTypeScriptDep) {
554
+ const tsconfig = {
555
+ compilerOptions: {
556
+ module: 'commonjs',
557
+ target: 'es2018',
558
+ outDir: 'dist',
559
+ rootDir: '.',
560
+ sourceMap: true,
561
+ strict: false,
562
+ esModuleInterop: true,
563
+ skipLibCheck: true,
564
+ forceConsistentCasingInFileNames: true,
565
+ resolveJsonModule: true,
566
+ },
567
+ include: ['src/**/*.ts'],
568
+ };
569
+ try {
570
+ await writeFile(tsconfigPath, JSON.stringify(tsconfig, null, 2));
571
+ if (verbose) console.log(dim(` Generated tsconfig.json`));
572
+ } catch (err) {
573
+ if (verbose) console.log(dim(` Warning: Could not generate tsconfig.json: ${err.message}`));
574
+ }
575
+ }
576
+ }
577
+ }
578
+
579
+ // Java: pom.xml
580
+ if (normalizedRuntime === 'java') {
581
+ const pomPath = join(targetDir, 'pom.xml');
582
+ if (existsSync(pomPath)) {
583
+ try {
584
+ let content = await readFile(pomPath, 'utf-8');
585
+ if (content.includes('{{javaVersion}}')) {
586
+ // For <java.version> (Maven compiler): convert "8" to "1.8", "11"+ stays as-is
587
+ const mavenVersion = version === '8' ? '1.8' : version;
588
+
589
+ // Replace <java.version>{{javaVersion}}</java.version> with Maven-compatible version
590
+ content = content.replace(
591
+ /<java\.version>\{\{javaVersion\}\}<\/java\.version>/g,
592
+ `<java.version>${mavenVersion}</java.version>`
593
+ );
594
+
595
+ // Replace other {{javaVersion}} placeholders (e.g., <javaVersion>) with raw version
596
+ content = content.replace(/\{\{javaVersion\}\}/g, version);
597
+
598
+ await writeFile(pomPath, content);
599
+ if (verbose) console.log(dim(` Replaced {{javaVersion}} with ${version} in pom.xml`));
600
+ }
601
+ } catch (err) {
602
+ if (verbose) console.log(dim(` Warning: Could not process pom.xml: ${err.message}`));
603
+ }
604
+ }
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Print success banner with next steps
610
+ * @param {string} targetDir - Target directory
611
+ * @param {string} projectName - Project name
612
+ * @param {string} sku - Target SKU
613
+ * @param {string} runtime - Runtime name (python, node, dotnet-isolated, java, powershell)
614
+ */
615
+ export function printSuccessBanner(targetDir, projectName, sku, runtime) {
616
+ const cwd = process.cwd();
617
+ const relativePath = targetDir === cwd ? '.' : targetDir.replace(cwd, '.').replace(/\\/g, '/');
618
+
619
+ // Runtime-specific install steps
620
+ let installStep;
621
+ let extraSteps = 0;
622
+ switch (runtime) {
623
+ case 'python':
624
+ installStep = `${dim('2.')} ${bold('python -m venv .venv && .venv\\Scripts\\activate')} ${dim('(Windows)')}
625
+ ${dim('or')} ${bold('python -m venv .venv && source .venv/bin/activate')} ${dim('(Linux/macOS)')}
626
+ ${dim('3.')} ${bold('pip install -r requirements.txt')}`;
627
+ extraSteps = 1;
628
+ break;
629
+ case 'typescript':
630
+ installStep = `${dim('2.')} ${bold('npm install')}
631
+ ${dim('3.')} ${bold('npm run build')}`;
632
+ extraSteps = 1;
633
+ break;
634
+ case 'node':
635
+ case 'javascript':
636
+ installStep = `${dim('2.')} ${bold('npm install')}`;
637
+ break;
638
+ case 'dotnet-isolated':
639
+ installStep = `${dim('2.')} ${bold('dotnet restore')}`;
640
+ break;
641
+ case 'java':
642
+ installStep = `${dim('2.')} ${bold('mvn clean package')}`;
643
+ break;
644
+ case 'powershell':
645
+ installStep = `${dim('2.')} ${dim('(No dependencies to install)')}`;
646
+ break;
647
+ default:
648
+ installStep = `${dim('2.')} ${bold('Install dependencies')}`;
649
+ }
650
+
651
+ // Adjust fnx start step number based on extra steps
652
+ const startStepNum = `${3 + extraSteps}.`;
653
+
654
+ console.log(`
655
+ ${success('✓')} ${bold('Project created successfully!')}
656
+
657
+ ${title('Project:')} ${funcName(projectName)}
658
+ ${title('Location:')} ${dim(relativePath)}
659
+ ${title('Target SKU:')} ${info(sku)}
660
+
661
+ ${title('Next steps:')}
662
+
663
+ ${dim('1.')} ${bold('cd ' + (relativePath === '.' ? '' : relativePath))}
664
+ ${installStep}
665
+ ${dim(startStepNum)} ${bold('fnx start')}
666
+
667
+ ${dim('For more templates:')}
668
+ ${bold('fnx init --template <name>')}`);
669
+ }