Initial commit
This commit is contained in:
26
turbo/generators/lib/anchor-validate.ts
Normal file
26
turbo/generators/lib/anchor-validate.ts
Normal 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
146
turbo/generators/lib/core-package-utils.test.ts
Normal file
146
turbo/generators/lib/core-package-utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
176
turbo/generators/lib/core-package-utils.ts
Normal file
176
turbo/generators/lib/core-package-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
146
turbo/generators/lib/release-please-utils.test.mjs
Normal file
146
turbo/generators/lib/release-please-utils.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
101
turbo/generators/lib/release-please-utils.ts
Normal file
101
turbo/generators/lib/release-please-utils.ts
Normal 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;
|
||||
}
|
||||
19
turbo/generators/lib/snapshot.test.ts
Normal file
19
turbo/generators/lib/snapshot.test.ts
Normal 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) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
33
turbo/generators/lib/snapshot.ts
Normal file
33
turbo/generators/lib/snapshot.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user