@oss-ma/tpl 1.0.5 → 1.0.24

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.
Files changed (50) hide show
  1. package/dist/commands/check.js +87 -9
  2. package/dist/commands/init.js +1 -0
  3. package/dist/engine/copy.js +52 -2
  4. package/dist/engine/hooks.js +103 -6
  5. package/dist/engine/initProject.js +4 -4
  6. package/dist/engine/loadTemplate.js +1 -0
  7. package/dist/engine/lock.js +66 -1
  8. package/dist/engine/packs/applyPackFiles.js +16 -5
  9. package/dist/engine/packs/validatePackRules.js +58 -115
  10. package/dist/engine/prompt.js +1 -0
  11. package/dist/engine/readLock.js +16 -0
  12. package/dist/engine/render.js +1 -0
  13. package/dist/engine/validators/reactTs.js +70 -3
  14. package/dist/engine/validators/standard.js +1 -0
  15. package/dist/engine/validators/template.js +1 -0
  16. package/package.json +36 -36
  17. package/resources/packs/company-pack-a/files/.github/badges/company-a.svg +5 -5
  18. package/resources/packs/company-pack-a/files/README.company.md +14 -14
  19. package/resources/packs/company-pack-a/files/SECURITY.md +19 -19
  20. package/resources/packs/company-pack-a/pack.yaml +28 -28
  21. package/resources/packs/company-pack-a/rules/rules.json +11 -11
  22. package/resources/standard.schema.json +31 -28
  23. package/resources/templates/react-ts/files/.editorconfig +9 -9
  24. package/resources/templates/react-ts/files/.gitattributes +1 -1
  25. package/resources/templates/react-ts/files/.github/dependabot.yml +12 -12
  26. package/resources/templates/react-ts/files/.github/workflows/ci.yml +297 -94
  27. package/resources/templates/react-ts/files/.github/workflows/codeql.yml +38 -37
  28. package/resources/templates/react-ts/files/.husky/commit-msg +4 -4
  29. package/resources/templates/react-ts/files/.husky/pre-commit +6 -6
  30. package/resources/templates/react-ts/files/.prettierrc.json +4 -4
  31. package/resources/templates/react-ts/files/README.md +51 -51
  32. package/resources/templates/react-ts/files/SECURITY.md +23 -0
  33. package/resources/templates/react-ts/files/commitlint.config.cjs +2 -2
  34. package/resources/templates/react-ts/files/eslint.config.js +67 -67
  35. package/resources/templates/react-ts/files/gitignore +8 -8
  36. package/resources/templates/react-ts/files/index.html +39 -11
  37. package/resources/templates/react-ts/files/package.json +55 -55
  38. package/resources/templates/react-ts/files/src/app/App.js +3 -3
  39. package/resources/templates/react-ts/files/src/app/App.tsx +9 -9
  40. package/resources/templates/react-ts/files/src/app/main.js +2 -2
  41. package/resources/templates/react-ts/files/src/app/main.tsx +8 -8
  42. package/resources/templates/react-ts/files/src/features/example/Example.js +4 -4
  43. package/resources/templates/react-ts/files/src/features/example/Example.tsx +13 -13
  44. package/resources/templates/react-ts/files/src/shared/ui/Button.js +2 -2
  45. package/resources/templates/react-ts/files/src/shared/ui/Button.tsx +19 -19
  46. package/resources/templates/react-ts/files/template.lock +8 -8
  47. package/resources/templates/react-ts/files/tsconfig.json +20 -20
  48. package/resources/templates/react-ts/files/tsconfig.node.json +9 -9
  49. package/resources/templates/react-ts/files/vite.config.ts +5 -5
  50. package/resources/templates/react-ts/template.yaml +60 -20
@@ -1,10 +1,74 @@
1
+ // cli/src/commands/check.ts
1
2
  import { Command } from "commander";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import fs from "fs-extra";
2
6
  import pc from "picocolors";
3
7
  import { loadStandardSchema, checkAgainstStandard } from "../engine/validators/standard.js";
4
8
  import { readTemplateLock } from "../engine/readLock.js";
5
9
  import { validateReactTs } from "../engine/validators/reactTs.js";
6
10
  import { loadPack } from "../engine/packs/loadPack.js";
7
11
  import { validatePackRules } from "../engine/packs/validatePackRules.js";
