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 +2 -2
- package/dist/commands/init/index.js +90 -37
- package/dist/lib/copy-transform.d.ts +20 -0
- package/dist/lib/copy-transform.js +134 -0
- package/dist/lib/template-json.d.ts +12 -1
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
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.
|
|
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.
|
|
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 {
|
|
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
|
|
174
|
+
const decision = await handleDirConflict({
|
|
174
175
|
policy,
|
|
175
|
-
source: step.source,
|
|
176
176
|
target,
|
|
177
177
|
defaults: args.defaults,
|
|
178
178
|
});
|
|
179
|
-
if (!
|
|
179
|
+
if (!decision.proceed) {
|
|
180
180
|
args.log(`skip copy ${step.source} -> ${target} (exists)`);
|
|
181
181
|
break;
|
|
182
182
|
}
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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 (!
|
|
203
|
+
if (!decision.proceed) {
|
|
214
204
|
args.log(`skip copy ${step.source} -> ${finalTarget} (exists)`);
|
|
215
205
|
break;
|
|
216
206
|
}
|
|
217
|
-
|
|
218
|
-
|
|
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";
|
package/oclif.manifest.json
CHANGED