@navneet_25/tempjs 1.0.4 → 2.0.0

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,220 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { findSiteTs } from "./theme-manager.js";
5
+
6
+ /**
7
+ * @typedef {{
8
+ * yes?: boolean,
9
+ * name?: string,
10
+ * shortName?: string,
11
+ * baseUrl?: string,
12
+ * phone?: string,
13
+ * phoneDisplay?: string,
14
+ * countryCode?: string,
15
+ * email?: string,
16
+ * address?: string
17
+ * }} BrandOptions
18
+ */
19
+
20
+ /**
21
+ * @param {string} siteContent
22
+ */
23
+ function parseCurrentBrandValues(siteContent) {
24
+ return {
25
+ brandName: (siteContent.match(/name:\s*"([^"]+)"/) || [])[1] || "Chanakya Resort",
26
+ shortName: (siteContent.match(/shortName:\s*"([^"]+)"/) || [])[1] || "Chanakya",
27
+ baseUrl: (siteContent.match(/baseUrl:\s*"([^"]+)"/) || [])[1] || "https://chanakyaresort.com",
28
+ phone: (siteContent.match(/phone:\s*"([^"]+)"/) || [])[1] || "9876543210",
29
+ phoneDisplay: (siteContent.match(/phoneDisplay:\s*"([^"]+)"/) || [])[1] || "+91 98765 43210",
30
+ countryCode: (siteContent.match(/countryCode:\s*"([^"]+)"/) || [])[1] || "91",
31
+ email: (siteContent.match(/email:\s*"([^"]+)"/) || [])[1] || "reservations@chanakyaresort.com",
32
+ address: (siteContent.match(/full:\s*"([^"]+)"/) || [])[1] || "Lonavala, Maharashtra, India",
33
+ };
34
+ }
35
+
36
+ /**
37
+ * @param {string} baseUrl
38
+ */
39
+ function deriveWwwHost(baseUrl) {
40
+ try {
41
+ const url = new URL(baseUrl);
42
+ return url.hostname.startsWith("www.") ? url.hostname : `www.${url.hostname}`;
43
+ } catch {
44
+ let wwwHost = baseUrl.replace(/^https?:\/\//, "");
45
+ if (!wwwHost.startsWith("www.")) wwwHost = `www.${wwwHost}`;
46
+ return wwwHost;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * @param {string} siteContent
52
+ * @param {{
53
+ * brandName: string,
54
+ * shortName: string,
55
+ * baseUrl: string,
56
+ * phone: string,
57
+ * phoneDisplay: string,
58
+ * countryCode: string,
59
+ * email: string,
60
+ * address: string
61
+ * }} values
62
+ */
63
+ function applyBrandToSiteContent(siteContent, values) {
64
+ const {
65
+ brandName,
66
+ shortName,
67
+ baseUrl,
68
+ phone,
69
+ phoneDisplay,
70
+ countryCode,
71
+ email,
72
+ address,
73
+ } = values;
74
+
75
+ const wwwHost = deriveWwwHost(baseUrl);
76
+
77
+ const newBrandBlock = `brand: {
78
+ name: "${brandName}",
79
+ shortName: "${shortName}",
80
+ tagline: "Where Nature Meets Refined Comfort",
81
+ developerName: "${brandName}",
82
+ channelPartner: "${brandName} Partner",
83
+ copyright: "${brandName}. All Rights Reserved.",
84
+ managedBy: "Managed by ${brandName}.",
85
+ }`;
86
+
87
+ const newDomainBlock = `domain: {
88
+ baseUrl: "${baseUrl}",
89
+ wwwHost: "${wwwHost}",
90
+ }`;
91
+
92
+ let locality = "Lonavala";
93
+ let region = "MH";
94
+ const addressParts = address.split(",");
95
+ if (addressParts.length >= 2) {
96
+ locality = addressParts[0].trim();
97
+ region = addressParts[1].trim();
98
+ }
99
+
100
+ const newContactBlock = `contact: {
101
+ phone: "${phone}",
102
+ phoneDisplay: "${phoneDisplay}",
103
+ countryCode: "${countryCode}",
104
+ email: "${email}",
105
+ address: {
106
+ locality: "${locality}",
107
+ region: "${region}",
108
+ country: "IN",
109
+ full: "${address}",
110
+ },
111
+ }`;
112
+
113
+ const brandRegex = /brand:\s*\{[\s\S]*?\n\s*\},/;
114
+ const domainRegex = /domain:\s*\{[\s\S]*?\n\s*\},/;
115
+ const contactRegex = /contact:\s*\{[\s\S]*?address:\s*\{[\s\S]*?\}[\s\S]*?\n\s*\},/;
116
+
117
+ if (brandRegex.test(siteContent)) {
118
+ siteContent = siteContent.replace(brandRegex, newBrandBlock + ",");
119
+ }
120
+ if (domainRegex.test(siteContent)) {
121
+ siteContent = siteContent.replace(domainRegex, newDomainBlock + ",");
122
+ }
123
+ if (contactRegex.test(siteContent)) {
124
+ siteContent = siteContent.replace(contactRegex, newContactBlock + ",");
125
+ }
126
+
127
+ return siteContent;
128
+ }
129
+
130
+ /**
131
+ * @param {string} targetDir
132
+ * @param {BrandOptions} options
133
+ */
134
+ export async function applyBrand(targetDir, options = {}) {
135
+ const siteTsPath = await findSiteTs(targetDir);
136
+ if (!siteTsPath) {
137
+ throw new Error(
138
+ "Could not locate constants/site.ts. Make sure you are inside an initialized tempjs template directory."
139
+ );
140
+ }
141
+
142
+ let siteContent = await readFile(siteTsPath, "utf8");
143
+ const current = parseCurrentBrandValues(siteContent);
144
+
145
+ const values = {
146
+ brandName: options.name?.trim() || current.brandName,
147
+ shortName: options.shortName?.trim() || current.shortName,
148
+ baseUrl: options.baseUrl?.trim() || current.baseUrl,
149
+ phone: options.phone?.trim() || current.phone,
150
+ phoneDisplay: options.phoneDisplay?.trim() || current.phoneDisplay,
151
+ countryCode: options.countryCode?.trim() || current.countryCode,
152
+ email: options.email?.trim() || current.email,
153
+ address: options.address?.trim() || current.address,
154
+ };
155
+
156
+ siteContent = applyBrandToSiteContent(siteContent, values);
157
+ await writeFile(siteTsPath, siteContent, "utf8");
158
+ console.log("Successfully updated brand and contact configurations in constants/site.ts.");
159
+ }
160
+
161
+ /**
162
+ * @param {string} targetDir
163
+ * @param {BrandOptions} options
164
+ */
165
+ export async function promptAndApplyBrand(targetDir, options = {}) {
166
+ if (options.yes) {
167
+ await applyBrand(targetDir, options);
168
+ return;
169
+ }
170
+
171
+ const siteTsPath = await findSiteTs(targetDir);
172
+ if (!siteTsPath) {
173
+ console.error(
174
+ "Error: Could not locate constants/site.ts. Make sure you are inside an initialized tempjs template directory."
175
+ );
176
+ return;
177
+ }
178
+
179
+ let siteContent = await readFile(siteTsPath, "utf8");
180
+ const current = parseCurrentBrandValues(siteContent);
181
+
182
+ const rl = createInterface({ input, output });
183
+ try {
184
+ console.log("\n--- Configure Brand & Contact Details ---");
185
+
186
+ const brandName =
187
+ (await rl.question(`Brand Name [${current.brandName}]: `)).trim() || current.brandName;
188
+ const shortName =
189
+ (await rl.question(`Short/Display Name [${current.shortName}]: `)).trim() || current.shortName;
190
+ const baseUrl =
191
+ (await rl.question(`Base URL [${current.baseUrl}]: `)).trim() || current.baseUrl;
192
+ const phone =
193
+ (await rl.question(`Contact Phone Number [${current.phone}]: `)).trim() || current.phone;
194
+ const phoneDisplay =
195
+ (await rl.question(`Display Phone Number [${current.phoneDisplay}]: `)).trim() ||
196
+ current.phoneDisplay;
197
+ const countryCode =
198
+ (await rl.question(`Country Code [${current.countryCode}]: `)).trim() || current.countryCode;
199
+ const email =
200
+ (await rl.question(`Contact Email [${current.email}]: `)).trim() || current.email;
201
+ const address =
202
+ (await rl.question(`Full Address [${current.address}]: `)).trim() || current.address;
203
+
204
+ siteContent = applyBrandToSiteContent(siteContent, {
205
+ brandName,
206
+ shortName,
207
+ baseUrl,
208
+ phone,
209
+ phoneDisplay,
210
+ countryCode,
211
+ email,
212
+ address,
213
+ });
214
+
215
+ await writeFile(siteTsPath, siteContent, "utf8");
216
+ console.log("Successfully updated brand and contact configurations in constants/site.ts.");
217
+ } finally {
218
+ rl.close();
219
+ }
220
+ }
package/cli/config.js CHANGED
@@ -5,7 +5,20 @@ import { fileURLToPath } from "node:url";
5
5
  const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
6
6
 
7
7
  /** @typedef {{ owner: string, repo: string, branch: string, templatesPath: string }} RepositoryConfig */
8
- /** @typedef {{ directory: string, name: string, description: string }} TemplateEntry */
8
+ /** @typedef {{
9
+ * directory: string,
10
+ * name: string,
11
+ * description: string,
12
+ * version?: string,
13
+ * stack?: string[],
14
+ * packageManager?: string,
15
+ * node?: string,
16
+ * setupTime?: string,
17
+ * docker?: boolean,
18
+ * tags?: string[],
19
+ * features?: string[],
20
+ * docs?: string
21
+ * }} TemplateEntry */
9
22
  /** @typedef {{ repository: RepositoryConfig, templates: Record<string, TemplateEntry> }} Manifest */
10
23
 
11
24
  /**
package/cli/copy.js CHANGED
@@ -9,23 +9,10 @@ import {
9
9
  statSync,
10
10
  } from "node:fs";
11
11
  import { join } from "node:path";
12
+ import { NEVER_COPY_NAMES, shouldSkipFileName } from "./fs-ignore.js";
12
13
 
13
- const NEVER_COPY = new Set([".git", ".gitignore.bak"]);
14
14
  const NEVER_OVERWRITE = new Set([".git"]);
15
15
 
16
- /**
17
- * Files that must never be transferred into a generated project.
18
- * @param {string} name
19
- * @returns {boolean}
20
- */
21
- function shouldSkipFile(name) {
22
- if (NEVER_COPY.has(name)) return true;
23
- if (name === ".env" || name.startsWith(".env.")) {
24
- return name !== ".env.example";
25
- }
26
- return false;
27
- }
28
-
29
16
  /**
30
17
  * @param {string} dir
31
18
  * @returns {string[]}
@@ -75,7 +62,7 @@ function collectConflicts(sourceDir, targetDir, relative, conflicts) {
75
62
 
76
63
  const sourceNames = readdirSync(currentSource);
77
64
  for (const name of sourceNames) {
78
- if (shouldSkipFile(name)) continue;
65
+ if (shouldSkipFileName(name)) continue;
79
66
 
80
67
  const relPath = relative ? join(relative, name) : name;
81
68
  const sourcePath = join(sourceDir, relPath);
@@ -126,7 +113,7 @@ function copyRecursive(sourceRoot, targetRoot, relative) {
126
113
  const names = readdirSync(sourcePath);
127
114
 
128
115
  for (const name of names) {
129
- if (shouldSkipFile(name)) continue;
116
+ if (shouldSkipFileName(name) || NEVER_COPY_NAMES.has(name)) continue;
130
117
 
131
118
  const relPath = relative ? join(relative, name) : name;
132
119
  const from = join(sourceRoot, relPath);
@@ -138,7 +125,6 @@ function copyRecursive(sourceRoot, targetRoot, relative) {
138
125
 
139
126
  const stat = lstatSync(from);
140
127
  if (stat.isSymbolicLink()) {
141
- const linkTarget = readlinkSync(from);
142
128
  mkdirSync(join(to, ".."), { recursive: true });
143
129
  cpSync(from, to, { recursive: true, force: true });
144
130
  continue;
@@ -158,7 +144,7 @@ function copyRecursive(sourceRoot, targetRoot, relative) {
158
144
  * @param {string} dir
159
145
  */
160
146
  export function removeDirectory(dir) {
161
- if (existsSync(dir)) {
147
+ if (dir && existsSync(dir)) {
162
148
  rmSync(dir, { recursive: true, force: true });
163
149
  }
164
150
  }
@@ -0,0 +1,241 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { join, basename, resolve } from "node:path";
6
+ import crypto from "node:crypto";
7
+ import { execSync } from "node:child_process";
8
+
9
+ /**
10
+ * @typedef {{
11
+ * yes?: boolean,
12
+ * dbHost?: string,
13
+ * dbPort?: string,
14
+ * dbUser?: string,
15
+ * dbPassword?: string,
16
+ * dbName?: string,
17
+ * adminUser?: string,
18
+ * adminPassword?: string,
19
+ * skipDbPush?: boolean,
20
+ * dbPush?: boolean
21
+ * }} DbSetupOptions
22
+ */
23
+
24
+ /**
25
+ * @param {string} targetDir
26
+ * @param {DbSetupOptions} options
27
+ */
28
+ export async function setupDb(targetDir, options = {}) {
29
+ const envPath = join(targetDir, ".env");
30
+ const envExamplePath = join(targetDir, ".env.example");
31
+
32
+ let existingEnv = "";
33
+ if (existsSync(envPath)) {
34
+ try {
35
+ existingEnv = await readFile(envPath, "utf8");
36
+ } catch {
37
+ // Ignore
38
+ }
39
+ }
40
+
41
+ const getEnvVal = (key, fallback) => {
42
+ const match = existingEnv.match(new RegExp(`^${key}\\s*=\\s*["']?([^"'\r\n]+)["']?`, "m"));
43
+ return match ? match[1] : fallback;
44
+ };
45
+
46
+ const currentDbUrl = getEnvVal("DATABASE_URL", "");
47
+ let defaultHost = "localhost";
48
+ let defaultPort = "3306";
49
+ let defaultUser = "root";
50
+ let defaultPass = "";
51
+ let defaultDbName =
52
+ basename(resolve(targetDir)).toLowerCase().replace(/[^a-z0-9_]/g, "_") + "_db";
53
+
54
+ if (currentDbUrl) {
55
+ const urlMatch = currentDbUrl.match(
56
+ /mysql:\/\/([^:@]+)(?::([^@]+))?@([^:/]+)(?::(\d+))?\/([^?]+)/
57
+ );
58
+ if (urlMatch) {
59
+ defaultUser = urlMatch[1];
60
+ defaultPass = urlMatch[2] || "";
61
+ defaultHost = urlMatch[3];
62
+ defaultPort = urlMatch[4] || "3306";
63
+ defaultDbName = urlMatch[5];
64
+ }
65
+ }
66
+
67
+ const defaultAdminUser = getEnvVal("ADMIN_USER", "admin");
68
+ const defaultAdminPass = getEnvVal("ADMIN_PASSWORD", crypto.randomBytes(4).toString("hex"));
69
+
70
+ const host = options.dbHost?.trim() || defaultHost;
71
+ const port = options.dbPort?.trim() || defaultPort;
72
+ const user = options.dbUser?.trim() || defaultUser;
73
+ const pass = options.dbPassword !== undefined ? options.dbPassword : defaultPass;
74
+ const dbName = options.dbName?.trim() || defaultDbName;
75
+ const adminUser = options.adminUser?.trim() || defaultAdminUser;
76
+ const adminPass = options.adminPassword?.trim() || defaultAdminPass;
77
+
78
+ const dbUrl = `mysql://${user}${pass ? `:${encodeURIComponent(pass)}` : ""}@${host}:${port}/${dbName}`;
79
+
80
+ let envContent = "";
81
+ if (existsSync(envExamplePath)) {
82
+ envContent = await readFile(envExamplePath, "utf8");
83
+ }
84
+
85
+ const randomSecret = crypto.randomBytes(32).toString("base64");
86
+
87
+ const updateEnvKey = (content, key, value) => {
88
+ const regex = new RegExp(`^#?\\s*${key}\\s*=.*$`, "m");
89
+ if (regex.test(content)) {
90
+ return content.replace(regex, `${key}="${value}"`);
91
+ }
92
+ return `${content.trim()}\n${key}="${value}"\n`;
93
+ };
94
+
95
+ if (envContent) {
96
+ envContent = updateEnvKey(envContent, "DATABASE_URL", dbUrl);
97
+ envContent = updateEnvKey(envContent, "AUTH_SECRET", randomSecret);
98
+ envContent = updateEnvKey(envContent, "ADMIN_USER", adminUser);
99
+ envContent = updateEnvKey(envContent, "ADMIN_PASSWORD", adminPass);
100
+ } else {
101
+ envContent = `DATABASE_URL="${dbUrl}"
102
+ AUTH_SECRET="${randomSecret}"
103
+ ADMIN_USER="${adminUser}"
104
+ ADMIN_PASSWORD="${adminPass}"
105
+ AUTH_TRUST_HOST="true"
106
+ `;
107
+ }
108
+
109
+ await writeFile(envPath, envContent, "utf8");
110
+ console.log("Successfully created/updated .env configuration file.");
111
+
112
+ const shouldPush =
113
+ options.dbPush ||
114
+ (options.yes && !options.skipDbPush);
115
+
116
+ if (shouldPush) {
117
+ console.log("\nRunning Prisma Database Push (npx prisma db push)...");
118
+ try {
119
+ execSync("npx prisma db push", { cwd: targetDir, stdio: "inherit" });
120
+ console.log("\nSuccessfully synchronized database schemas.");
121
+ } catch {
122
+ console.error(
123
+ "\nWarning: Database synchronization failed. Make sure your MySQL server is running and the database details are correct."
124
+ );
125
+ console.error("You can run this manually later inside the project folder via: npx prisma db push");
126
+ }
127
+ } else if (options.yes && options.skipDbPush) {
128
+ console.log("\nSkipped database table synchronization (--skip-db-push).");
129
+ }
130
+ }
131
+
132
+ /**
133
+ * @param {string} targetDir
134
+ * @param {DbSetupOptions} options
135
+ */
136
+ export async function promptAndSetupDb(targetDir, options = {}) {
137
+ if (options.yes) {
138
+ await setupDb(targetDir, options);
139
+ return;
140
+ }
141
+
142
+ const envPath = join(targetDir, ".env");
143
+ const envExamplePath = join(targetDir, ".env.example");
144
+
145
+ let existingEnv = "";
146
+ if (existsSync(envPath)) {
147
+ try {
148
+ existingEnv = await readFile(envPath, "utf8");
149
+ } catch {
150
+ // Ignore
151
+ }
152
+ }
153
+
154
+ const getEnvVal = (key, fallback) => {
155
+ const match = existingEnv.match(new RegExp(`^${key}\\s*=\\s*["']?([^"'\r\n]+)["']?`, "m"));
156
+ return match ? match[1] : fallback;
157
+ };
158
+
159
+ const currentDbUrl = getEnvVal("DATABASE_URL", "");
160
+ let defaultHost = "localhost";
161
+ let defaultPort = "3306";
162
+ let defaultUser = "root";
163
+ let defaultPass = "";
164
+ let defaultDbName =
165
+ basename(resolve(targetDir)).toLowerCase().replace(/[^a-z0-9_]/g, "_") + "_db";
166
+
167
+ if (currentDbUrl) {
168
+ const urlMatch = currentDbUrl.match(
169
+ /mysql:\/\/([^:@]+)(?::([^@]+))?@([^:/]+)(?::(\d+))?\/([^?]+)/
170
+ );
171
+ if (urlMatch) {
172
+ defaultUser = urlMatch[1];
173
+ defaultPass = urlMatch[2] || "";
174
+ defaultHost = urlMatch[3];
175
+ defaultPort = urlMatch[4] || "3306";
176
+ defaultDbName = urlMatch[5];
177
+ }
178
+ }
179
+
180
+ const defaultAdminUser = getEnvVal("ADMIN_USER", "admin");
181
+ const defaultAdminPass = getEnvVal("ADMIN_PASSWORD", crypto.randomBytes(4).toString("hex"));
182
+
183
+ const rl = createInterface({ input, output });
184
+ try {
185
+ console.log("\n--- Configure Database & Environment Variables ---");
186
+
187
+ const host =
188
+ (await rl.question(`MySQL Database Host [${defaultHost}]: `)).trim() || defaultHost;
189
+ const port =
190
+ (await rl.question(`MySQL Database Port [${defaultPort}]: `)).trim() || defaultPort;
191
+ const user =
192
+ (await rl.question(`MySQL Database Username [${defaultUser}]: `)).trim() || defaultUser;
193
+ const pass =
194
+ (await rl.question(`MySQL Database Password [${defaultPass ? "*****" : "(empty)"}]: `)).trim() ||
195
+ defaultPass;
196
+ const dbName =
197
+ (await rl.question(`MySQL Database Name [${defaultDbName}]: `)).trim() || defaultDbName;
198
+ const adminUser =
199
+ (await rl.question(`Admin Portal Username [${defaultAdminUser}]: `)).trim() ||
200
+ defaultAdminUser;
201
+ const adminPass =
202
+ (await rl.question(`Admin Portal Password [${defaultAdminPass}]: `)).trim() ||
203
+ defaultAdminPass;
204
+
205
+ await setupDb(targetDir, {
206
+ dbHost: host,
207
+ dbPort: port,
208
+ dbUser: user,
209
+ dbPassword: pass,
210
+ dbName,
211
+ adminUser,
212
+ adminPassword: adminPass,
213
+ skipDbPush: true,
214
+ });
215
+
216
+ const runMigrate = await rl.question(
217
+ "\nDo you want to initialize the database tables now using Prisma? (y/n) [y]: "
218
+ );
219
+ const choice = runMigrate.trim().toLowerCase();
220
+ if (choice === "" || choice === "y" || choice === "yes") {
221
+ console.log("\nRunning Prisma Database Push (npx prisma db push)...");
222
+ try {
223
+ execSync("npx prisma db push", { cwd: targetDir, stdio: "inherit" });
224
+ console.log("\nSuccessfully synchronized database schemas.");
225
+ } catch {
226
+ console.error(
227
+ "\nWarning: Database synchronization failed. Make sure your MySQL server is running and the database details are correct."
228
+ );
229
+ console.error(
230
+ "You can run this manually later inside the project folder via: npx prisma db push"
231
+ );
232
+ }
233
+ } else {
234
+ console.log(
235
+ "\nSkipped database table synchronization. Remember to run 'npx prisma db push' before running the app."
236
+ );
237
+ }
238
+ } finally {
239
+ rl.close();
240
+ }
241
+ }
package/cli/fetch.js CHANGED
@@ -1,39 +1,44 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { createWriteStream, existsSync } from "node:fs";
2
+ import { createWriteStream, existsSync, statSync } from "node:fs";
3
3
  import { mkdir, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { pipeline } from "node:stream/promises";
7
7
  import { Readable } from "node:stream";
8
+ import { shouldSkipFileName } from "./fs-ignore.js";
9
+ import { countFiles } from "./file-tree.js";
8
10
 
9
11
  const CODELOAD_BASE = "https://codeload.github.com";
10
12
 
11
13
  /**
12
- * Download a single template directory from GitHub without cloning or per-file API calls.
13
- *
14
- * Strategy: one tarball download from codeload.github.com (not the REST API), then
15
- * extract only templates/<templateDirectory>/ from the archive. This uses a single
16
- * HTTP request instead of 60+ blob API calls, avoiding unauthenticated rate limits.
17
- *
14
+ * @typedef {{ bytes: number, files: number, durationMs: number, source: string }} FetchStats
15
+ * @typedef {{ templateRoot: string, cleanupDir: string, stats: FetchStats }} FetchResult
16
+ */
17
+
18
+ /**
18
19
  * @param {import('./config.js').RepositoryConfig} repo
19
20
  * @param {string} templateDirectory
20
- * @returns {Promise<string>} Path to temp directory containing template files
21
+ * @returns {Promise<FetchResult>}
21
22
  */
22
23
  export async function fetchTemplateFromGitHub(repo, templateDirectory) {
24
+ const startMs = performance.now();
23
25
  const tempRoot = await mkdtemp(join(tmpdir(), "template-cli-"));
24
26
  const tarballPath = join(tempRoot, "archive.tar.gz");
25
27
  const templateRoot = join(tempRoot, "template");
26
28
 
27
29
  try {
28
- await downloadTarball(repo, tarballPath);
29
- await extractTemplateFromTarball(
30
- tarballPath,
31
- templateRoot,
32
- repo,
33
- templateDirectory
34
- );
30
+ const bytes = await downloadTarball(repo, tarballPath);
31
+ await extractTemplateFromTarball(tarballPath, templateRoot, repo, templateDirectory);
35
32
  await readFile(join(templateRoot, "package.json"));
36
- return templateRoot;
33
+
34
+ const durationMs = performance.now() - startMs;
35
+ const files = countFiles(templateRoot);
36
+
37
+ return {
38
+ templateRoot,
39
+ cleanupDir: tempRoot,
40
+ stats: { bytes, files, durationMs, source: "remote" },
41
+ };
37
42
  } catch (error) {
38
43
  await rm(tempRoot, { recursive: true, force: true });
39
44
  throw error;
@@ -43,6 +48,7 @@ export async function fetchTemplateFromGitHub(repo, templateDirectory) {
43
48
  /**
44
49
  * @param {import('./config.js').RepositoryConfig} repo
45
50
  * @param {string} destPath
51
+ * @returns {Promise<number>} Downloaded bytes
46
52
  */
47
53
  async function downloadTarball(repo, destPath) {
48
54
  const branch = encodeURIComponent(repo.branch);
@@ -69,6 +75,7 @@ async function downloadTarball(repo, destPath) {
69
75
  }
70
76
 
71
77
  await pipeline(Readable.fromWeb(response.body), createWriteStream(destPath));
78
+ return statSync(destPath).size;
72
79
  }
73
80
 
74
81
  /**
@@ -128,13 +135,12 @@ async function extractTemplateFromTarball(
128
135
  }
129
136
 
130
137
  /**
131
- * Remove files that must never appear in generated projects.
132
138
  * @param {string} dir
133
139
  */
134
140
  async function removeSkippedFiles(dir) {
135
141
  if (!existsSync(dir)) return;
136
142
 
137
- const entries = await readDirSafe(dir);
143
+ const entries = await readDirEntries(dir);
138
144
  for (const entry of entries) {
139
145
  const fullPath = join(dir, entry.name);
140
146
  if (entry.isDirectory) {
@@ -155,7 +161,7 @@ async function removeSkippedFiles(dir) {
155
161
  /**
156
162
  * @param {string} dir
157
163
  */
158
- async function readDirSafe(dir) {
164
+ async function readDirEntries(dir) {
159
165
  const names = await readdir(dir);
160
166
  const result = [];
161
167
  for (const name of names) {
@@ -166,16 +172,6 @@ async function readDirSafe(dir) {
166
172
  return result;
167
173
  }
168
174
 
169
- /**
170
- * @param {string} name
171
- */
172
- function shouldSkipFileName(name) {
173
- if (name === ".env" || (name.startsWith(".env.") && name !== ".env.example")) {
174
- return true;
175
- }
176
- return false;
177
- }
178
-
179
175
  function buildHeaders() {
180
176
  const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
181
177
  if (token) {
@@ -199,3 +195,25 @@ export async function resolveLocalTemplate(packageRoot, templatesPath, templateD
199
195
  return null;
200
196
  }
201
197
  }
198
+
199
+ /**
200
+ * @param {string} packageRoot
201
+ * @param {string} templatesPath
202
+ * @param {string} templateDirectory
203
+ * @returns {Promise<FetchResult | null>}
204
+ */
205
+ export async function loadLocalTemplate(packageRoot, templatesPath, templateDirectory) {
206
+ const localPath = await resolveLocalTemplate(packageRoot, templatesPath, templateDirectory);
207
+ if (!localPath) return null;
208
+
209
+ return {
210
+ templateRoot: localPath,
211
+ cleanupDir: "",
212
+ stats: {
213
+ bytes: 0,
214
+ files: countFiles(localPath),
215
+ durationMs: 0,
216
+ source: "local",
217
+ },
218
+ };
219
+ }