harbor-templater 1.0.0 → 1.2.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.
package/README.md CHANGED
@@ -27,7 +27,7 @@ $ npm install -g harbor-templater
27
27
  $ harbor-templater COMMAND
28
28
  running command...
29
29
  $ harbor-templater (--version)
30
- harbor-templater/1.0.0 linux-x64 node-v24.12.0
30
+ harbor-templater/1.2.0 linux-x64 node-v24.12.0
31
31
  $ harbor-templater --help [COMMAND]
32
32
  USAGE
33
33
  $ harbor-templater COMMAND
@@ -100,7 +100,7 @@ EXAMPLES
100
100
  $ harbor-templater init -t template.json -o . --answer projectDir=./my-app --defaults
101
101
  ```
102
102
 
103
- _See code: [src/commands/init/index.ts](https://github.com/bendigiorgio/harbor-templater/blob/v1.0.0/src/commands/init/index.ts)_
103
+ _See code: [src/commands/init/index.ts](https://github.com/bendigiorgio/harbor-templater/blob/v1.2.0/src/commands/init/index.ts)_
104
104
 
105
105
  ## `harbor-templater plugins`
106
106
 
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { run } from '@oclif/core';
1
+ export { run } from "@oclif/core";
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { run } from '@oclif/core';
1
+ export { run } from "@oclif/core";
@@ -1,13 +1,45 @@
1
1
  import { spawn } from "node:child_process";
2
2
  export async function runShellCommand(command, cwd) {
3
3
  await new Promise((resolve, reject) => {
4
- const child = spawn(command, { cwd, shell: true, stdio: "inherit" });
4
+ const child = spawn(command, {
5
+ cwd,
6
+ shell: true,
7
+ stdio: ["inherit", "pipe", "pipe"],
8
+ });
9
+ const stdoutChunks = [];
10
+ const stderrChunks = [];
11
+ const maxCaptureBytes = 64 * 1024;
12
+ let stdoutBytes = 0;
13
+ let stderrBytes = 0;
14
+ child.stdout?.on("data", (chunk) => {
15
+ process.stdout.write(chunk);
16
+ if (stdoutBytes < maxCaptureBytes) {
17
+ stdoutChunks.push(chunk);
18
+ stdoutBytes += chunk.length;
19
+ }
20
+ });
21
+ child.stderr?.on("data", (chunk) => {
22
+ process.stderr.write(chunk);
23
+ if (stderrBytes < maxCaptureBytes) {
24
+ stderrChunks.push(chunk);
25
+ stderrBytes += chunk.length;
26
+ }
27
+ });
5
28
  child.on("error", reject);
6
29
  child.on("exit", (code) => {
7
30
  if (code === 0)
8
- resolve();
9
- else
10
- reject(new Error(`Command failed (${code}): ${command}`));
31
+ return resolve();
32
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
33
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
34
+ const details = [
35
+ stdout.trim() ? `stdout:\n${stdout.trim()}` : "",
36
+ stderr.trim() ? `stderr:\n${stderr.trim()}` : "",
37
+ ]
38
+ .filter(Boolean)
39
+ .join("\n\n");
40
+ reject(new Error(details
41
+ ? `Command failed (${code}): ${command}\n\n${details}`
42
+ : `Command failed (${code}): ${command}`));
11
43
  });
12
44
  });
13
45
  }
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { x as untar } from "tar";
5
+ import { runShellCommand } from "./commands.js";
5
6
  export async function resolveSource(source) {
6
7
  if (source.startsWith("http://") || source.startsWith("https://")) {
7
8
  return await downloadUrlToTempFile(source);
@@ -30,6 +31,34 @@ async function downloadUrlToTempFile(url) {
30
31
  // If <path> points to a directory, returns kind=dir.
31
32
  async function downloadGitHubToTemp(source) {
32
33
  const parsed = parseGitHubSource(source);
34
+ const transport = (process.env.HARBOR_TEMPLATER_GITHUB_TRANSPORT ?? "auto")
35
+ .trim()
36
+ .toLowerCase();
37
+ if (transport !== "auto" && transport !== "tarball" && transport !== "git") {
38
+ throw new Error(`Invalid HARBOR_TEMPLATER_GITHUB_TRANSPORT: ${process.env.HARBOR_TEMPLATER_GITHUB_TRANSPORT}. Expected auto|tarball|git.`);
39
+ }
40
+ if (transport === "tarball") {
41
+ return await downloadGitHubTarballToTemp(parsed);
42
+ }
43
+ if (transport === "git") {
44
+ return await downloadGitHubViaGitToTemp(parsed);
45
+ }
46
+ // auto
47
+ try {
48
+ return await downloadGitHubTarballToTemp(parsed);
49
+ }
50
+ catch (error) {
51
+ // Private repos via codeload typically return 404 (and sometimes 403).
52
+ // In those cases, fall back to git so the user's local credentials are used.
53
+ const message = String(error.message ?? error);
54
+ if (message.includes(" 404 ") || message.includes(" 403 ")) {
55
+ return await downloadGitHubViaGitToTemp(parsed);
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+ async function downloadGitHubTarballToTemp(parsed) {
61
+ const subpath = normalizeGitHubSubpath(parsed.subpath);
33
62
  const tarballUrl = `https://codeload.github.com/${parsed.owner}/${parsed.repo}/tar.gz/${parsed.ref}`;
