harbor-templater 1.3.0 → 1.4.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.3.0 linux-x64 node-v24.12.0
30
+ harbor-templater/1.4.0 linux-x64 node-v24.13.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.3.0/src/commands/init/index.ts)_
103
+ _See code: [src/commands/init/index.ts](https://github.com/bendigiorgio/harbor-templater/blob/v1.4.0/src/commands/init/index.ts)_
104
104
 
105
105
  ## `harbor-templater plugins`
106
106
 
@@ -4,8 +4,9 @@ import { checkbox, confirm, input, select } from "@inquirer/prompts";
4
4
  import { Command, Flags } from "@oclif/core";
5
5
  import picomatch from "picomatch";
6
6
  import { runShellCommand } from "../../lib/commands.js";
7
+ import { applyRenameToBasename, copyDirWithTransforms, copyFileMaybeRender, } from "../../lib/copy-transform.js";
7
8
  import { applyEnvironmentReplacements } from "../../lib/environment.js";
8
- import { copyPath, ensureDir, looksLikeDirTarget, pathExists, } from "../../lib/fs-ops.js";
9
+ import { ensureDir, looksLikeDirTarget, pathExists } from "../../lib/fs-ops.js";
9
10
  import { mergeIntoTarget } from "../../lib/merge.js";
10
11
  import { resolveSource } from "../../lib/sources.js";
11
12
  import { buildInitialContext, evaluateCondition, interpolate, resolveTargetPath, templateQuestions, templateSteps, } from "../../lib/template-engine.js";
@@ -170,56 +171,84 @@ async function executeSteps(args) {
170
171
  }
171
172
  const policy = effectiveConflictPolicy(args);
172
173
  if (src.kind === "dir") {
173
- const shouldProceed = await handleDirConflict({
174
+ const decision = await handleDirConflict({
174
175
  policy,
175
- source: step.source,
176
176
  target,
177
177
  defaults: args.defaults,
178
178
  });
179
- if (!shouldProceed) {
179
+ if (!decision.proceed) {
180
180
  args.log(`skip copy ${step.source} -> ${target} (exists)`);
181
181
  break;
182
182
  }
183
- const excludeMatcher = step.exclude?.length
184
- ? picomatch(step.exclude, { dot: true })
185
- : null;
186
- await fs.cp(src.path, target, {
187
- recursive: true,
188
- force: policy === "overwrite" || args.force,
189
- filter: excludeMatcher
190
- ? (srcEntry) => {
191
- const rel = path
192
- .relative(src.path, srcEntry)
193
- .replaceAll("\\", "/");
194
- // keep root
195
- if (!rel)
196
- return true;
197
- return !excludeMatcher(rel);
198
- }
199
- : undefined,
183
+ const force = policy === "overwrite" || args.force || Boolean(decision.overwrite);
184
+ await copyDirWithTransforms(src.path, target, {
185
+ ctx: args.ctx,
186
+ force,
187
+ include: step.include,
188
+ exclude: step.exclude,
189
+ rename: step.rename,
190
+ render: step.render,
200
191
  });
201
192
  }
202
193
  else {
203
194
  // If target ends with '/', treat it as directory and keep filename
204
195
  const finalTarget = looksLikeDirTarget(step.target)
205
- ? path.join(target, path.basename(src.path))
196
+ ? path.join(target, applyRenameToBasename(path.basename(src.path), step.rename, args.ctx))
206
197
  : target;
207
- const shouldProceed = await handleFileConflict({
198
+ const decision = await handleFileConflict({
208
199
  policy,
209
- source: step.source,
210
200
  target: finalTarget,
211
201
  defaults: args.defaults,
212
202
  });
213
- if (!shouldProceed) {
203
+ if (!decision.proceed) {
214
204
  args.log(`skip copy ${step.source} -> ${finalTarget} (exists)`);
215
205
  break;
216
206
  }
217
- await copyPath(src.path, finalTarget, {
218
- force: policy === "overwrite" || args.force,
207
+ const force = policy === "overwrite" || args.force || Boolean(decision.overwrite);
208
+ const shouldRender = Boolean(step.render &&
209
+ (() => {
210
+ const rel = path.basename(src.path).replaceAll("\\", "/");
211
+ const include = picomatch(step.render.include, { dot: true });
212
+ const exclude = step.render.exclude?.length
213
+ ? picomatch(step.render.exclude, { dot: true })
214
+ : null;
215
+ return include(rel) && !exclude?.(rel);
216
+ })());
217
+ await copyFileMaybeRender(src.path, finalTarget, {
218
+ ctx: args.ctx,
219
+ force,
220
+ render: shouldRender,
219
221
  });
220
222
  }
221
223
  break;
222
224
  }
225
+ case "move": {
226
+ const from = resolveTargetPath(args.ctx.outDir, interpolate(step.from, args.ctx));
227
+ const to = resolveTargetPath(args.ctx.outDir, interpolate(step.to, args.ctx));
228
+ if (args.dryRun) {
229
+ args.log(`move ${from} -> ${to}`);
230
+ break;
231
+ }
232
+ if (!(await pathExists(from))) {
233
+ throw new Error(`Move source does not exist: ${from}`);
234
+ }
235
+ const policy = effectiveConflictPolicy(args);
236
+ const decision = await handleFileConflict({
237
+ policy,
238
+ target: to,
239
+ defaults: args.defaults,
240
+ });
241
+ if (!decision.proceed) {
242
+ args.log(`skip move ${from} -> ${to} (exists)`);
243
+ break;
244
+ }
245
+ if (decision.overwrite && (await pathExists(to))) {
246
+ await fs.rm(to, { recursive: true, force: true });
247
+ }
248
+ await ensureDir(path.dirname(to));
249
+ await movePath(from, to);
250
+ break;
251
+ }
223
252
  case "merge": {
224
253
  const target = resolveTargetPath(args.ctx.outDir, interpolate(step.target, args.ctx));
225
254
  const src = await resolveSource(interpolate(step.source, args.ctx));
@@ -258,6 +287,26 @@ async function executeSteps(args) {
258
287
  }
259
288
  }
260
289
  }
290
+ async function movePath(from, to) {
291
+ try {
292
+ await fs.rename(from, to);
293
+ return;
294
+ }
295
+ catch (error) {
296
+ const code = error?.code;
297
+ if (code !== "EXDEV")
298
+ throw error;
299
+ }
300
+ // Cross-device fallback.
301
+ const stat = await fs.stat(from);
302
+ if (stat.isDirectory()) {
303
+ await fs.cp(from, to, { recursive: true, force: true });
304
+ await fs.rm(from, { recursive: true, force: true });
305
+ return;
306
+ }
307
+ await fs.copyFile(from, to);
308
+ await fs.rm(from, { force: true });
309
+ }
261
310
  function effectiveConflictPolicy(args) {
262
311
  // If user asked for no prompting, don't prompt on conflicts.
263
312
  if (args.defaults && args.conflict === "prompt")
@@ -267,36 +316,40 @@ function effectiveConflictPolicy(args) {
267
316
  async function handleFileConflict(args) {
268
317
  const exists = await pathExists(args.target);
269
318
  if (!exists)
270
- return true;
319
+ return { proceed: true, overwrite: false };
271
320
  switch (args.policy) {
272
321
  case "overwrite":
273
- return true;
322
+ return { proceed: true, overwrite: true };
274
323
  case "skip":
275
- return false;
324
+ return { proceed: false, overwrite: false };
276
325
  case "error":
277
326
  throw new Error(`Target exists: ${args.target}`);
278
327
  case "prompt":
279
- return await confirm({
328
+ return (await confirm({
280
329
  message: `Overwrite ${args.target}?`,
281
330
  default: false,
282
- });
331
+ }))
332
+ ? { proceed: true, overwrite: true }
333
+ : { proceed: false, overwrite: false };
283
334
  }
284
335
  }
285
336
  async function handleDirConflict(args) {
286
337
  const exists = await pathExists(args.target);
287
338
  if (!exists)
288
- return true;
339
+ return { proceed: true, overwrite: false };
289
340
  switch (args.policy) {
290
341
  case "overwrite":
291
- return true;
342
+ return { proceed: true, overwrite: true };
292
343
  case "skip":
293
- return false;
344
+ return { proceed: false, overwrite: false };
294
345
  case "error":
295
346
  throw new Error(`Target exists: ${args.target}`);
296
347
  case "prompt":
297
- return await confirm({
348
+ return (await confirm({
298
349
  message: `Directory exists. Merge/overwrite into ${args.target}?`,
299
350
  default: false,
300
- });
351
+ }))
352
+ ? { proceed: true, overwrite: true }
353
+ : { proceed: false, overwrite: false };
301
354
  }
302
355
  }
@@ -0,0 +1,20 @@
1
+ import type { TemplateContext } from "./template-engine.js";
2
+ export type RenderOptions = {
3
+ include: string[];
4
+ exclude?: string[];
5
+ };
6
+ export type CopyTransformOptions = {
7
+ ctx: TemplateContext;
8
+ force: boolean;
9
+ include?: string[];
10
+ exclude?: string[];
11
+ rename?: Record<string, string>;
12
+ render?: RenderOptions;
13
+ };
14
+ export declare function copyDirWithTransforms(sourceDir: string, targetDir: string, options: CopyTransformOptions): Promise<void>;
15
+ export declare function copyFileMaybeRender(sourceFile: string, targetFile: string, options: {
16
+ ctx: TemplateContext;
17
+ force: boolean;
18
+ render: boolean;
19
+ }): Promise<void>;
20
+ export declare function applyRenameToBasename(basename: string, rename: Record<string, string> | undefined, ctx: TemplateContext): string;
@@ -0,0 +1,134 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import picomatch from "picomatch";
4
+ import { ensureDir } from "./fs-ops.js";
5
+ import { interpolate } from "./template-engine.js";
6
+ export async function copyDirWithTransforms(sourceDir, targetDir, options) {
7
+ const includeMatcher = options.include?.length
8
+ ? picomatch(options.include, { dot: true })
9
+ : null;
10
+ const excludeMatcher = options.exclude?.length
11
+ ? picomatch(options.exclude, { dot: true })
12
+ : null;
13
+ const renderMatcher = options.render
14
+ ? {
15
+ include: picomatch(options.render.include, { dot: true }),
16
+ exclude: options.render.exclude?.length
17
+ ? picomatch(options.render.exclude, { dot: true })
18
+ : null,
19
+ }
20
+ : null;
21
+ const renamePairs = normalizeRename(options.rename, options.ctx);
22
+ async function walk(currentSourceDir) {
23
+ const entries = await fs.readdir(currentSourceDir, { withFileTypes: true });
24
+ for (const entry of entries) {
25
+ const srcPath = path.join(currentSourceDir, entry.name);
26
+ const relNative = path.relative(sourceDir, srcPath);
27
+ const rel = relNative.replaceAll(path.sep, "/");
28
+ // Exclude directories early to avoid traversing large trees.
29
+ if (entry.isDirectory() && excludeMatcher && excludeMatcher(`${rel}/x`)) {
30
+ continue;
31
+ }
32
+ if (entry.isDirectory()) {
33
+ await walk(srcPath);
34
+ continue;
35
+ }
36
+ if (entry.isFile()) {
37
+ if (excludeMatcher?.(rel))
38
+ continue;
39
+ if (includeMatcher && !includeMatcher(rel))
40
+ continue;
41
+ const renamedRel = validateRelativePath(applyRename(rel, renamePairs));
42
+ const dstPath = path.join(targetDir, ...renamedRel.split("/"));
43
+ const shouldRender = Boolean(renderMatcher?.include(rel) && !renderMatcher.exclude?.(rel));
44
+ await copyFileMaybeRender(srcPath, dstPath, {
45
+ ctx: options.ctx,
46
+ force: options.force,
47
+ render: Boolean(shouldRender),
48
+ });
49
+ continue;
50
+ }
51
+ // For now, ignore other filesystem entry types (symlinks, sockets, etc)
52
+ throw new Error(`Unsupported entry type in template source: ${srcPath}`);
53
+ }
54
+ }
55
+ await walk(sourceDir);
56
+ }
57
+ export async function copyFileMaybeRender(sourceFile, targetFile, options) {
58
+ await ensureDir(path.dirname(targetFile));
59
+ if (!options.force) {
60
+ // Match old behavior: fail if target exists.
61
+ await fs.access(targetFile).then(() => {
62
+ throw new Error(`Target exists: ${targetFile}`);
63
+ }, () => undefined);
64
+ }
65
+ if (!options.render) {
66
+ await fs.copyFile(sourceFile, targetFile);
67
+ return;
68
+ }
69
+ const buffer = Buffer.from(await fs.readFile(sourceFile));
70
+ const text = decodeUtf8TextOrNull(buffer);
71
+ if (text == null) {
72
+ // Binary-safe behavior: copy bytes unchanged.
73
+ await fs.writeFile(targetFile, buffer);
74
+ return;
75
+ }
76
+ const rendered = interpolate(text, options.ctx);
77
+ await fs.writeFile(targetFile, rendered, "utf8");
78
+ }
79
+ function decodeUtf8TextOrNull(buffer) {
80
+ // Cheap binary heuristic: NUL bytes are extremely uncommon in text files.
81
+ if (buffer.includes(0))
82
+ return null;
83
+ try {
84
+ const decoder = new TextDecoder("utf-8", { fatal: true });
85
+ return decoder.decode(buffer);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ function normalizeRename(rename, ctx) {
92
+ if (!rename)
93
+ return [];
94
+ const pairs = [];
95
+ for (const [token, replacementRaw] of Object.entries(rename)) {
96
+ const replacement = interpolate(replacementRaw, ctx);
97
+ if (!token)
98
+ continue;
99
+ pairs.push([token, replacement]);
100
+ }
101
+ // Apply longer tokens first to reduce surprising partial overlaps.
102
+ pairs.sort((a, b) => b[0].length - a[0].length);
103
+ return pairs;
104
+ }
105
+ function applyRename(input, pairs) {
106
+ let out = input;
107
+ for (const [token, replacement] of pairs) {
108
+ out = out.split(token).join(replacement);
109
+ }
110
+ return out;
111
+ }
112
+ export function applyRenameToBasename(basename, rename, ctx) {
113
+ const pairs = normalizeRename(rename, ctx);
114
+ const renamed = applyRename(basename, pairs);
115
+ if (renamed.includes("/") || renamed.includes("\\")) {
116
+ throw new Error(`Invalid rename result for basename (must not include path separators): ${renamed}`);
117
+ }
118
+ if (renamed.includes("\u0000")) {
119
+ throw new Error("Invalid rename result for basename (NUL byte)");
120
+ }
121
+ return renamed;
122
+ }
123
+ function validateRelativePath(rel) {
124
+ const cleaned = rel.replaceAll("\\", "/");
125
+ if (cleaned.startsWith("/"))
126
+ throw new Error(`Invalid relative path: ${rel}`);
127
+ if (cleaned.includes("\u0000"))
128
+ throw new Error("Invalid relative path (NUL byte)");
129
+ const parts = cleaned.split("/");
130
+ if (parts.some((p) => p === "..")) {
131
+ throw new Error(`Invalid relative path (path traversal): ${rel}`);
132
+ }
133
+ return cleaned;
134
+ }
@@ -19,7 +19,7 @@ export type TemplateQuestion = {
19
19
  }>;
20
20
  when?: Condition;
21
21
  };
22
- export type TemplateStep = CopyStep | MergeStep | EnvironmentStep | CommandStep;
22
+ export type TemplateStep = CopyStep | MergeStep | EnvironmentStep | CommandStep | MoveStep;
23
23
  type StepBase = {
24
24
  when?: Condition;
25
25
  };
@@ -27,7 +27,18 @@ export type CopyStep = StepBase & {
27
27
  type: "copy";
28
28
  source: string;
29
29
  target: string;
30
+ include?: string[];
30
31
  exclude?: string[];
32
+ rename?: Record<string, string>;
33
+ render?: {
34
+ include: string[];
35
+ exclude?: string[];
36
+ };
37
+ };
38
+ export type MoveStep = StepBase & {
39
+ type: "move";
40
+ from: string;
41
+ to: string;
31
42
  };
32
43
  export type MergeStep = StepBase & {
33
44
  type: "merge";
@@ -90,5 +90,5 @@
90
90
  ]
91
91
  }
92
92
  },
93
- "version": "1.3.0"
93
+ "version": "1.4.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.3.0",
4
+ "version": "1.4.0",
5
5
  "author": "Ben Di Giorgio",
6
6
  "bin": {
7
7
  "harbor-templater": "./bin/run.js"