@navneet_25/tempjs 1.0.1 → 1.0.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/README.md CHANGED
@@ -5,8 +5,8 @@ A single repository containing multiple website project templates, plus a small
5
5
  ## Quick start
6
6
 
7
7
  ```bash
8
- # Install the CLI globally (after publishing or linking locally)
9
- npm install -g tempjs
8
+ # Install the CLI globally
9
+ npm install -g @navneet_25/tempjs
10
10
 
11
11
  # Create a new project
12
12
  mkdir hotel-client
@@ -79,10 +79,17 @@ When templates exist locally under `templates/`, the CLI uses them directly (fas
79
79
 
80
80
  ### Global install (npm)
81
81
 
82
+ To install the CLI for the first time:
82
83
  ```bash
83
- npm install -g tempjs
84
+ npm install -g @navneet_25/tempjs
84
85
  ```
85
86
 
87
+ To update an existing installation to the absolute latest version (bypassing local NPM caches):
88
+ ```bash
89
+ npm install -g @navneet_25/tempjs@latest
90
+ ```
91
+ *Use the `@latest` flag when you have recently pushed a new template or updated configuration, to ensure NPM fetches the updated `templates.json` mapping configuration immediately.*
92
+
86
93
  ### From this repository
87
94
 
88
95
  ```bash
@@ -134,14 +141,13 @@ tempjs hotel --remote
134
141
 
135
142
  ## How fetching works
136
143
 
137
- `tempjs hotel` does **not** clone the entire repository.
144
+ `tempjs hotel` does **not** run `git clone` and does **not** call the GitHub REST API per file.
138
145
 
139
- 1. The CLI reads the GitHub API tree for the configured branch.
140
- 2. It filters files under `templates/hotel-website-template/`.
141
- 3. Only those files are downloaded (via blob API or raw content).
142
- 4. Files are validated in a temporary directory, then copied to your project.
146
+ 1. The CLI downloads **one** repository archive from `codeload.github.com` (a single HTTP request — no API rate-limit issues for public repos).
147
+ 2. It extracts only `templates/hotel-website-template/` from that archive into a temp directory.
148
+ 3. Files are validated, then copied into your project directory.
143
149
 
144
- Other templates are not transferred.
150
+ The archive contains the whole templates repo on the wire, but only the requested template folder is extracted locally. No `GITHUB_TOKEN` is required for public repositories.
145
151
 
146
152
  ## Repository structure
147
153
 
package/cli/fetch.js CHANGED
@@ -1,231 +1,189 @@
1
- import { createWriteStream } from "node:fs";
2
- import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
1
+ import { execFileSync } from "node:child_process";
2
+ import { createWriteStream, existsSync } from "node:fs";
3
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
- import { dirname, join } from "node:path";
5
+ import { join } from "node:path";
5
6
  import { pipeline } from "node:stream/promises";
6
7
  import { Readable } from "node:stream";
7
8
 
8
- const GITHUB_API = "https://api.github.com";
9
- const RAW_BASE = "https://raw.githubusercontent.com";
10
- const LARGE_FILE_BYTES = 1024 * 1024;
9
+ const CODELOAD_BASE = "https://codeload.github.com";
11
10
 
12
11
  /**
13
- * Download a single template directory from GitHub without cloning the full repo.
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
18
  * @param {import('./config.js').RepositoryConfig} repo
15
19
  * @param {string} templateDirectory
16
20
  * @returns {Promise<string>} Path to temp directory containing template files
17
21
  */