34
63
  const response = await fetch(tarballUrl);
35
64
  if (!response.ok) {
@@ -50,12 +79,75 @@ async function downloadGitHubToTemp(source) {
50
79
  if (!firstEntry)
51
80
  throw new Error("Downloaded GitHub tarball was empty");
52
81
  const root = path.join(extractDir, firstEntry);
53
- const candidate = path.join(root, parsed.subpath);
82
+ const candidate = path.join(root, subpath);
54
83
  const stat = await fs.stat(candidate);
55
84
  return stat.isDirectory()
56
85
  ? { kind: "dir", path: candidate }
57
86
  : { kind: "file", path: candidate };
58
87
  }
88
+ async function downloadGitHubViaGitToTemp(parsed) {
89
+ const subpath = normalizeGitHubSubpath(parsed.subpath);
90
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "harbor-templater-gh-git-"));
91
+ const repoDir = path.join(tmpDir, "repo");
92
+ const extractDir = path.join(tmpDir, "extract");
93
+ await fs.mkdir(repoDir, { recursive: true });
94
+ await fs.mkdir(extractDir, { recursive: true });
95
+ const httpsUrl = `https://github.com/${parsed.owner}/${parsed.repo}.git`;
96
+ const sshUrl = `git@github.com:${parsed.owner}/${parsed.repo}.git`;
97
+ const preferred = (process.env.HARBOR_TEMPLATER_GITHUB_CLONE_PROTOCOL ?? "")
98
+ .trim()
99
+ .toLowerCase();
100
+ const urls = preferred === "ssh"
101
+ ? [sshUrl, httpsUrl]
102
+ : preferred === "https" || preferred === ""
103
+ ? [httpsUrl, sshUrl]
104
+ : (() => {
105
+ throw new Error(`Invalid HARBOR_TEMPLATER_GITHUB_CLONE_PROTOCOL: ${process.env.HARBOR_TEMPLATER_GITHUB_CLONE_PROTOCOL}. Expected https|ssh.`);
106
+ })();
107
+ await runShellCommand("git init", repoDir);
108
+ let lastError;
109
+ for (const [idx, url] of urls.entries()) {
110
+ try {
111
+ if (idx === 0) {
112
+ await runShellCommand(`git remote add origin ${escapeShellArg(url)}`, repoDir);
113
+ }
114
+ else {
115
+ await runShellCommand(`git remote set-url origin ${escapeShellArg(url)}`, repoDir);
116
+ }
117
+ // Fetch only what's needed for the requested ref.
118
+ await runShellCommand(`git fetch --depth 1 origin ${escapeShellArg(parsed.ref)}`, repoDir);
119
+ const archivePath = path.join(tmpDir, "archive.tar");
120
+ await runShellCommand(`git archive --format=tar --output=${escapeShellArg(archivePath)} FETCH_HEAD ${escapeShellArg(subpath)}`, repoDir);
121
+ await untar({ file: archivePath, cwd: extractDir });
122
+ const candidate = path.join(extractDir, subpath);
123
+ const stat = await fs.stat(candidate);
124
+ return stat.isDirectory()
125
+ ? { kind: "dir", path: candidate }
126
+ : { kind: "file", path: candidate };
127
+ }
128
+ catch (error) {
129
+ lastError = error;
130
+ }
131
+ }
132
+ throw new Error(`Failed to fetch GitHub source via git for ${parsed.owner}/${parsed.repo}#${parsed.ref}:${subpath}. Ensure you have access to the repo and that your git credentials (credential helper / SSH agent) are configured.\n\n${String(lastError?.message ?? lastError)}`);
133
+ }
134
+ function normalizeGitHubSubpath(input) {
135
+ const cleaned = input.replaceAll("\\\\", "/").trim();
136
+ if (cleaned.length === 0)
137
+ throw new Error("GitHub source path cannot be empty");
138
+ if (cleaned.includes("\u0000"))
139
+ throw new Error("GitHub source path contains invalid characters");
140
+ if (path.posix.isAbsolute(cleaned))
141
+ throw new Error("GitHub source path must be relative");
142
+ const segments = cleaned.split("/");
143
+ if (segments.some((s) => s === ".."))
144
+ throw new Error("GitHub source path must not contain '..'");
145
+ return cleaned;
146
+ }
147
+ function escapeShellArg(value) {
148
+ // Minimal POSIX-style escaping (works on macOS/Linux shells; Windows uses separate .cmd entrypoints).
149
+ return `'${value.replaceAll("'", "'\\''")}'`;
150
+ }
59
151
  function parseGitHubSource(input) {
60
152
  const trimmed = input.slice("github:".length);
61
153
  const hashIdx = trimmed.indexOf("#");
@@ -1,4 +1,5 @@
1
1
  export type JSONTemplate = {
2
+ $schema?: string;
2
3
  author?: string;
3
4
  description?: string;
4
5
  name: string;
@@ -90,5 +90,5 @@
90
90
  ]
91
91
  }
92
92
  },
93
- "version": "1.0.0"
93
+ "version": "1.2.0"
94
94
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harbor-templater",
3
3
  "description": "A CLI tool for scaffolding projects using Harbor templates",
4
- "version": "1.0.0",
4
+ "version": "1.2.0",
5
5
  "author": "Ben Di Giorgio",
6
6
  "bin": {
7
7
  "harbor-templater": "./bin/run.js"