@oss-ma/tpl 1.0.5 → 1.0.25
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/dist/commands/check.js +87 -9
- package/dist/commands/init.js +1 -0
- package/dist/engine/copy.js +52 -2
- package/dist/engine/hooks.js +103 -6
- package/dist/engine/initProject.js +4 -4
- package/dist/engine/loadTemplate.js +1 -0
- package/dist/engine/lock.js +66 -1
- package/dist/engine/packs/applyPackFiles.js +16 -5
- package/dist/engine/packs/validatePackRules.js +58 -115
- package/dist/engine/prompt.js +1 -0
- package/dist/engine/readLock.js +16 -0
- package/dist/engine/render.js +1 -0
- package/dist/engine/validators/reactTs.js +70 -3
- package/dist/engine/validators/standard.js +1 -0
- package/dist/engine/validators/template.js +1 -0
- package/package.json +36 -36
- package/resources/packs/company-pack-a/files/.github/badges/company-a.svg +5 -5
- package/resources/packs/company-pack-a/files/README.company.md +14 -14
- package/resources/packs/company-pack-a/files/SECURITY.md +19 -19
- package/resources/packs/company-pack-a/pack.yaml +28 -28
- package/resources/packs/company-pack-a/rules/rules.json +11 -11
- package/resources/standard.schema.json +31 -28
- package/resources/templates/react-ts/files/.editorconfig +9 -9
- package/resources/templates/react-ts/files/.gitattributes +1 -1
- package/resources/templates/react-ts/files/.github/dependabot.yml +12 -12
- package/resources/templates/react-ts/files/.github/workflows/ci.yml +297 -94
- package/resources/templates/react-ts/files/.github/workflows/codeql.yml +38 -37
- package/resources/templates/react-ts/files/.husky/commit-msg +4 -4
- package/resources/templates/react-ts/files/.husky/pre-commit +6 -6
- package/resources/templates/react-ts/files/.prettierrc.json +4 -4
- package/resources/templates/react-ts/files/README.md +51 -51
- package/resources/templates/react-ts/files/SECURITY.md +23 -0
- package/resources/templates/react-ts/files/commitlint.config.cjs +2 -2
- package/resources/templates/react-ts/files/eslint.config.js +67 -67
- package/resources/templates/react-ts/files/gitignore +8 -8
- package/resources/templates/react-ts/files/index.html +39 -11
- package/resources/templates/react-ts/files/package.json +55 -55
- package/resources/templates/react-ts/files/src/app/App.js +3 -3
- package/resources/templates/react-ts/files/src/app/App.tsx +9 -9
- package/resources/templates/react-ts/files/src/app/main.js +2 -2
- package/resources/templates/react-ts/files/src/app/main.tsx +8 -8
- package/resources/templates/react-ts/files/src/features/example/Example.js +4 -4
- package/resources/templates/react-ts/files/src/features/example/Example.tsx +13 -13
- package/resources/templates/react-ts/files/src/shared/ui/Button.js +2 -2
- package/resources/templates/react-ts/files/src/shared/ui/Button.tsx +19 -19
- package/resources/templates/react-ts/files/template.lock +8 -8
- package/resources/templates/react-ts/files/tsconfig.json +20 -20
- package/resources/templates/react-ts/files/tsconfig.node.json +9 -9
- package/resources/templates/react-ts/files/vite.config.ts +5 -5
- package/resources/templates/react-ts/template.yaml +60 -20
package/dist/commands/check.js
CHANGED
|
@@ -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)
|
|
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
|
-
//
|
|
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
|
-
//
|
|
63
|
-
const
|
|
64
|
-
|
|
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
|
|
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
|
package/dist/commands/init.js
CHANGED
package/dist/engine/copy.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
126
|
+
const destPath = safeJoin(destDir, destName);
|
|
77
127
|
if (entry.isDirectory()) {
|
|
78
128
|
await copyAndRenderDir(srcPath, destPath, vars);
|
|
79
129
|
continue;
|
package/dist/engine/hooks.js
CHANGED
|
@@ -1,13 +1,110 @@
|
|
|
1
|
+
// cli/src/engine/hooks.ts
|
|
1
2
|
import { execa } from "execa";
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
6
|
-
//
|
|
7
|
-
|
|
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:
|
|
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
|
|
65
|
-
await runHooks(
|
|
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
|
}
|
package/dist/engine/lock.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|