Initial commit

This commit is contained in:
fraqtal
2026-07-12 08:15:46 +00:00
commit ee0fec0691
1397 changed files with 127242 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Throws with a clear message if any required anchor is missing from the target
* file. Generator actions call this in their prompt validator so the run aborts
* cleanly before any partial output lands on disk.
*/
export function assertAnchors(
repoRoot: string,
relativePath: string,
anchors: string[],
): void {
const path = join(repoRoot, relativePath);
if (!existsSync(path)) {
throw new Error(`Required file does not exist: ${relativePath}`);
}
const content = readFileSync(path, "utf8");
for (const anchor of anchors) {
if (!content.includes(anchor)) {
throw new Error(
`Missing anchor "${anchor}" in ${relativePath}. Add it before running this generator.`,
);
}
}
}

View File

@@ -0,0 +1,146 @@
import { describe, it, expect } from "vitest";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
readFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
assertOptionalPackageNotPresent,
addToTranspilePackages,
splicePluginRulesAt,
addBoundariesEntry,
emitTemplateTree,
} from "./core-package-utils.js";
describe("assertOptionalPackageNotPresent", () => {
it("throws if packages/<name>/ exists in cwd", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
mkdirSync(join(tmp, "packages", "core-foo"), { recursive: true });
expect(() => assertOptionalPackageNotPresent("core-foo", tmp)).toThrow(/already exists/);
});
it("returns silently if packages/<name>/ is absent", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
expect(() => assertOptionalPackageNotPresent("core-foo", tmp)).not.toThrow();
});
});
describe("addToTranspilePackages", () => {
it("inserts package name alphabetically into transpilePackages array", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const cfgPath = join(tmp, "next.config.mjs");
writeFileSync(
cfgPath,
`const nextConfig = {
transpilePackages: [
"@repo/core-cms",
"@repo/core-shared",
],
};
`,
);
addToTranspilePackages(cfgPath, "@repo/core-realtime");
const result = readFileSync(cfgPath, "utf8");
// Order should be: core-cms, core-realtime, core-shared
const order = result.match(/@repo\/core-\w+/g) ?? [];
expect(order).toEqual(["@repo/core-cms", "@repo/core-realtime", "@repo/core-shared"]);
});
it("is idempotent — duplicate insertion is a no-op", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const cfgPath = join(tmp, "next.config.mjs");
writeFileSync(
cfgPath,
`const nextConfig = {
transpilePackages: ["@repo/core-realtime"],
};
`,
);
addToTranspilePackages(cfgPath, "@repo/core-realtime");
const result = readFileSync(cfgPath, "utf8");
expect(result.match(/@repo\/core-realtime/g)?.length).toBe(1);
});
});
describe("splicePluginRulesAt", () => {
it("inserts rule block at the named anchor", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const path = join(tmp, "base.js");
writeFileSync(
path,
`before\n// <gen:realtime-rules>\nafter\n`,
);
splicePluginRulesAt(path, "realtime-rules", "INSERTED_BLOCK");
const result = readFileSync(path, "utf8");
expect(result).toContain("// <gen:realtime-rules>\nINSERTED_BLOCK\nafter");
});
it("is idempotent — re-inserting same block at anchor is a no-op", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const path = join(tmp, "base.js");
writeFileSync(
path,
`// <gen:realtime-rules>\nINSERTED_BLOCK\nrest\n`,
);
splicePluginRulesAt(path, "realtime-rules", "INSERTED_BLOCK");
const result = readFileSync(path, "utf8");
expect(result.match(/INSERTED_BLOCK/g)?.length).toBe(1);
});
});
describe("addBoundariesEntry", () => {
it("inserts entry before the packages/core-* wildcard", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const path = join(tmp, "base.js");
writeFileSync(
path,
`"boundaries/elements": [
{ type: "core-composition", pattern: "packages/core-cms" },
{ type: "core", pattern: "packages/core-*" },
{ type: "feature", pattern: "packages/!(core-*)" },
],`,
);
addBoundariesEntry(path, "packages/core-realtime", { mode: "folder" });
const result = readFileSync(path, "utf8");
// The new entry must appear BEFORE the packages/core-* wildcard
const newIdx = result.indexOf(`pattern: "packages/core-realtime"`);
const wildcardIdx = result.indexOf(`pattern: "packages/core-*"`);
expect(newIdx).toBeGreaterThan(0);
expect(newIdx).toBeLessThan(wildcardIdx);
});
it("is idempotent — re-inserting same entry is a no-op", () => {
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
const path = join(tmp, "base.js");
writeFileSync(
path,
`"boundaries/elements": [
{ type: "core", pattern: "packages/core-realtime", mode: "folder" },
{ type: "core", pattern: "packages/core-*" },
],`,
);
addBoundariesEntry(path, "packages/core-realtime", { mode: "folder" });
const result = readFileSync(path, "utf8");
expect(result.match(/packages\/core-realtime/g)?.length).toBe(1);
});
});
describe("emitTemplateTree", () => {
it("produces an `add` plop action per .hbs file in the template directory", () => {
// emitTemplateTree reads from turbo/generators/templates/ — the test uses a
// temp template directory injected via the `templatesRoot` arg.
const tmpTemplates = mkdtempSync(join(tmpdir(), "tpl-"));
mkdirSync(join(tmpTemplates, "core-package", "demo", "src"), { recursive: true });
writeFileSync(join(tmpTemplates, "core-package", "demo", "package.json.hbs"), "{}");
writeFileSync(join(tmpTemplates, "core-package", "demo", "src", "index.ts.hbs"), "export {};");
const actions = emitTemplateTree("core-package/demo", "packages/demo", { templatesRoot: tmpTemplates });
expect(actions).toHaveLength(2);
expect(actions[0]!.type).toBe("add");
const paths = actions.map((a) => (a as { path: string }).path);
expect(paths).toContain("packages/demo/package.json");
expect(paths).toContain("packages/demo/src/index.ts");
});
});

