@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
|
@@ -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
|
+
}
|