12
+ ;
13
+ // ── Integrity helpers
14
+ async function hashFile(filePath) {
15
+ const content = await fs.readFile(filePath);
16
+ return crypto.createHash("sha256").update(content).digest("hex");
17
+ }
18
+ async function collectFiles(dir) {
19
+ const entries = await fs.readdir(dir, { withFileTypes: true });
20
+ const results = [];
21
+ for (const entry of entries) {
22
+ const full = path.join(dir, entry.name);
23
+ if (entry.isDirectory())
24
+ results.push(...(await collectFiles(full)));
25
+ else if (entry.isFile())
26
+ results.push(full);
27
+ }
28
+ return results.sort();
29
+ }
30
+ async function verifyIntegrity(projectPath, manifest) {
31
+ const issues = [];
32
+ const abs = path.resolve(projectPath);
33
+ for (const [rel, expectedHash] of Object.entries(manifest)) {
34
+ const filePath = path.join(abs, rel);
35
+ if (!(await fs.pathExists(filePath))) {
36
+ issues.push({
37
+ code: "INTEGRITY_MISSING_FILE",
38
+ message: `File was deleted after generation: ${rel}`,
39
+ path: rel,
40
+ hint: "Restore the file or re-generate the project"
41
+ });
42
+ continue;
43
+ }
44
+ const actualHash = await hashFile(filePath);
45
+ if (actualHash !== expectedHash) {
46
+ issues.push({
47
+ code: "INTEGRITY_MODIFIED_FILE",
48
+ message: `File modified after generation: ${rel}`,
49
+ path: rel,
50
+ hint: "If intentional, re-run init to update template.lock"
51
+ });
52
+ }
53
+ }
54
+ const allFiles = await collectFiles(abs);
55
+ const manifestKeys = new Set(Object.keys(manifest));
56
+ for (const filePath of allFiles) {
57
+ const rel = path.relative(abs, filePath).replace(/\\/g, "/");
58
+ if (rel === "template.lock")
59
+ continue;
60
+ if (!manifestKeys.has(rel)) {
61
+ issues.push({
62
+ code: "INTEGRITY_ADDED_FILE",
63
+ message: `File added after generation: ${rel}`,
64
+ path: rel,
65
+ hint: "Expected for normal development. Re-run init to update template.lock if needed"
66
+ });
67
+ }
68
+ }
69
+ return issues;
70
+ }
71
+ // ── Output ───────────────────────────────────────────────────────────────────
8
72
  function printHuman(result) {
9
73
  if (result.ok) {
10
74
  console.log(pc.green(`✅ OK — Standard v${result.schemaVersion}`));
@@ -22,9 +86,11 @@ function printHuman(result) {
22
86
  console.log("");
23
87
  console.log(pc.yellow(`Total issues: ${result.issues.length}`));
24
88
  }
89
+ // ── Command ──────────────────────────────────────────────────────────────────
25
90
  export const checkCommand = new Command("check")
26
91
  .option("--path <path>", "Project path", ".")
27
92
  .option("--json", "JSON output")
93
+ .option("--skip-integrity", "Skip template.lock integrity verification")
28
94
  .description("Check a project against the standard")
29
95
  .action(async (opts) => {
30
96
  try {
@@ -33,21 +99,24 @@ export const checkCommand = new Command("check")
33
99
  const standardResult = await checkAgainstStandard(opts.path, schema);
34
100
  // 2) Lock
35
101
  const lock = await readTemplateLock(standardResult.projectPath);
36
- // 3) Template-specific
102
+ // 3) Integrity
103
+ let integrityIssues = [];
104
+ if (!opts.skipIntegrity && lock?.filesIntegrity) {
105
+ integrityIssues = await verifyIntegrity(standardResult.projectPath, lock.filesIntegrity);
106
+ }
107
+ // 4) Template-specific
37
108
  let templateIssues = [];
38
109
  if (lock?.template === "react-ts") {
39
110
  templateIssues = await validateReactTs(standardResult.projectPath);
40
111
  }
41
- // 4) Packs
112
+ // 5) Packs
42
113
  let packIssues = [];
43
114
  if (lock?.packs?.length) {
44
115
  for (const packName of lock.packs) {
45
116
  const pack = await loadPack(packName);
46
117
  const issues = await validatePackRules(pack, standardResult.projectPath);
47
- // enforcement (strict vs advisory)
48
118
  const level = pack.spec.enforcement?.level ?? "strict";
49
119
  if (level === "advisory") {
50
- // On garde l’info mais on ne bloque pas (on tag en WARNING)
51
120
  packIssues.push(...issues.map((i) => ({
52
121
  ...i,
53
122
  code: `PACK_WARNING:${i.code}`,
@@ -59,13 +128,22 @@ export const checkCommand = new Command("check")
59
128
  }
60
129
  }
61
130
  }
62
- // 5) Combine
63
- const issues = [...standardResult.issues, ...templateIssues, ...packIssues];
64
- // advisory warnings don't fail build
131
+ // 6) Combine
132
+ const blockingIntegrityIssues = integrityIssues.filter((i) => i.code !== "INTEGRITY_ADDED_FILE");
133
+ const advisoryIntegrityIssues = integrityIssues.filter((i) => i.code === "INTEGRITY_ADDED_FILE");
65
134
  const blockingPackIssues = packIssues.filter((i) => !String(i.code).startsWith("PACK_WARNING:"));
66
- const ok = standardResult.issues.length + templateIssues.length + blockingPackIssues.length === 0;
135
+ const issues = [
136
+ ...standardResult.issues,
137
+ ...blockingIntegrityIssues,
138
+ ...advisoryIntegrityIssues,
139
+ ...templateIssues,
140
+ ...packIssues
141
+ ];
142
+ const ok = standardResult.issues.length +
143
+ blockingIntegrityIssues.length +
144
+ templateIssues.length +
145
+ blockingPackIssues.length === 0;
67
146
  const result = { ...standardResult, issues, ok };
68
- // 6) Output + exit
69
147
  if (opts.json)
70
148
  console.log(JSON.stringify(result, null, 2));
71
149
  else
@@ -1,3 +1,4 @@
1
+ // cli/src/commands/init.ts
1
2
  import { Command } from "commander";
2
3
  import { initProject } from "../engine/initProject.js";
3
4
  function nowVars() {
@@ -1,6 +1,10 @@
1
+ // cli/src/engine/copy.ts
1
2
  import path from "node:path";
2
3
  import fs from "fs-extra";
3
4
  import { renderString } from "./render.js";
5
+ /**
6
+ * Extensions treated as text files (template rendering applies).
7
+ */
4
8
  const TEXT_EXT = new Set([
5
9
  ".ts",
6
10
  ".tsx",
@@ -20,7 +24,10 @@ const TEXT_EXT = new Set([
20
24
  ".cjs",
21
25
  ".mjs"
22
26
  ]);
23
- // Files that ship without dot, but must be generated as dotfiles.
27
+ /**
28
+ * Files that ship without dot but must be generated as dotfiles.
29
+ * (npm packaging limitation workaround)
30
+ */
24
31
  const DOTFILE_MAP = {
25
32
  "gitignore": ".gitignore",
26
33
  "editorconfig": ".editorconfig",
@@ -31,11 +38,37 @@ const DOTFILE_MAP = {
31
38
  "nvmrc": ".nvmrc",
32
39
  "node-version": ".node-version",
33
40
  };
41
+ /**
42
+ * Prevent path traversal: ensure target stays inside baseDir.
43
+ */
44
+ function safeJoin(baseDir, ...parts) {
45
+ const base = path.resolve(baseDir) + path.sep;
46
+ const target = path.resolve(baseDir, ...parts);
47
+ if (!target.startsWith(base)) {
48
+ throw new Error(`Path traversal detected: ${target}`);
49
+ }
50
+ return target;
51
+ }
52
+ /**
53
+ * Reject symlinks to prevent escaping the project directory.
54
+ */
55
+ async function assertNoSymlink(p) {
56
+ const st = await fs.lstat(p);
57
+ if (st.isSymbolicLink()) {
58
+ throw new Error(`Symlink not allowed in template/pack: ${p}`);
59
+ }
60
+ }
61
+ /**
62
+ * Map template filename → destination filename (dotfiles handling).
63
+ */
34
64
  function mapDestName(name) {
35
65
  if (name.startsWith("."))
36
66
  return name;
37
67
  return DOTFILE_MAP[name] ?? name;
38
68
  }
69
+ /**
70
+ * Detect whether a file should be treated as text.
71
+ */
39
72
  function isTextFile(filePath) {
40
73
  const ext = path.extname(filePath).toLowerCase();
41
74
  if (TEXT_EXT.has(ext))
@@ -56,7 +89,16 @@ function isTextFile(filePath) {
56
89
  return true;
57
90
  return false;
58
91
  }
92
+ /**
93
+ * Copy a single file with rendering if applicable.
94
+ * - No symlinks
95
+ * - No overwrite
96
+ */
59
97
  async function copyOne(srcPath, destPath, vars) {
98
+ await assertNoSymlink(srcPath);
99
+ if (await fs.pathExists(destPath)) {
100
+ throw new Error(`Refusing to overwrite existing file: ${destPath}`);
101
+ }
60
102
  await fs.ensureDir(path.dirname(destPath));
61
103
  if (isTextFile(srcPath)) {
62
104
  const raw = await fs.readFile(srcPath, "utf8");
@@ -67,13 +109,21 @@ async function copyOne(srcPath, destPath, vars) {
67
109
  await fs.copyFile(srcPath, destPath);
68
110
  }
69
111
  }
112
+ /**
113
+ * Recursively copy and render a directory.
114
+ * Security guarantees:
115
+ * - No path traversal
116
+ * - No symlinks
117
+ * - No overwrite
118
+ */
70
119
  export async function copyAndRenderDir(srcDir, destDir, vars) {
71
120
  await fs.ensureDir(destDir);
72
121
  const entries = await fs.readdir(srcDir, { withFileTypes: true });
73
122
  for (const entry of entries) {
74
123
  const srcPath = path.join(srcDir, entry.name);
124
+ await assertNoSymlink(srcPath);
75
125
  const destName = mapDestName(entry.name);
76
- const destPath = path.join(destDir, destName);
126
+ const destPath = safeJoin(destDir, destName);
77
127
  if (entry.isDirectory()) {
78
128
  await copyAndRenderDir(srcPath, destPath, vars);
79
129
  continue;
@@ -1,13 +1,110 @@
1
+ // cli/src/engine/hooks.ts
1
2
  import { execa } from "execa";
2
- export async function runHooks(cmds, cwd) {
3
- if (!cmds?.length)
3
+ /**
4
+ * Safely parse a command string into [executable, ...args].
5
+ * Handles simple quoted strings but does NOT support pipes, redirects,
6
+ * or subshells — intentionally restricted to prevent injection.
7
+ *
8
+ * Examples:
9
+ * "npm ci --ignore-scripts" → ["npm", "ci", "--ignore-scripts"]
10
+ * "git init" → ["git", "init"]
11
+ */
12
+ export function parseCommand(cmd) {
13
+ const args = [];
14
+ let current = "";
15
+ let inSingle = false;
16
+ let inDouble = false;
17
+ for (let i = 0; i < cmd.length; i++) {
18
+ const ch = cmd[i];
19
+ if (ch === "'" && !inDouble) {
20
+ inSingle = !inSingle;
21
+ }
22
+ else if (ch === '"' && !inSingle) {
23
+ inDouble = !inDouble;
24
+ }
25
+ else if (ch === " " && !inSingle && !inDouble) {
26
+ if (current.length > 0) {
27
+ args.push(current);
28
+ current = "";
29
+ }
30
+ }
31
+ else {
32
+ current += ch;
33
+ }
34
+ }
35
+ if (current.length > 0)
36
+ args.push(current);
37
+ if (args.length === 0) {
38
+ throw new Error(`Empty command after parsing: "${cmd}"`);
39
+ }
40
+ // Security: reject shell metacharacters that should never appear
41
+ const FORBIDDEN = /[;&|`$<>\\]/;
42
+ for (const arg of args) {
43
+ if (FORBIDDEN.test(arg)) {
44
+ throw new Error(`Forbidden shell metacharacter in hook command: "${arg}" (from: "${cmd}")`);
45
+ }
46
+ }
47
+ return args;
48
+ }
49
+ /**
50
+ * Evaluate a `when` condition of the form:
51
+ * "{{resolvedValue}} == expectedValue"
52
+ *
53
+ * The vars have already been substituted before this function is called,
54
+ * so `condition` looks like: "npm == npm" or "pnpm == npm".
55
+ */
56
+ function evaluateWhen(condition) {
57
+ const m = condition.match(/^(.+?)\s*==\s*(.+)$/);
58
+ if (!m) {
59
+ throw new Error(`Invalid when condition format: "${condition}". Expected: "value == value"`);
60
+ }
61
+ return m[1].trim() === m[2].trim();
62
+ }
63
+ /**
64
+ * Resolve variables in a string using the vars map.
65
+ */
66
+ function resolveVars(input, vars) {
67
+ return input.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
68
+ const v = vars[key];
69
+ return v !== undefined ? String(v) : `{{${key}}}`;
70
+ });
71
+ }
72
+ export async function runHooks(hooks, cwd, vars = {}) {
73
+ if (!hooks?.length)
4
74
  return;
5
- for (const cmd of cmds) {
6
- // simple shell execution (cross-platform)
7
- await execa(cmd, {
75
+ for (const hook of hooks) {
76
+ // Support both legacy string[] and new Hook[] formats
77
+ const raw = typeof hook === "string" ? { run: hook } : hook;
78
+ // Resolve variables in `when` condition and evaluate it
79
+ if (raw.when !== undefined) {
80
+ const resolvedWhen = resolveVars(raw.when, vars);
81
+ if (!evaluateWhen(resolvedWhen)) {
82
+ continue; // skip this hook
83
+ }
84
+ }
85
+ // Resolve variables in the command itself
86
+ const resolvedCmd = resolveVars(raw.run, vars);
87
+ // Parse safely — no shell: true
88
+ const [bin, ...args] = parseCommand(resolvedCmd);
89
+ await execa(bin, args, {
8
90
  cwd,
9
- shell: true,
91
+ shell: false, // ← explicit: never use shell
10
92
  stdio: "inherit"
11
93
  });
12
94
  }
13
95
  }
96
+ // import { execa } from "execa";
97
+ // export async function runHooks(
98
+ // cmds: string[] | undefined,
99
+ // cwd: string
100
+ // ): Promise<void> {
101
+ // if (!cmds?.length) return;
102
+ // for (const cmd of cmds) {
103
+ // // simple shell execution (cross-platform)
104
+ // await execa(cmd, {
105
+ // cwd,
106
+ // shell: true,
107
+ // stdio: "inherit"
108
+ // });
109
+ // }
110
+ // }
@@ -50,7 +50,7 @@ export async function initProject(params) {
50
50
  await applyPackFiles(pack, destDir, vars);
51
51
  appliedPacks.push(pack.spec.name);
52
52
  }
53
- // 3) Write template.lock (includes packs)
53
+ // 3) Write template.lock (includes packs + file integrity hashes)
54
54
  await writeTemplateLock({
55
55
  destDir,
56
56
  template: spec.name,
@@ -59,10 +59,10 @@ export async function initProject(params) {
59
59
  packs: appliedPacks,
60
60
  generatedAt: sys.isoDate
61
61
  });
62
- // 4) Hooks
62
+ // 4) Hooks — pass vars so `when` conditions can be evaluated
63
63
  if (!params.noHooks) {
64
- const cmds = spec.hooks?.postGenerate?.map((h) => h.run.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`));
65
- await runHooks(cmds, destDir);
64
+ const hooks = (spec.hooks?.postGenerate ?? []).map((h) => typeof h === "string" ? { run: h } : h);
65
+ await runHooks(hooks, destDir, vars);
66
66
  }
67
67
  return { destDir };
68
68
  }
@@ -1,3 +1,4 @@
1
+ // cli/src/engine/loadTemplate.ts
1
2
  import path from "node:path";
2
3
  import fs from "fs-extra";
3
4
  import YAML from "yaml";
@@ -1,13 +1,58 @@
1
+ // cli/src/engine/lock.ts
1
2
  import path from "node:path";
3
+ import crypto from "node:crypto";
2
4
  import fs from "fs-extra";
5
+ /**
6
+ * Recursively collect all file paths under a directory.
7
+ */
8
+ async function collectFiles(dir) {
9
+ const entries = await fs.readdir(dir, { withFileTypes: true });
10
+ const results = [];
11
+ for (const entry of entries) {
12
+ const full = path.join(dir, entry.name);
13
+ if (entry.isDirectory()) {
14
+ results.push(...(await collectFiles(full)));
15
+ }
16
+ else if (entry.isFile()) {
17
+ results.push(full);
18
+ }
19
+ }
20
+ return results.sort(); // deterministic order
21
+ }
22
+ /**
23
+ * Compute SHA-256 of a single file's content.
24
+ */
25
+ async function hashFile(filePath) {
26
+ const content = await fs.readFile(filePath);
27
+ return crypto.createHash("sha256").update(content).digest("hex");
28
+ }
29
+ /**
30
+ * Compute a manifest of { relativePath -> sha256 } for all files in destDir.
31
+ * Excludes template.lock itself to avoid circular dependency.
32
+ */
33
+ export async function computeFilesHash(destDir) {
34
+ const abs = path.resolve(destDir);
35
+ const files = await collectFiles(abs);
36
+ const manifest = {};
37
+ for (const filePath of files) {
38
+ const rel = path.relative(abs, filePath).replace(/\\/g, "/");
39
+ if (rel === "template.lock")
40
+ continue; // exclude lock file itself
41
+ manifest[rel] = await hashFile(filePath);
42
+ }
43
+ return manifest;
44
+ }
3
45
  export async function writeTemplateLock(params) {
4
46
  const lockPath = path.join(params.destDir, "template.lock");
47
+ // Compute integrity manifest of all generated files
48
+ const filesIntegrity = await computeFilesHash(params.destDir);
5
49
  const data = {
6
50
  template: params.template,
7
51
  version: params.version,
8
52
  options: params.options,
9
53
  packs: params.packs ?? [],
10
- generatedAt: params.generatedAt
54
+ generatedAt: params.generatedAt,
55
+ filesIntegrity
11
56
  };
12
57
  await fs.writeFile(lockPath, JSON.stringify(data, null, 2) + "\n", "utf8");
13
58
  }
@@ -20,6 +65,26 @@ export async function writeTemplateLock(params) {
20
65
  // options: Record<string, string>;
21
66
  // packs?: string[];
22
67
  // generatedAt: string;
68
+ // }): Promise<void> {
69
+ // const lockPath = path.join(params.destDir, "template.lock");
70
+ // const data = {
71
+ // template: params.template,
72
+ // version: params.version,
73
+ // options: params.options,
74
+ // packs: params.packs ?? [],
75
+ // generatedAt: params.generatedAt
76
+ // };
77
+ // await fs.writeFile(lockPath, JSON.stringify(data, null, 2) + "\n", "utf8");
78
+ // }
79
+ // import path from "node:path";
80
+ // import fs from "fs-extra";
81
+ // export async function writeTemplateLock(params: {
82
+ // destDir: string;
83
+ // template: string;
84
+ // version: string;
85
+ // options: Record<string, string>;
86
+ // packs?: string[];
87
+ // generatedAt: string;
23
88
  // }) {
24
89
  // const lockPath = path.join(params.destDir, "template.lock");
25
90
  // const data = {
@@ -1,12 +1,27 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import { copyAndRenderDir } from "../copy.js";
4
+ function safeJoin(baseDir, ...parts) {
5
+ const base = path.resolve(baseDir) + path.sep;
6
+ const target = path.resolve(baseDir, ...parts);
7
+ if (!target.startsWith(base)) {
8
+ throw new Error(`Path traversal detected: ${target}`);
9
+ }
10
+ return target;
11
+ }
12
+ async function assertNoSymlink(p) {
13
+ const st = await fs.lstat(p);
14
+ if (st.isSymbolicLink()) {
15
+ throw new Error(`Symlink not allowed in pack output: ${p}`);
16
+ }
17
+ }
4
18
  async function mergeDir(srcDir, destDir, packName) {
5
19
  await fs.ensureDir(destDir);
6
20
  const entries = await fs.readdir(srcDir, { withFileTypes: true });
7
21
  for (const e of entries) {
8
22
  const src = path.join(srcDir, e.name);
9
- const dest = path.join(destDir, e.name);
23
+ await assertNoSymlink(src);
24
+ const dest = safeJoin(destDir, e.name);
10
25
  if (e.isDirectory()) {
11
26
  if (await fs.pathExists(dest)) {
12
27
  const stat = await fs.stat(dest);
@@ -19,7 +34,6 @@ async function mergeDir(srcDir, destDir, packName) {
19
34
  }
20
35
  if (e.isFile()) {
21
36
  if (await fs.pathExists(dest)) {
22
- // Conflict only if the *same file path* already exists
23
37
  throw new Error(`Pack "${packName}" conflict: file already exists: ${path.relative(destDir, dest)}`);
24
38
  }
25
39
  await fs.copyFile(src, dest);
@@ -35,13 +49,10 @@ export async function applyPackFiles(pack, projectDir, vars) {
35
49
  await fs.remove(tmpDir);
36
50
  await fs.ensureDir(tmpDir);
37
51
  try {
38
- // Render pack files into tmpDir
39
52
  await copyAndRenderDir(pack.filesDir, tmpDir, vars);
40
- // Merge tmpDir into projectDir (directories are merged, files must not overwrite)
41
53
  await mergeDir(tmpDir, projectDir, pack.spec.name);
42
54
  }
43
55
  finally {
44
- // Always cleanup (even if we throw on conflict)
45
56
  await fs.remove(tmpRoot);
46
57
  }
47
58
  }