18
22
  export async function fetchTemplateFromGitHub(repo, templateDirectory) {
19
- const prefix = `${repo.templatesPath}/${templateDirectory}/`;
20
- const tree = await fetchRepoTree(repo);
21
-
22
- const blobs = tree
23
- .filter((entry) => entry.type === "blob" && entry.path.startsWith(prefix))
24
- .map((entry) => ({
25
- githubPath: entry.path,
26
- relPath: entry.path.slice(prefix.length),
27
- sha: entry.sha,
28
- }));
29
-
30
- if (blobs.length === 0) {
31
- throw new Error(
32
- `Template directory not found on GitHub: ${repo.templatesPath}/${templateDirectory}`
33
- );
34
- }
35
-
36
23
  const tempRoot = await mkdtemp(join(tmpdir(), "template-cli-"));
24
+ const tarballPath = join(tempRoot, "archive.tar.gz");
37
25
  const templateRoot = join(tempRoot, "template");
38
- await mkdir(templateRoot, { recursive: true });
39
-
40
- const concurrency = 8;
41
- let index = 0;
42
- const errors = [];
43
-
44
- async function worker() {
45
- while (index < blobs.length) {
46
- const current = index++;
47
- const blob = blobs[current];
48
- try {
49
- await downloadBlob(repo, blob, templateRoot);
50
- } catch (error) {
51
- errors.push({ relPath: blob.relPath, error });
52
- }
53
- }
54
- }
55
26
 
56
- await Promise.all(Array.from({ length: concurrency }, () => worker()));
57
-
58
- if (errors.length > 0) {
59
- const detail = errors
60
- .slice(0, 5)
61
- .map((e) => `${e.relPath}: ${e.error.message}`)
62
- .join("\n");
63
- throw new Error(
64
- `Failed to download ${errors.length} file(s).\n${detail}`
27
+ try {
28
+ await downloadTarball(repo, tarballPath);
29
+ await extractTemplateFromTarball(
30
+ tarballPath,
31
+ templateRoot,
32
+ repo,
33
+ templateDirectory
65
34
  );
35
+ await readFile(join(templateRoot, "package.json"));
36
+ return templateRoot;
37
+ } catch (error) {
38
+ await rm(tempRoot, { recursive: true, force: true });
39
+ throw error;
66
40
  }
67
-
68
- return templateRoot;
69
41
  }
70
42
 
71
43
  /**
72
44
  * @param {import('./config.js').RepositoryConfig} repo
73
- * @returns {Promise<Array<{ path: string, type: string, sha: string }>>}
45
+ * @param {string} destPath
74
46
  */
75
- async function fetchRepoTree(repo) {
76
- const refUrl = `${GITHUB_API}/repos/${repo.owner}/${repo.repo}/git/ref/heads/${repo.branch}`;
77
- const refResponse = await githubRequest(refUrl);
78
-
79
- if (!refResponse.ok) {
80
- throw await formatGitHubError(
81
- refResponse,
82
- `Could not resolve branch "${repo.branch}" for ${repo.owner}/${repo.repo}`
83
- );
84
- }
85
-
86
- const refData = await refResponse.json();
87
- const commitSha = refData.object?.sha;
88
- if (!commitSha) {
89
- throw new Error(`Invalid ref response for branch ${repo.branch}`);
90
- }
91
-
92
- const commitUrl = `${GITHUB_API}/repos/${repo.owner}/${repo.repo}/git/commits/${commitSha}`;
93
- const commitResponse = await githubRequest(commitUrl);
94
- if (!commitResponse.ok) {
95
- throw await formatGitHubError(commitResponse, "Could not fetch commit metadata");
96
- }
47
+ async function downloadTarball(repo, destPath) {
48
+ const branch = encodeURIComponent(repo.branch);
49
+ const url = `${CODELOAD_BASE}/${repo.owner}/${repo.repo}/tar.gz/${branch}`;
97
50
 
98
- const commitData = await commitResponse.json();
99
- const treeSha = commitData.tree?.sha;
100
- if (!treeSha) {
101
- throw new Error("Invalid commit response: missing tree SHA");
102
- }
51
+ const response = await fetch(url, {
52
+ headers: buildHeaders(),
53
+ redirect: "follow",
54
+ });
103
55
 
104
- const treeUrl = `${GITHUB_API}/repos/${repo.owner}/${repo.repo}/git/trees/${treeSha}?recursive=1`;
105
- const treeResponse = await githubRequest(treeUrl);
106
- if (!treeResponse.ok) {
107
- throw await formatGitHubError(treeResponse, "Could not fetch repository tree");
56
+ if (!response.ok) {
57
+ let detail = `Could not download ${repo.owner}/${repo.repo}@${repo.branch}`;
58
+ if (response.status === 404) {
59
+ detail += "\nVerify the repository, branch, and templates.json settings.";
60
+ }
61
+ if (response.status === 401 || response.status === 403) {
62
+ detail += "\nFor private repositories, set GITHUB_TOKEN or GH_TOKEN.";
63
+ }
64
+ throw new Error(`${detail} (HTTP ${response.status})`);
108
65
  }
109
66
 
110
- const treeData = await treeResponse.json();
111
- if (treeData.truncated) {
112
- throw new Error(
113
- "Repository tree is too large for a single API request. Contact the repository maintainer."
114
- );
67
+ if (!response.body) {
68
+ throw new Error("Empty response while downloading repository archive");
115
69
  }
116
70
 
117
- return treeData.tree ?? [];
71
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(destPath));
118
72
  }
119
73
 
120
74
  /**
75
+ * @param {string} tarballPath
76
+ * @param {string} destDir
121
77
  * @param {import('./config.js').RepositoryConfig} repo
122
- * @param {{ githubPath: string, relPath: string, sha: string }} blob
123
- * @param {string} templateRoot
78
+ * @param {string} templateDirectory
124
79
  */
125
- async function downloadBlob(repo, blob, templateRoot) {
126
- const { githubPath, relPath, sha } = blob;
80
+ async function extractTemplateFromTarball(
81
+ tarballPath,
82
+ destDir,
83
+ repo,
84
+ templateDirectory
85
+ ) {
86
+ const listing = execFileSync("tar", ["-tzf", tarballPath], {
87
+ encoding: "utf8",
88
+ maxBuffer: 64 * 1024 * 1024,
89
+ });
127
90
 
128
- if (relPath.includes(".git/") || relPath === ".git" || githubPath.includes("/.git/")) {
129
- return;
91
+ const lines = listing.split("\n").filter(Boolean);
92
+ if (lines.length === 0) {
93
+ throw new Error("Repository archive is empty");
130
94
  }
131
95
 
132
- const baseName = relPath.includes("/") ? relPath.slice(relPath.lastIndexOf("/") + 1) : relPath;
133
- if (baseName === ".env" || (baseName.startsWith(".env.") && baseName !== ".env.example")) {
134
- return;
135
- }
96
+ const archiveRoot = lines[0].replace(/\/$/, "").split("/")[0];
97
+ const templateArchivePath = `${archiveRoot}/${repo.templatesPath}/${templateDirectory}`;
98
+ const templatePrefix = `${templateArchivePath}/`;
136
99
 
137
- const targetPath = join(templateRoot, relPath);
138
- await mkdir(dirname(targetPath), { recursive: true });
100
+ const hasTemplate = lines.some(
101
+ (line) => line === templatePrefix || line.startsWith(templatePrefix)
102
+ );
139
103
 
140
- const blobUrl = `${GITHUB_API}/repos/${repo.owner}/${repo.repo}/git/blobs/${sha}`;
141
- const blobResponse = await githubRequest(blobUrl);
142
- if (!blobResponse.ok) {
143
- throw await formatGitHubError(blobResponse, `Failed to fetch blob for ${relPath}`);
104
+ if (!hasTemplate) {
105
+ throw new Error(
106
+ `Template directory not found in repository: ${repo.templatesPath}/${templateDirectory}`
107
+ );
144
108
  }
145
109
 
146
- const blobData = await blobResponse.json();
110
+ const stripComponents = templateArchivePath.split("/").length;
147
111
 
148
- if (blobData.encoding === "base64" && blobData.content && blobData.size <= LARGE_FILE_BYTES) {
149
- const content = Buffer.from(blobData.content.replace(/\n/g, ""), "base64");
150
- await writeFile(targetPath, content);
151
- return;
152
- }
112
+ await mkdir(destDir, { recursive: true });
113
+
114
+ execFileSync(
115
+ "tar",
116
+ [
117
+ "-xzf",
118
+ tarballPath,
119
+ "-C",
120
+ destDir,
121
+ `--strip-components=${stripComponents}`,
122
+ templateArchivePath,
123
+ ],
124
+ { stdio: "pipe", maxBuffer: 64 * 1024 * 1024 }
125
+ );
153
126
 
154
- await downloadRawFile(repo, githubPath, targetPath);
127
+ await removeSkippedFiles(destDir);
155
128
  }
156
129
 
157
130
  /**
158
- * @param {import('./config.js').RepositoryConfig} repo
159
- * @param {string} githubPath
160
- * @param {string} targetPath
131
+ * Remove files that must never appear in generated projects.
132
+ * @param {string} dir
161
133
  */
162
- async function downloadRawFile(repo, githubPath, targetPath) {
163
- const url = `${RAW_BASE}/${repo.owner}/${repo.repo}/${repo.branch}/${githubPath}`;
164
- const response = await fetch(url, {
165
- headers: buildHeaders(),
166
- redirect: "follow",
167
- });
168
-
169
- if (!response.ok) {
170
- throw new Error(`HTTP ${response.status} for ${url}`);
171
- }
134
+ async function removeSkippedFiles(dir) {
135
+ if (!existsSync(dir)) return;
136
+
137
+ const entries = await readDirSafe(dir);
138
+ for (const entry of entries) {
139
+ const fullPath = join(dir, entry.name);
140
+ if (entry.isDirectory) {
141
+ if (entry.name === ".git") {
142
+ await rm(fullPath, { recursive: true, force: true });
143
+ continue;
144
+ }
145
+ await removeSkippedFiles(fullPath);
146
+ continue;
147
+ }
172
148
 
173
- if (!response.body) {
174
- throw new Error(`Empty response body for ${url}`);
149
+ if (shouldSkipFileName(entry.name)) {
150
+ await rm(fullPath, { force: true });
151
+ }
175
152
  }
153
+ }
176
154
 
177
- await pipeline(Readable.fromWeb(response.body), createWriteStream(targetPath));
155
+ /**
156
+ * @param {string} dir
157
+ */
158
+ async function readDirSafe(dir) {
159
+ const names = await readdir(dir);
160
+ const result = [];
161
+ for (const name of names) {
162
+ const fullPath = join(dir, name);
163
+ const info = await stat(fullPath);
164
+ result.push({ name, isDirectory: info.isDirectory() });
165
+ }
166
+ return result;
178
167
  }
179
168
 
180
169
  /**
181
- * @param {string} url
170
+ * @param {string} name
182
171
  */
183
- async function githubRequest(url) {
184
- return fetch(url, {
185
- headers: {
186
- ...buildHeaders(),
187
- Accept: "application/vnd.github+json",
188
- "X-GitHub-Api-Version": "2022-11-28",
189
- },
190
- });
172
+ function shouldSkipFileName(name) {
173
+ if (name === ".env" || (name.startsWith(".env.") && name !== ".env.example")) {
174
+ return true;
175
+ }
176
+ return false;
191
177
  }
192
178
 
193
179
  function buildHeaders() {
194
180
  const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
195
181
  if (token) {
196
- return { Authorization: `Bearer ${token}` };
182
+ return { Authorization: `token ${token}` };
197
183
  }
198
184
  return {};
199
185
  }
200
186
 
201
- /**
202
- * @param {Response} response
203
- * @param {string} message
204
- */
205
- async function formatGitHubError(response, message) {
206
- let detail = message;
207
- try {
208
- const body = await response.json();
209
- if (body?.message) detail = `${message}: ${body.message}`;
210
- } catch {
211
- // ignore parse errors
212
- }
213
-
214
- if (response.status === 404) {
215
- return new Error(
216
- `${detail}\nVerify TEMPLATES_REPO_URL / templates.json repository settings.`
217
- );
218
- }
219
-
220
- if (response.status === 403) {
221
- return new Error(
222
- `${detail}\nGitHub API rate limit may apply. Set GITHUB_TOKEN for higher limits.`
223
- );
224
- }
225
-
226
- return new Error(`${detail} (HTTP ${response.status})`);
227
- }
228
-
229
187
  /**
230
188
  * @param {string} packageRoot
231
189
  * @param {string} templatesPath
package/cli/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ #!/usr/bin/env node
2
+
1
3
  import { execSync } from "node:child_process";
2
4
  import { createInterface } from "node:readline/promises";
3
5
  import { stdin as input, stdout as output } from "node:process";
@@ -37,7 +39,7 @@ ENVIRONMENT
37
39
  TEMPLATES_REPO_REPO GitHub repository name
38
40
  TEMPLATES_REPO_BRANCH Branch name (default: main)
39
41
  TEMPLATE_USE_REMOTE=1 Always fetch from GitHub
40
- GITHUB_TOKEN / GH_TOKEN GitHub token for API rate limits
42
+ GITHUB_TOKEN / GH_TOKEN Optional required only for private repositories
41
43
 
42
44
  CONFIGURATION
43
45
  Repository and template mappings live in templates.json at the package root.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navneet_25/tempjs",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "CLI to instantiate website project templates from a single GitHub repository",
5
5
  "type": "module",
6
6
  "bin": {