View File

@@ -0,0 +1,176 @@
import {
existsSync,
readFileSync,
writeFileSync,
readdirSync,
statSync,
} from "node:fs";
import { join, relative } from "node:path";
import type { PlopTypes } from "@turbo/gen";
/**
* Throws if a core package directory already exists. Used as the first action
* in every per-package generator so re-running is safe.
*/
export function assertOptionalPackageNotPresent(
name: string,
cwd: string = process.cwd(),
): void {
const pkgRoot = join(cwd, "packages", name);
if (existsSync(pkgRoot)) {
throw new Error(
`packages/${name}/ already exists — refusing to scaffold (delete it first if intentional)`,
);
}
}
/**
* Inserts a package name into the transpilePackages array of a Next.js config
* file, preserving alphabetical order. Idempotent.
*/
export function addToTranspilePackages(
nextConfigPath: string,
pkgName: string,
): void {
const source = readFileSync(nextConfigPath, "utf8");
if (source.includes(`"${pkgName}"`)) return;
const updated = source.replace(
/(transpilePackages:\s*\[\s*)([\s\S]*?)(\s*\])/,
(_match, open: string, body: string, close: string) => {
const entries = body
.split(",")
.map((e) => e.trim())
.filter(Boolean);
entries.push(`"${pkgName}"`);
entries.sort();
const formatted = entries.map((e) => ` ${e}`).join(",\n");
return `${open}\n${formatted},${close}`;
},
);
writeFileSync(nextConfigPath, updated);
}
/**
* Inserts a code block immediately after a `// <gen:NAME>` anchor in a file.
* Idempotent: refuses to insert if the exact block already follows the anchor.
*/
export function splicePluginRulesAt(
filePath: string,
anchorName: string,
block: string,
): void {
const source = readFileSync(filePath, "utf8");
const anchor = `// <gen:${anchorName}>`;
const idx = source.indexOf(anchor);
if (idx === -1) {
throw new Error(`Anchor ${anchor} not found in ${filePath}`);
}
const after = source.slice(idx + anchor.length);
if (after.trimStart().startsWith(block.trim())) return; // idempotent
const updated = source.slice(0, idx + anchor.length) + "\n" + block + after;
writeFileSync(filePath, updated);
}
/**
* Inserts an import line immediately after the matching anchor. Idempotent.
*/
export function splicePluginImportsAt(
filePath: string,
anchorName: string,
importLine: string,
): void {
splicePluginRulesAt(filePath, anchorName, importLine);
}
/**
* Inserts a `{ type, pattern, ...opts }` entry into the boundaries/elements
* array of `core-eslint/base.js`, placed immediately BEFORE the
* `packages/core-*` wildcard so its more-specific match wins. Idempotent.
*/
export function addBoundariesEntry(
baseJsPath: string,
packagePath: string,
opts: { mode?: "folder" } = {},
): void {
const source = readFileSync(baseJsPath, "utf8");
if (source.includes(`pattern: "${packagePath}"`)) return; // idempotent
const wildcardLine = source.match(
/(\s*\{\s*type:\s*"core",\s*pattern:\s*"packages\/core-\*"[^}]*\},)/,
);
if (!wildcardLine) {
throw new Error(`packages/core-* wildcard not found in ${baseJsPath}`);
}
const modeFragment = opts.mode ? `, mode: "${opts.mode}"` : "";
const newEntry = ` { type: "core", pattern: "${packagePath}"${modeFragment} },\n`;
const updated = source.replace(
wildcardLine[0],
`\n${newEntry}${wildcardLine[1]}`,
);
writeFileSync(baseJsPath, updated);
}
/**
* Walks turbo/generators/templates/<srcPrefix>/ recursively. For each .hbs
* file, returns a plop `add` action that emits the file (without .hbs
* extension) at <destPrefix>/<relative-path>. The actions are sorted so
* directory creation is deterministic.
*
* Set `opts.force` to overwrite existing files (idempotent re-runs).
*/
export function emitTemplateTree(
srcPrefix: string,
destPrefix: string,
opts: { templatesRoot?: string; force?: boolean } = {},
): PlopTypes.AddActionConfig[] {
// The templates directory is resolved in priority order:
// 1. opts.templatesRoot — test injection (temp directory)
// 2. cwd/turbo/generators/templates — turbo gen context (cwd = repo root)
// 3. cwd/templates — vitest context (cwd = turbo/generators)
let root: string;
if (opts.templatesRoot) {
root = opts.templatesRoot;
} else {
const fromRepoRoot = join(
process.cwd(),
"turbo",
"generators",
"templates",
);
const fromGeneratorsDir = join(process.cwd(), "templates");
root = existsSync(fromRepoRoot) ? fromRepoRoot : fromGeneratorsDir;
}
const srcRoot = join(root, srcPrefix);
const out: PlopTypes.AddActionConfig[] = [];
walkHbs(srcRoot, srcRoot, srcPrefix, destPrefix, out, opts.force ?? false);
out.sort((a, b) => (a.path ?? "").localeCompare(b.path ?? ""));
return out;
}
function walkHbs(
topRoot: string,
dir: string,
srcPrefix: string,
destPrefix: string,
out: PlopTypes.AddActionConfig[],
force: boolean,
): void {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) {
walkHbs(topRoot, full, srcPrefix, destPrefix, out, force);
continue;
}
if (!name.endsWith(".hbs")) continue;
const rel = relative(topRoot, full).replace(/\.hbs$/, "");
out.push({
type: "add",
path: join(destPrefix, rel).replace(/\\/g, "/"),
templateFile: join(
"templates",
srcPrefix,
relative(topRoot, full),
).replace(/\\/g, "/"),
force,
} as PlopTypes.AddActionConfig);
}
}

