@oss-ma/tpl 1.0.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.
@@ -0,0 +1,83 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { loadStandardSchema, checkAgainstStandard } from "../engine/validators/standard.js";
4
+ import { readTemplateLock } from "../engine/readLock.js";
5
+ import { validateReactTs } from "../engine/validators/reactTs.js";
6
+ import { loadPack } from "../engine/packs/loadPack.js";
7
+ import { validatePackRules } from "../engine/packs/validatePackRules.js";
8
+ function printHuman(result) {
9
+ if (result.ok) {
10
+ console.log(pc.green(`✅ OK — Standard v${result.schemaVersion}`));
11
+ console.log(`Project: ${result.projectPath}`);
12
+ return;
13
+ }
14
+ console.log(pc.red(`❌ FAILED — Standard v${result.schemaVersion}`));
15
+ console.log(`Project: ${result.projectPath}`);
16
+ console.log("");
17
+ for (const issue of result.issues) {
18
+ console.log(`${pc.red("•")} ${issue.message}${issue.path ? pc.gray(` (${issue.path})`) : ""}`);
19
+ if (issue.hint)
20
+ console.log(` ${pc.gray("hint:")} ${issue.hint}`);
21
+ }
22
+ console.log("");
23
+ console.log(pc.yellow(`Total issues: ${result.issues.length}`));
24
+ }
25
+ export const checkCommand = new Command("check")
26
+ .option("--path <path>", "Project path", ".")
27
+ .option("--json", "JSON output")
28
+ .description("Check a project against the standard")
29
+ .action(async (opts) => {
30
+ try {
31
+ // 1) Standard
32
+ const { schema } = await loadStandardSchema();
33
+ const standardResult = await checkAgainstStandard(opts.path, schema);
34
+ // 2) Lock
35
+ const lock = await readTemplateLock(standardResult.projectPath);
36
+ // 3) Template-specific
37
+ let templateIssues = [];
38
+ if (lock?.template === "react-ts") {
39
+ templateIssues = await validateReactTs(standardResult.projectPath);
40
+ }
41
+ // 4) Packs
42
+ let packIssues = [];
43
+ if (lock?.packs?.length) {
44
+ for (const packName of lock.packs) {
45
+ const pack = await loadPack(packName);
46
+ const issues = await validatePackRules(pack, standardResult.projectPath);
47
+ // enforcement (strict vs advisory)
48
+ const level = pack.spec.enforcement?.level ?? "strict";
49
+ if (level === "advisory") {
50
+ // On garde l’info mais on ne bloque pas (on tag en WARNING)
51
+ packIssues.push(...issues.map((i) => ({
52
+ ...i,
53
+ code: `PACK_WARNING:${i.code}`,
54
+ message: `${i.message} (advisory)`
55
+ })));
56
+ }
57
+ else {
58
+ packIssues.push(...issues);
59
+ }
60
+ }
61
+ }
62
+ // 5) Combine
63
+ const issues = [...standardResult.issues, ...templateIssues, ...packIssues];
64
+ // advisory warnings don't fail build
65
+ const blockingPackIssues = packIssues.filter((i) => !String(i.code).startsWith("PACK_WARNING:"));
66
+ const ok = standardResult.issues.length + templateIssues.length + blockingPackIssues.length === 0;
67
+ const result = { ...standardResult, issues, ok };
68
+ // 6) Output + exit
69
+ if (opts.json)
70
+ console.log(JSON.stringify(result, null, 2));
71
+ else
72
+ printHuman(result);
73
+ process.exit(result.ok ? 0 : 1);
74
+ }
75
+ catch (err) {
76
+ const msg = err?.message ?? String(err);
77
+ if (opts.json)
78
+ console.log(JSON.stringify({ ok: false, error: msg }, null, 2));
79
+ else
80
+ console.error(pc.red("❌ Error:"), msg);
81
+ process.exit(2);
82
+ }
83
+ });
@@ -0,0 +1,96 @@
1
+ import { Command } from "commander";
2
+ import { initProject } from "../engine/initProject.js";
3
+ function nowVars() {
4
+ const d = new Date();
5
+ const isoDate = d.toISOString();
6
+ const date = isoDate.slice(0, 10);
7
+ const year = String(d.getUTCFullYear());
8
+ return { isoDate, date, year };
9
+ }
10
+ export const initCommand = new Command("init")
11
+ .argument("<template>", "Template name (e.g. react-ts)")
12
+ .argument("<dir>", "Destination directory")
13
+ .option("--no-hooks", "Do not run post-generate hooks")
14
+ .option("--yes", "Use defaults, no prompts")
15
+ .option("--pack <name...>", "Apply one or more packs")
16
+ .option("--force", "Overwrite destination directory if not empty")
17
+ .description("Generate a new project from a template")
18
+ .action(async (templateName, dir, opts) => {
19
+ const res = await initProject({
20
+ templateName,
21
+ destDir: dir,
22
+ packs: opts.pack,
23
+ noHooks: !opts.hooks,
24
+ yes: !!opts.yes,
25
+ force: !!opts.force
26
+ });
27
+ console.log("\n✅ Project generated");
28
+ console.log(`- Path: ${res.destDir}`);
29
+ });
30
+ // import path from "node:path";
31
+ // import fs from "fs-extra";
32
+ // import { Command } from "commander";
33
+ // import { loadTemplate } from "../engine/loadTemplate.js";
34
+ // import { askQuestions } from "../engine/prompt.js";
35
+ // import { copyAndRenderDir } from "../engine/copy.js";
36
+ // import { writeTemplateLock } from "../engine/lock.js";
37
+ // import { runHooks } from "../engine/hooks.js";
38
+ // import { initProject } from "../engine/initProject.js";
39
+ // function nowVars() {
40
+ // const d = new Date();
41
+ // const isoDate = d.toISOString();
42
+ // const date = isoDate.slice(0, 10);
43
+ // const year = String(d.getUTCFullYear());
44
+ // return { isoDate, date, year };
45
+ // }
46
+ // export const initCommand = new Command("init")
47
+ // .argument("<template>", "Template name (e.g. react-ts)")
48
+ // .argument("<dir>", "Destination directory")
49
+ // .option("--no-hooks", "Do not run post-generate hooks")
50
+ // .option("--yes", "Use defaults, no prompts")
51
+ // .description("Generate a new project from a template")
52
+ // .action(async (templateName: string, dir: string, opts: { hooks: boolean; yes?: boolean }) => {
53
+ // const { spec, filesDir } = await loadTemplate(templateName);
54
+ // const answers = await askQuestions(spec.questions, { yes: opts.yes });
55
+ // const pm = answers.packageManager || "npm";
56
+ // // system vars
57
+ // const sys = nowVars();
58
+ // const vars: Record<string, string> = {
59
+ // ...answers,
60
+ // ...sys,
61
+ // templateName: spec.name,
62
+ // templateVersion: spec.version
63
+ // };
64
+ // const destDir = path.resolve(process.cwd(), dir);
65
+ // if (await fs.pathExists(destDir)) {
66
+ // const content = await fs.readdir(destDir);
67
+ // if (content.length > 0) {
68
+ // throw new Error(`Destination not empty: ${destDir}`);
69
+ // }
70
+ // }
71
+ // await fs.ensureDir(destDir);
72
+ // // copy + render template files
73
+ // await copyAndRenderDir(filesDir, destDir, vars);
74
+ // // force lock (overwrites any template.lock from files/)
75
+ // await writeTemplateLock({
76
+ // destDir,
77
+ // template: spec.name,
78
+ // version: spec.version,
79
+ // options: answers,
80
+ // generatedAt: sys.isoDate
81
+ // });
82
+ // // hooks
83
+ // if (opts.hooks) {
84
+ // const cmds = spec.hooks?.postGenerate?.map((h) => {
85
+ // // allow {{var}} in hook command
86
+ // return h.run.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
87
+ // });
88
+ // await runHooks(cmds, destDir);
89
+ // }
90
+ // console.log("\n✅ Project generated");
91
+ // console.log(`- Template: ${spec.name}@${spec.version}`);
92
+ // console.log(`- Path: ${destDir}`);
93
+ // console.log("\nNext:");
94
+ // console.log(` cd ${dir}`);
95
+ // console.log(` ${pm} run dev`);
96
+ // });
@@ -0,0 +1,55 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import { renderString } from "./render.js";
4
+ const TEXT_EXT = new Set([
5
+ ".ts",
6
+ ".tsx",
7
+ ".js",
8
+ ".jsx",
9
+ ".json",
10
+ ".md",
11
+ ".yml",
12
+ ".yaml",
13
+ ".html",
14
+ ".css",
15
+ ".scss",
16
+ ".txt",
17
+ ".env",
18
+ ".gitignore",
19
+ ".editorconfig",
20
+ ".gitattributes",
21
+ ".cjs",
22
+ ".mjs"
23
+ ]);
24
+ function isTextFile(filePath) {
25
+ const ext = path.extname(filePath).toLowerCase();
26
+ if (TEXT_EXT.has(ext))
27
+ return true;
28
+ // Special dotfiles with no extension
29
+ const base = path.basename(filePath);
30
+ if (base === "Dockerfile")
31
+ return true;
32
+ return false;
33
+ }
34
+ export async function copyAndRenderDir(srcDir, destDir, vars) {
35
+ await fs.ensureDir(destDir);
36
+ const entries = await fs.readdir(srcDir, { withFileTypes: true });
37
+ for (const e of entries) {
38
+ const srcPath = path.join(srcDir, e.name);
39
+ const destPath = path.join(destDir, e.name);
40
+ if (e.isDirectory()) {
41
+ await copyAndRenderDir(srcPath, destPath, vars);
42
+ continue;
43
+ }
44
+ if (e.isFile()) {
45
+ if (isTextFile(srcPath)) {
46
+ const raw = await fs.readFile(srcPath, "utf8");
47
+ const out = renderString(raw, vars);
48
+ await fs.outputFile(destPath, out, "utf8");
49
+ }
50
+ else {
51
+ await fs.copyFile(srcPath, destPath);
52
+ }
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,13 @@
1
+ import { execa } from "execa";
2
+ export async function runHooks(cmds, cwd) {
3
+ if (!cmds?.length)
4
+ return;
5
+ for (const cmd of cmds) {
6
+ // simple shell execution (cross-platform)
7
+ await execa(cmd, {
8
+ cwd,
9
+ shell: true,
10
+ stdio: "inherit"
11
+ });
12
+ }
13
+ }
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import { loadTemplate } from "./loadTemplate.js";
4
+ import { askQuestions } from "./prompt.js";
5
+ import { copyAndRenderDir } from "./copy.js";
6
+ import { writeTemplateLock } from "./lock.js";
7
+ import { runHooks } from "./hooks.js";
8
+ import { loadPack } from "./packs/loadPack.js";
9
+ import { applyPackFiles } from "./packs/applyPackFiles.js";
10
+ function nowVars() {
11
+ const d = new Date();
12
+ const isoDate = d.toISOString();
13
+ const date = isoDate.slice(0, 10);
14
+ const year = String(d.getUTCFullYear());
15
+ return { isoDate, date, year };
16
+ }
17
+ export async function initProject(params) {
18
+ const { spec, filesDir } = await loadTemplate(params.templateName);
19
+ const answers = await askQuestions(spec.questions, { yes: params.yes });
20
+ const sys = nowVars();
21
+ const vars = {
22
+ ...answers,
23
+ ...sys,
24
+ templateName: spec.name,
25
+ templateVersion: spec.version
26
+ };
27
+ const destDir = path.resolve(params.destDir);
28
+ // Destination handling
29
+ if (await fs.pathExists(destDir)) {
30
+ const content = await fs.readdir(destDir);
31
+ if (content.length > 0) {
32
+ if (params.force) {
33
+ await fs.emptyDir(destDir);
34
+ }
35
+ else {
36
+ throw new Error(`Destination not empty: ${destDir} (use --force)`);
37
+ }
38
+ }
39
+ }
40
+ await fs.ensureDir(destDir);
41
+ // 1) Copy + render template files
42
+ await copyAndRenderDir(filesDir, destDir, vars);
43
+ // 2) Apply packs (files + compatibility checks)
44
+ const appliedPacks = [];
45
+ for (const packName of params.packs ?? []) {
46
+ const pack = await loadPack(packName);
47
+ if (pack.spec.appliesTo?.templates && !pack.spec.appliesTo.templates.includes(spec.name)) {
48
+ throw new Error(`Pack "${packName}" not compatible with template "${spec.name}"`);
49
+ }
50
+ await applyPackFiles(pack, destDir, vars);
51
+ appliedPacks.push(pack.spec.name);
52
+ }
53
+ // 3) Write template.lock (includes packs)
54
+ await writeTemplateLock({
55
+ destDir,
56
+ template: spec.name,
57
+ version: spec.version,
58
+ options: answers,
59
+ packs: appliedPacks,
60
+ generatedAt: sys.isoDate
61
+ });
62
+ // 4) Hooks
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);
66
+ }
67
+ return { destDir };
68
+ }
@@ -0,0 +1,46 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import YAML from "yaml";
4
+ import { findUp } from "../utils/findUp.js";
5
+ export async function loadTemplate(templateName) {
6
+ const schemaPath = await findUp(process.cwd(), "standard.schema.json");
7
+ const repoRoot = schemaPath ? path.dirname(schemaPath) : null;
8
+ if (!repoRoot) {
9
+ throw new Error(`Cannot locate repo root (standard.schema.json not found upward from: ${process.cwd()})`);
10
+ }
11
+ const templateDir = path.join(repoRoot, "templates", templateName);
12
+ const templateYamlPath = path.join(templateDir, "template.yaml");
13
+ const filesDir = path.join(templateDir, "files");
14
+ if (!(await fs.pathExists(templateYamlPath))) {
15
+ throw new Error(`Template not found: ${templateYamlPath}`);
16
+ }
17
+ if (!(await fs.pathExists(filesDir))) {
18
+ throw new Error(`Template files folder not found: ${filesDir}`);
19
+ }
20
+ const raw = await fs.readFile(templateYamlPath, "utf8");
21
+ const spec = YAML.parse(raw);
22
+ if (!spec?.name || !spec?.version) {
23
+ throw new Error(`Invalid template.yaml: missing name/version (${templateYamlPath})`);
24
+ }
25
+ return { spec, templateDir, filesDir };
26
+ }
27
+ // export async function loadTemplate(templateName: string): Promise<LoadedTemplate> {
28
+ // // CLI is at: template-platform/cli
29
+ // // templates are at: template-platform/templates/<name>
30
+ // const repoRoot = path.resolve(process.cwd(), ".."); // assumes we run from cli/
31
+ // const templateDir = path.join(repoRoot, "templates", templateName);
32
+ // const templateYamlPath = path.join(templateDir, "template.yaml");
33
+ // const filesDir = path.join(templateDir, "files");
34
+ // if (!(await fs.pathExists(templateYamlPath))) {
35
+ // throw new Error(`Template not found: ${templateYamlPath}`);
36
+ // }
37
+ // if (!(await fs.pathExists(filesDir))) {
38
+ // throw new Error(`Template files folder not found: ${filesDir}`);
39
+ // }
40
+ // const raw = await fs.readFile(templateYamlPath, "utf8");
41
+ // const spec = YAML.parse(raw) as TemplateSpec;
42
+ // if (!spec?.name || !spec?.version) {
43
+ // throw new Error(`Invalid template.yaml: missing name/version (${templateYamlPath})`);
44
+ // }
45
+ // return { spec, templateDir, filesDir };
46
+ // }
@@ -0,0 +1,49 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ export async function writeTemplateLock(params) {
4
+ const lockPath = path.join(params.destDir, "template.lock");
5
+ const data = {
6
+ template: params.template,
7
+ version: params.version,
8
+ options: params.options,
9
+ packs: params.packs ?? [],
10
+ generatedAt: params.generatedAt
11
+ };
12
+ await fs.writeFile(lockPath, JSON.stringify(data, null, 2) + "\n", "utf8");
13
+ }
14
+ // import path from "node:path";
15
+ // import fs from "fs-extra";
16
+ // export async function writeTemplateLock(params: {
17
+ // destDir: string;
18
+ // template: string;
19
+ // version: string;
20
+ // options: Record<string, string>;
21
+ // packs?: string[];
22
+ // generatedAt: string;
23
+ // }) {
24
+ // const lockPath = path.join(params.destDir, "template.lock");
25
+ // const data = {
26
+ // template: params.template,
27
+ // version: params.version,
28
+ // options: params.options,
29
+ // packs: params.packs ?? [],
30
+ // generatedAt: params.generatedAt
31
+ // };
32
+ // await fs.writeFile(lockPath, JSON.stringify(data, null, 2) + "\n", "utf8");
33
+ // }
34
+ // // export async function writeTemplateLock(params: {
35
+ // // destDir: string;
36
+ // // template: string;
37
+ // // version: string;
38
+ // // options: Record<string, string>;
39
+ // // generatedAt: string;
40
+ // // }): Promise<void> {
41
+ // // const lockPath = path.join(params.destDir, "template.lock");
42
+ // // const data = {
43
+ // // template: params.template,
44
+ // // version: params.version,
45
+ // // options: params.options,
46
+ // // generatedAt: params.generatedAt
47
+ // // };
48
+ // // await fs.writeFile(lockPath, JSON.stringify(data, null, 2) + "\n", "utf8");
49
+ // // }
@@ -0,0 +1,47 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import { copyAndRenderDir } from "../copy.js";
4
+ async function mergeDir(srcDir, destDir, packName) {
5
+ await fs.ensureDir(destDir);
6
+ const entries = await fs.readdir(srcDir, { withFileTypes: true });
7
+ for (const e of entries) {
8
+ const src = path.join(srcDir, e.name);
9
+ const dest = path.join(destDir, e.name);
10
+ if (e.isDirectory()) {
11
+ if (await fs.pathExists(dest)) {
12
+ const stat = await fs.stat(dest);
13
+ if (!stat.isDirectory()) {
14
+ throw new Error(`Pack "${packName}" conflict: destination is a file: ${dest}`);
15
+ }
16
+ }
17
+ await mergeDir(src, dest, packName);
18
+ continue;
19
+ }
20
+ if (e.isFile()) {
21
+ if (await fs.pathExists(dest)) {
22
+ // Conflict only if the *same file path* already exists
23
+ throw new Error(`Pack "${packName}" conflict: file already exists: ${path.relative(destDir, dest)}`);
24
+ }
25
+ await fs.copyFile(src, dest);
26
+ continue;
27
+ }
28
+ }
29
+ }
30
+ export async function applyPackFiles(pack, projectDir, vars) {
31
+ if (!pack.filesDir)
32
+ return;
33
+ const tmpRoot = path.join(projectDir, ".pack-tmp");
34
+ const tmpDir = path.join(tmpRoot, pack.spec.name);
35
+ await fs.remove(tmpDir);
36
+ await fs.ensureDir(tmpDir);
37
+ try {
38
+ // Render pack files into tmpDir
39
+ await copyAndRenderDir(pack.filesDir, tmpDir, vars);
40
+ // Merge tmpDir into projectDir (directories are merged, files must not overwrite)
41
+ await mergeDir(tmpDir, projectDir, pack.spec.name);
42
+ }
43
+ finally {
44
+ // Always cleanup (even if we throw on conflict)
45
+ await fs.remove(tmpRoot);
46
+ }
47
+ }
@@ -0,0 +1,27 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import YAML from "yaml";
4
+ import { findUp } from "../../utils/findUp.js";
5
+ export async function loadPack(packName) {
6
+ // findUp returns the FULL path to the "packs" directory
7
+ const packsDir = await findUp(process.cwd(), "packs");
8
+ if (!packsDir) {
9
+ throw new Error(`Cannot locate "packs" directory (searched upward from: ${process.cwd()})`);
10
+ }
11
+ // packsDir already ends with ".../packs"
12
+ const packDir = path.join(packsDir, packName);
13
+ const packYaml = path.join(packDir, "pack.yaml");
14
+ if (!(await fs.pathExists(packYaml))) {
15
+ throw new Error(`Pack not found: ${packName} (expected: ${packYaml})`);
16
+ }
17
+ const raw = await fs.readFile(packYaml, "utf8");
18
+ const spec = YAML.parse(raw);
19
+ if (!spec?.name || !spec?.version) {
20
+ throw new Error(`Invalid pack.yaml (missing name/version): ${packYaml}`);
21
+ }
22
+ const filesCandidate = path.join(packDir, "files");
23
+ const rulesCandidate = path.join(packDir, "rules");
24
+ const filesDir = (await fs.pathExists(filesCandidate)) ? filesCandidate : null;
25
+ const rulesDir = (await fs.pathExists(rulesCandidate)) ? rulesCandidate : null;
26
+ return { spec, packDir, filesDir, rulesDir };
27
+ }
@@ -0,0 +1,141 @@
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+ /**
4
+ * Validate rules defined by a pack against a project
5
+ */
6
+ export async function validatePackRules(pack, projectDir) {
7
+ const issues = [];
8
+ if (!pack.rulesDir)
9
+ return issues;
10
+ const rulesFile = path.join(pack.rulesDir, "rules.json");
11
+ if (!(await fs.pathExists(rulesFile)))
12
+ return issues;
13
+ const rules = JSON.parse(await fs.readFile(rulesFile, "utf8"));
14
+ /**
15
+ * 1️⃣ Required files
16
+ */
17
+ if (Array.isArray(rules.requiredFiles)) {
18
+ for (const file of rules.requiredFiles) {
19
+ const target = path.join(projectDir, file);
20
+ if (!(await fs.pathExists(target))) {
21
+ issues.push({
22
+ code: "PACK_MISSING_FILE",
23
+ message: `[${pack.spec.name}] Missing required file: ${file}`,
24
+ path: file
25
+ });
26
+ }
27
+ }
28
+ }
29
+ /**
30
+ * 2️⃣ Required npm scripts
31
+ */
32
+ if (Array.isArray(rules.requiredScripts)) {
33
+ const pkgPath = path.join(projectDir, "package.json");
34
+ if (await fs.pathExists(pkgPath)) {
35
+ const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
36
+ const scripts = pkg.scripts ?? {};
37
+ for (const script of rules.requiredScripts) {
38
+ if (!scripts[script]) {
39
+ issues.push({
40
+ code: "PACK_MISSING_SCRIPT",
41
+ message: `[${pack.spec.name}] Missing required npm script: "${script}"`,
42
+ path: "package.json"
43
+ });
44
+ }
45
+ }
46
+ }
47
+ }
48
+ /**
49
+ * 3️⃣ Forbidden patterns (simple text scan)
50
+ * V1 intentionally simple → AST later if needed
51
+ */
52
+ if (Array.isArray(rules.forbiddenPatterns)) {
53
+ const srcDir = path.join(projectDir, "src");
54
+ if (await fs.pathExists(srcDir)) {
55
+ const files = await fs.readdir(srcDir, { recursive: true });
56
+ for (const file of files) {
57
+ if (typeof file !== "string" ||
58
+ (!file.endsWith(".ts") && !file.endsWith(".tsx"))) {
59
+ continue;
60
+ }
61
+ const absPath = path.join(srcDir, file);
62
+ const content = await fs.readFile(absPath, "utf8");
63
+ for (const pattern of rules.forbiddenPatterns) {
64
+ if (content.includes(pattern)) {
65
+ issues.push({
66
+ code: "PACK_FORBIDDEN_PATTERN",
67
+ message: `[${pack.spec.name}] Forbidden pattern "${pattern}" found`,
68
+ path: file
69
+ });
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return issues;
76
+ }
77
+ // import fs from "fs-extra";
78
+ // import path from "node:path";
79
+ // import type { CheckIssue } from "../validators/standard.js";
80
+ // import type { LoadedPack } from "./loadPack.js";
81
+ // export async function validatePackRules(
82
+ // pack: LoadedPack,
83
+ // projectDir: string
84
+ // ): Promise<CheckIssue[]> {
85
+ // const issues: CheckIssue[] = [];
86
+ // if (!pack.rulesDir) return issues;
87
+ // const rulesFile = path.join(pack.rulesDir, "rules.json");
88
+ // if (!(await fs.pathExists(rulesFile))) return issues;
89
+ // const rules = JSON.parse(await fs.readFile(rulesFile, "utf8"));
90
+ // // Required files
91
+ // if (rules.requiredFiles) {
92
+ // for (const f of rules.requiredFiles) {
93
+ // if (!(await fs.pathExists(path.join(projectDir, f)))) {
94
+ // issues.push({
95
+ // code: "PACK_MISSING_FILE",
96
+ // message: `[${pack.spec.name}] Missing required file: ${f}`,
97
+ // path: f
98
+ // });
99
+ // }
100
+ // }
101
+ // }
102
+ // // Required scripts
103
+ // if (rules.requiredScripts) {
104
+ // const pkgPath = path.join(projectDir, "package.json");
105
+ // if (await fs.pathExists(pkgPath)) {
106
+ // const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
107
+ // const scripts = pkg.scripts ?? {};
108
+ // for (const s of rules.requiredScripts) {
109
+ // if (!scripts[s]) {
110
+ // issues.push({
111
+ // code: "PACK_MISSING_SCRIPT",
112
+ // message: `[${pack.spec.name}] Missing required script: "${s}"`,
113
+ // path: "package.json"
114
+ // });
115
+ // }
116
+ // }
117
+ // }
118
+ // }
119
+ // // Forbidden patterns (simple scan)
120
+ // if (rules.forbiddenPatterns) {
121
+ // const srcDir = path.join(projectDir, "src");
122
+ // if (await fs.pathExists(srcDir)) {
123
+ // const files = await fs.readdir(srcDir, { recursive: true });
124
+ // for (const f of files) {
125
+ // // On ne scanne que les fichiers TS / TSX
126
+ // if (typeof f !== "string" || (!f.endsWith(".ts") && !f.endsWith(".tsx"))) continue;
127
+ // const content = await fs.readFile(path.join(srcDir, f), "utf8");
128
+ // for (const pattern of rules.forbiddenPatterns) {
129
+ // if (content.includes(pattern)) {
130
+ // issues.push({
131
+ // code: "PACK_FORBIDDEN_PATTERN",
132
+ // message: `[${pack.spec.name}] Forbidden pattern "${pattern}" found`,
133
+ // path: f
134
+ // });
135
+ // }
136
+ // }
137
+ // }
138
+ // }
139
+ // }
140
+ // return issues;
141
+ // }
@@ -0,0 +1,66 @@
1
+ import prompts from "prompts";
2
+ export async function askQuestions(questions, opts = {}) {
3
+ if (!questions?.length)
4
+ return {};
5
+ // --yes : aucune interaction, on prend les defaults
6
+ if (opts.yes) {
7
+ const answers = {};
8
+ for (const q of questions)
9
+ answers[q.name] = String(q.default ?? "");
10
+ return answers;
11
+ }
12
+ const defs = questions.map((q) => {
13
+ const base = {
14
+ name: q.name,
15
+ message: q.message,
16
+ initial: q.default
17
+ };
18
+ if (q.choices?.length) {
19
+ return { type: "select", ...base, choices: q.choices.map((c) => ({ title: c, value: c })) };
20
+ }
21
+ return { type: "text", ...base };
22
+ });
23
+ // Sur certains terminaux Windows, prompts peut déclencher onCancel inopinément.
24
+ // Stratégie: si cancel -> fallback sur defaults au lieu de crash.
25
+ const res = (await prompts(defs, {
26
+ onCancel: () => true
27
+ }));
28
+ const out = {};
29
+ for (const q of questions) {
30
+ const v = res[q.name];
31
+ out[q.name] = v !== undefined && v !== null && String(v).length > 0 ? String(v) : String(q.default ?? "");
32
+ }
33
+ return out;
34
+ }
35
+ // import prompts from "prompts";
36
+ // import type { TemplateQuestion } from "./loadTemplate.js";
37
+ // export async function askQuestions(
38
+ // questions: TemplateQuestion[] | undefined,
39
+ // opts: { yes?: boolean } = {}
40
+ // ): Promise<Record<string, string>> {
41
+ // if (!questions?.length) return {};
42
+ // if (opts.yes) {
43
+ // const answers: Record<string, string> = {};
44
+ // for (const q of questions) {
45
+ // answers[q.name] = String(q.default ?? "");
46
+ // }
47
+ // return answers;
48
+ // }
49
+ // const defs = questions.map((q) => {
50
+ // const base: any = {
51
+ // name: q.name,
52
+ // message: q.message,
53
+ // initial: q.default
54
+ // };
55
+ // if (q.choices?.length) {
56
+ // return { type: "select", ...base, choices: q.choices.map((c) => ({ title: c, value: c })) };
57
+ // }
58
+ // return { type: "text", ...base };
59
+ // });
60
+ // const res = await prompts(defs, {
61
+ // onCancel: () => {
62
+ // throw new Error("Cancelled by user.");
63
+ // }
64
+ // });
65
+ // return res as Record<string, string>;
66
+ // }
@@ -0,0 +1,9 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ export async function readTemplateLock(projectPath) {
4
+ const p = path.join(projectPath, "template.lock");
5
+ if (!(await fs.pathExists(p)))
6
+ return null;
7
+ const raw = await fs.readFile(p, "utf8");
8
+ return JSON.parse(raw);
9
+ }
@@ -0,0 +1,6 @@
1
+ export function renderString(input, vars) {
2
+ return input.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
3
+ const v = vars[key];
4
+ return v !== undefined ? String(v) : `{{${key}}}`; // keep unresolved visible
5
+ });
6
+ }
@@ -0,0 +1,44 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ async function readJson(p) {
4
+ const raw = await fs.readFile(p, "utf8");
5
+ return JSON.parse(raw);
6
+ }
7
+ export async function validateReactTs(projectPath) {
8
+ const issues = [];
9
+ // package.json scripts
10
+ const pkgPath = path.join(projectPath, "package.json");
11
+ if (!(await fs.pathExists(pkgPath))) {
12
+ issues.push({ code: "MISSING_FILE", message: "Missing package.json", path: "package.json" });
13
+ return issues;
14
+ }
15
+ const pkg = await readJson(pkgPath);
16
+ const scripts = pkg?.scripts ?? {};
17
+ const requiredScripts = ["lint", "format", "test", "build", "typecheck"];
18
+ for (const s of requiredScripts) {
19
+ if (!scripts[s]) {
20
+ issues.push({
21
+ code: "MISSING_SCRIPT",
22
+ message: `Missing npm script: "${s}"`,
23
+ path: "package.json",
24
+ hint: `Add it under scripts: "${s}": "..."`
25
+ });
26
+ }
27
+ }
28
+ // Gold Secure files
29
+ const secureFiles = [
30
+ ".github/dependabot.yml",
31
+ ".github/workflows/codeql.yml"
32
+ ];
33
+ for (const f of secureFiles) {
34
+ const p = path.join(projectPath, f);
35
+ if (!(await fs.pathExists(p))) {
36
+ issues.push({
37
+ code: "MISSING_SECURE_FILE",
38
+ message: `Missing security file: ${f}`,
39
+ path: f
40
+ });
41
+ }
42
+ }
43
+ return issues;
44
+ }
@@ -0,0 +1,127 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import { findUp } from "../../utils/findUp.js";
4
+ async function fileExists(p) {
5
+ try {
6
+ const stat = await fs.stat(p);
7
+ return stat.isFile();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ async function dirExists(p) {
14
+ try {
15
+ const stat = await fs.stat(p);
16
+ return stat.isDirectory();
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function extractMarkdownHeadings(md) {
23
+ // Extract headings like: ## Overview
24
+ const lines = md.split(/\r?\n/);
25
+ const headings = [];
26
+ for (const line of lines) {
27
+ const m = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/);
28
+ if (m)
29
+ headings.push(m[1].trim());
30
+ }
31
+ return headings;
32
+ }
33
+ // export async function loadStandardSchema(): Promise<{ schema: StandardSchema; schemaPath: string }> {
34
+ // // CLI lives in template-platform/cli
35
+ // // standard.schema.json lives in template-platform/standard.schema.json
36
+ // const repoRoot = path.resolve(process.cwd(), "..");
37
+ // const schemaPath = path.join(repoRoot, "standard.schema.json");
38
+ // if (!(await fileExists(schemaPath))) {
39
+ // throw new Error(`standard.schema.json not found at: ${schemaPath}`);
40
+ // }
41
+ // const raw = await fs.readFile(schemaPath, "utf8");
42
+ // const schema = JSON.parse(raw) as StandardSchema;
43
+ // if (!schema?.version || !Array.isArray(schema.requiredFiles) || !Array.isArray(schema.requiredDirs)) {
44
+ // throw new Error(`Invalid standard.schema.json: ${schemaPath}`);
45
+ // }
46
+ // return { schema, schemaPath };
47
+ // }
48
+ export async function loadStandardSchema() {
49
+ const schemaPath = await findUp(process.cwd(), "standard.schema.json");
50
+ if (!schemaPath) {
51
+ throw new Error(`standard.schema.json not found (searched upward from: ${process.cwd()})`);
52
+ }
53
+ const raw = await fs.readFile(schemaPath, "utf8");
54
+ const schema = JSON.parse(raw);
55
+ if (!schema?.version || !Array.isArray(schema.requiredFiles) || !Array.isArray(schema.requiredDirs)) {
56
+ throw new Error(`Invalid standard.schema.json: ${schemaPath}`);
57
+ }
58
+ return { schema, schemaPath };
59
+ }
60
+ export async function checkAgainstStandard(projectPath, schema) {
61
+ const issues = [];
62
+ const abs = path.resolve(projectPath);
63
+ // Required files
64
+ for (const rel of schema.requiredFiles ?? []) {
65
+ const p = path.join(abs, rel);
66
+ if (!(await fileExists(p))) {
67
+ issues.push({
68
+ code: "MISSING_FILE",
69
+ message: `Missing required file: ${rel}`,
70
+ path: rel
71
+ });
72
+ }
73
+ }
74
+ // Required dirs
75
+ for (const rel of schema.requiredDirs ?? []) {
76
+ const p = path.join(abs, rel);
77
+ if (!(await dirExists(p))) {
78
+ issues.push({
79
+ code: "MISSING_DIR",
80
+ message: `Missing required directory: ${rel}`,
81
+ path: rel
82
+ });
83
+ }
84
+ }
85
+ // Required ADR files
86
+ for (const rel of schema.requiredAdrFiles ?? []) {
87
+ const p = path.join(abs, rel);
88
+ if (!(await fileExists(p))) {
89
+ issues.push({
90
+ code: "MISSING_ADR",
91
+ message: `Missing ADR file: ${rel}`,
92
+ path: rel
93
+ });
94
+ }
95
+ }
96
+ // README headings
97
+ const readmePath = path.join(abs, "README.md");
98
+ if (await fileExists(readmePath)) {
99
+ const md = await fs.readFile(readmePath, "utf8");
100
+ const headings = extractMarkdownHeadings(md).map((h) => h.toLowerCase());
101
+ for (const required of schema.readmeRequiredHeadings ?? []) {
102
+ const ok = headings.includes(required.toLowerCase());
103
+ if (!ok) {
104
+ issues.push({
105
+ code: "README_MISSING_HEADING",
106
+ message: `README missing heading: "${required}"`,
107
+ path: "README.md",
108
+ hint: `Add a markdown heading like: "## ${required}"`
109
+ });
110
+ }
111
+ }
112
+ }
113
+ else {
114
+ // (already flagged as missing file, but add a hint)
115
+ issues.push({
116
+ code: "README_UNREADABLE",
117
+ message: "README.md could not be read",
118
+ path: "README.md"
119
+ });
120
+ }
121
+ return {
122
+ ok: issues.length === 0,
123
+ schemaVersion: schema.version,
124
+ projectPath: abs,
125
+ issues
126
+ };
127
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { initCommand } from "./commands/init.js";
4
+ import { checkCommand } from "./commands/check.js";
5
+ const program = new Command();
6
+ program
7
+ .name("tpl")
8
+ .description("Template Platform CLI")
9
+ .version("0.1.0");
10
+ program.addCommand(initCommand);
11
+ program.addCommand(checkCommand);
12
+ program.parse(process.argv);
@@ -0,0 +1,53 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import path from "node:path";
4
+ import fs from "fs-extra";
5
+ import { initProject } from "../engine/initProject.js";
6
+ import { loadStandardSchema, checkAgainstStandard } from "../engine/validators/standard.js";
7
+ import { readTemplateLock } from "../engine/readLock.js";
8
+ import { validateReactTs } from "../engine/validators/reactTs.js";
9
+ async function exists(p) {
10
+ return fs.pathExists(p);
11
+ }
12
+ test("smoke: init react-ts then check passes", async () => {
13
+ const tmpRoot = path.join(process.cwd(), ".tmp-tests");
14
+ const appDir = path.join(tmpRoot, "app-ok");
15
+ await fs.remove(tmpRoot);
16
+ await fs.ensureDir(tmpRoot);
17
+ await initProject({
18
+ templateName: "react-ts",
19
+ destDir: appDir,
20
+ noHooks: true,
21
+ yes: true
22
+ });
23
+ // required files quick check
24
+ assert.ok(await exists(path.join(appDir, "README.md")));
25
+ assert.ok(await exists(path.join(appDir, "template.lock")));
26
+ assert.ok(await exists(path.join(appDir, ".github", "workflows", "ci.yml")));
27
+ const { schema } = await loadStandardSchema();
28
+ const standard = await checkAgainstStandard(appDir, schema);
29
+ assert.equal(standard.ok, true, JSON.stringify(standard.issues, null, 2));
30
+ const lock = await readTemplateLock(appDir);
31
+ assert.ok(lock);
32
+ assert.equal(lock.template, "react-ts");
33
+ const extra = await validateReactTs(appDir);
34
+ assert.equal(extra.length, 0, JSON.stringify(extra, null, 2));
35
+ });
36
+ test("smoke: check fails when required file is missing", async () => {
37
+ const tmpRoot = path.join(process.cwd(), ".tmp-tests");
38
+ const appDir = path.join(tmpRoot, "app-ko");
39
+ await fs.remove(tmpRoot);
40
+ await fs.ensureDir(tmpRoot);
41
+ await initProject({
42
+ templateName: "react-ts",
43
+ destDir: appDir,
44
+ noHooks: true,
45
+ yes: true
46
+ });
47
+ // break it
48
+ await fs.remove(path.join(appDir, "README.md"));
49
+ const { schema } = await loadStandardSchema();
50
+ const standard = await checkAgainstStandard(appDir, schema);
51
+ assert.equal(standard.ok, false);
52
+ assert.ok(standard.issues.some((i) => i.code === "MISSING_FILE" && i.path === "README.md"));
53
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ export async function findUp(startDir, relPath) {
4
+ let cur = path.resolve(startDir);
5
+ while (true) {
6
+ const candidate = path.join(cur, relPath);
7
+ if (await fs.pathExists(candidate))
8
+ return candidate;
9
+ const parent = path.dirname(cur);
10
+ if (parent === cur)
11
+ return null; // reached filesystem root
12
+ cur = parent;
13
+ }
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@oss-ma/tpl",
3
+ "version": "1.0.0",
4
+ "description": "Generate, enforce and maintain clean project architectures",
5
+ "type": "module",
6
+ "bin": {
7
+ "tpl": "./dist/index.js"
8
+ },
9
+ "files": ["dist"],
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "scripts": {
14
+ "dev": "tsx src/index.ts",
15
+ "build": "tsc -p tsconfig.json",
16
+ "test:smoke": "node --test dist/tests/smoke.test.js"
17
+ },
18
+ "dependencies": {
19
+ "commander": "^12.1.0",
20
+ "execa": "^9.3.0",
21
+ "fs-extra": "^11.2.0",
22
+ "picocolors": "^1.0.0",
23
+ "prompts": "^2.4.2",
24
+ "yaml": "^2.5.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/fs-extra": "^11.0.4",
28
+ "@types/node": "^22.7.0",
29
+ "@types/prompts": "^2.4.9",
30
+ "tsx": "^4.16.2",
31
+ "typescript": "^5.5.4"
32
+ }
33
+ }