View File

@@ -0,0 +1,146 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { registerFeatureInReleasePlease } from "./release-please-utils.ts";
function setupRepo() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rp-utils-"));
fs.writeFileSync(
path.join(tmp, ".release-please-manifest.json"),
JSON.stringify(
{
".": "0.1.0",
"packages/auth": "0.1.0",
"packages/blog": "0.1.0",
},
null,
2,
) + "\n",
);
fs.writeFileSync(
path.join(tmp, "release-please-config.json"),
JSON.stringify(
{
"release-type": "node",
"include-component-in-tag": true,
packages: {
".": {
"package-name": "template-vertical",
component: "template",
"changelog-path": "CHANGELOG.md",
},
"packages/auth": {
"package-name": "@repo/auth",
component: "auth",
"changelog-path": "CHANGELOG.md",
},
},
},
null,
2,
) + "\n",
);
return tmp;
}
describe("registerFeatureInReleasePlease", () => {
test("adds a new package to both files", () => {
const tmp = setupRepo();
try {
const { manifestChanged, configChanged } = registerFeatureInReleasePlease(
tmp,
"comments",
);
assert.equal(manifestChanged, true);
assert.equal(configChanged, true);
const manifest = JSON.parse(
fs.readFileSync(
path.join(tmp, ".release-please-manifest.json"),
"utf8",
),
);
assert.equal(manifest["packages/comments"], "0.1.0");
const config = JSON.parse(
fs.readFileSync(path.join(tmp, "release-please-config.json"), "utf8"),
);
assert.deepEqual(config.packages["packages/comments"], {
"package-name": "@repo/comments",
component: "comments",
"changelog-path": "CHANGELOG.md",
});
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("is idempotent — re-running on an already-tracked feature is a no-op", () => {
const tmp = setupRepo();
try {
const first = registerFeatureInReleasePlease(tmp, "comments");
assert.equal(first.manifestChanged, true);
const second = registerFeatureInReleasePlease(tmp, "comments");
assert.equal(second.manifestChanged, false);
assert.equal(second.configChanged, false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("keeps root ('.') entry first and sorts the rest alphabetically", () => {
const tmp = setupRepo();
try {
registerFeatureInReleasePlease(tmp, "alphabetically-first-comments");
const manifestText = fs.readFileSync(
path.join(tmp, ".release-please-manifest.json"),
"utf8",
);
const keys = Object.keys(JSON.parse(manifestText));
assert.equal(keys[0], ".", "root should always be first");
// The rest are alphabetical
const rest = keys.slice(1);
const sorted = [...rest].sort();
assert.deepEqual(rest, sorted);
const configText = fs.readFileSync(
path.join(tmp, "release-please-config.json"),
"utf8",
);
const packageKeys = Object.keys(JSON.parse(configText).packages);
assert.equal(packageKeys[0], ".");
assert.deepEqual(packageKeys.slice(1), [...packageKeys.slice(1)].sort());
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("throws if .release-please-manifest.json is missing", () => {
const tmp = setupRepo();
try {
fs.unlinkSync(path.join(tmp, ".release-please-manifest.json"));
assert.throws(
() => registerFeatureInReleasePlease(tmp, "x"),
/\.release-please-manifest\.json is missing/,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("throws if release-please-config.json is missing", () => {
const tmp = setupRepo();
try {
fs.unlinkSync(path.join(tmp, "release-please-config.json"));
assert.throws(
() => registerFeatureInReleasePlease(tmp, "x"),
/release-please-config\.json is missing/,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,101 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
const MANIFEST_FILE = ".release-please-manifest.json";
const CONFIG_FILE = "release-please-config.json";
const INITIAL_VERSION = "0.1.0";
/**
* Register a new feature package in release-please tracking (ADR-021).
*
* Mutates two files at the repo root:
* - `.release-please-manifest.json` — adds `"packages/<name>": "0.1.0"`
* - `release-please-config.json` — adds the per-package config block
*
* Idempotent: if the package is already tracked, both writes are no-ops.
*
* Throws if either file is missing — release-please MUST be set up before
* a feature generator can register against it (ADR-021 land-down sequence).
*/
export function registerFeatureInReleasePlease(
repoRoot: string,
featureKebab: string,
): { manifestChanged: boolean; configChanged: boolean } {
const manifestPath = join(repoRoot, MANIFEST_FILE);
const configPath = join(repoRoot, CONFIG_FILE);
if (!existsSync(manifestPath)) {
throw new Error(
`Cannot register feature in release-please: ${MANIFEST_FILE} is missing. ` +
`release-please must be set up before scaffolding features that participate in tracking.`,
);
}
if (!existsSync(configPath)) {
throw new Error(
`Cannot register feature in release-please: ${CONFIG_FILE} is missing.`,
);
}
const packagePath = `packages/${featureKebab}`;
const manifestChanged = addToManifest(manifestPath, packagePath);
const configChanged = addToConfig(configPath, packagePath, featureKebab);
return { manifestChanged, configChanged };
}
function addToManifest(manifestPath: string, packagePath: string): boolean {
const text = readFileSync(manifestPath, "utf8");
const manifest = JSON.parse(text) as Record<string, string>;
if (manifest[packagePath]) return false; // already tracked
manifest[packagePath] = INITIAL_VERSION;
// Sort keys: root (".") stays first if present; the rest alphabetically by
// path so insertions are deterministic + future diffs stay minimal.
const sorted: Record<string, string> = {};
if (manifest["."] !== undefined) sorted["."] = manifest["."];
for (const key of Object.keys(manifest).sort()) {
if (key === ".") continue;
sorted[key] = manifest[key];
}
writeFileSync(manifestPath, JSON.stringify(sorted, null, 2) + "\n");
return true;
}
function addToConfig(
configPath: string,
packagePath: string,
featureKebab: string,
): boolean {
const text = readFileSync(configPath, "utf8");
const config = JSON.parse(text) as {
packages?: Record<
string,
{
"package-name": string;
component: string;
"changelog-path"?: string;
}
>;
[k: string]: unknown;
};
if (!config.packages) {
throw new Error(
`release-please-config.json has no "packages" key — config is malformed.`,
);
}
if (config.packages[packagePath]) return false; // already tracked
config.packages[packagePath] = {
"package-name": `@repo/${featureKebab}`,
component: featureKebab,
"changelog-path": "CHANGELOG.md",
};
// Sort packages the same way as the manifest: root first, rest alphabetical.
const sortedPackages: typeof config.packages = {};
if (config.packages["."]) sortedPackages["."] = config.packages["."];
for (const key of Object.keys(config.packages).sort()) {
if (key === ".") continue;
sortedPackages[key] = config.packages[key];
}
config.packages = sortedPackages;
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
return true;
}

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { computeSnapshot } from "./snapshot.js";
describe("computeSnapshot", () => {
it("returns sorted file paths + sha256 hashes", () => {
const tmp = mkdtempSync(join(tmpdir(), "snapshot-"));
mkdirSync(join(tmp, "src"));
writeFileSync(join(tmp, "package.json"), `{ "name": "x" }\n`);
writeFileSync(join(tmp, "src", "index.ts"), `export {};\n`);
const snap = computeSnapshot(tmp);
expect(snap).toEqual([
{ path: "package.json", sha256: expect.any(String) },
{ path: "src/index.ts", sha256: expect.any(String) },
]);
});
});

View File

@@ -0,0 +1,33 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative } from "node:path";
export type SnapshotEntry = { path: string; sha256: string };
/**
* Recursively collect all files under root, sorted by relative path, with
* sha256 of post-normalized contents (LF line endings, single trailing
* newline). Used by the byte-identical reconstruction test.
*/
export function computeSnapshot(root: string): SnapshotEntry[] {
const out: SnapshotEntry[] = [];
walk(root, root, out);
out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
return out;
}
function walk(root: string, dir: string, out: SnapshotEntry[]): void {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const stat = statSync(full);
if (stat.isDirectory()) {
if (name === "node_modules" || name === ".turbo") continue;
walk(root, full, out);
} else if (stat.isFile()) {
const raw = readFileSync(full, "utf8");
const normalized = raw.replace(/\r\n/g, "\n").replace(/\n*$/, "\n");
const sha = createHash("sha256").update(normalized).digest("hex");
out.push({ path: relative(root, full).replace(/\\/g, "/"), sha256: sha });
}
}
}