repokeeper 0.1.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.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/cli.js +106 -0
- package/dist/commands/check.js +45 -0
- package/dist/commands/context.js +7 -0
- package/dist/commands/init.js +89 -0
- package/dist/commands/report.js +27 -0
- package/dist/commands/update.js +42 -0
- package/dist/config/load.js +99 -0
- package/dist/config/schema.js +45 -0
- package/dist/config/types.js +21 -0
- package/dist/errors.js +26 -0
- package/dist/git.js +44 -0
- package/dist/model.js +21 -0
- package/dist/modules/ci.js +5 -0
- package/dist/modules/commits.js +35 -0
- package/dist/modules/deps.js +5 -0
- package/dist/modules/editorconfig.js +44 -0
- package/dist/modules/gitignore.js +20 -0
- package/dist/modules/health.js +58 -0
- package/dist/modules/hooks.js +41 -0
- package/dist/modules/index.js +18 -0
- package/dist/modules/release.js +10 -0
- package/dist/plan.js +19 -0
- package/dist/platforms/github.js +168 -0
- package/dist/stacks/dart.js +35 -0
- package/dist/stacks/dotnet.js +47 -0
- package/dist/stacks/index.js +23 -0
- package/dist/stacks/java.js +52 -0
- package/dist/stacks/node.js +81 -0
- package/dist/stacks/python.js +68 -0
- package/dist/stacks/script.js +29 -0
- package/dist/stacks/support.js +50 -0
- package/dist/stacks/types.js +1 -0
- package/dist/sync/apply.js +82 -0
- package/dist/sync/block.js +51 -0
- package/dist/sync/decide.js +21 -0
- package/dist/sync/hash.js +8 -0
- package/dist/sync/json.js +31 -0
- package/dist/sync/lock.js +40 -0
- package/dist/sync/state.js +50 -0
- package/dist/sync/sync.js +32 -0
- package/dist/sync/yaml.js +49 -0
- package/dist/templates.js +6 -0
- package/dist/version.js +23 -0
- package/package.json +56 -0
- package/templates/CODE_OF_CONDUCT.md +83 -0
- package/templates/gitignore/Dart.gitignore +29 -0
- package/templates/gitignore/Gradle.gitignore +21 -0
- package/templates/gitignore/Java.gitignore +24 -0
- package/templates/gitignore/Maven.gitignore +17 -0
- package/templates/gitignore/Node.gitignore +149 -0
- package/templates/gitignore/Python.gitignore +220 -0
- package/templates/gitignore/VisualStudio.gitignore +432 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { UsageError } from "../errors.js";
|
|
5
|
+
import { checkKeys, stringList } from "./support.js";
|
|
6
|
+
const NPM_PLACEHOLDER_TEST = 'echo "Error: no test specified" && exit 1';
|
|
7
|
+
const OPTION_KEYS = ["versions", "os", "scripts"];
|
|
8
|
+
const CI_SCRIPTS = ["typecheck", "lint", "test", "build"];
|
|
9
|
+
/** NestJS projects scaffold end-to-end tests as a separate script. */
|
|
10
|
+
const NEST_CI_SCRIPTS = ["typecheck", "lint", "test", "test:e2e", "build"];
|
|
11
|
+
export const nodeStack = {
|
|
12
|
+
id: "node",
|
|
13
|
+
detect: (root) => existsSync(join(root, "package.json")),
|
|
14
|
+
async resolve(root, options = {}) {
|
|
15
|
+
checkKeys("node", options, OPTION_KEYS);
|
|
16
|
+
let pkg;
|
|
17
|
+
try {
|
|
18
|
+
pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw new UsageError(`package.json could not be read: ${error.message}`);
|
|
22
|
+
}
|
|
23
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
24
|
+
const pm = existsSync(join(root, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(root, "yarn.lock")) ? "yarn" : "npm";
|
|
25
|
+
const staged = [];
|
|
26
|
+
if (deps["@biomejs/biome"]) {
|
|
27
|
+
staged.push({
|
|
28
|
+
name: "node:biome",
|
|
29
|
+
glob: "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,css}",
|
|
30
|
+
run: "npx biome check --write --no-errors-on-unmatched {staged_files}",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
if (deps.prettier) {
|
|
35
|
+
staged.push({
|
|
36
|
+
name: "node:prettier",
|
|
37
|
+
glob: "*.{js,jsx,ts,tsx,mjs,cjs,json,css,scss,md,yml,yaml,html}",
|
|
38
|
+
run: "npx prettier --write --ignore-unknown {staged_files}",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (deps.eslint) {
|
|
42
|
+
staged.push({ name: "node:eslint", glob: "*.{js,jsx,ts,tsx,mjs,cjs}", run: "npx eslint --fix {staged_files}" });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const testScript = pkg.scripts?.test;
|
|
46
|
+
const hasTest = testScript !== undefined && testScript !== NPM_PLACEHOLDER_TEST;
|
|
47
|
+
const npmLock = existsSync(join(root, "package-lock.json")) || existsSync(join(root, "npm-shrinkwrap.json"));
|
|
48
|
+
const ciInstall = pm === "npm"
|
|
49
|
+
? npmLock
|
|
50
|
+
? "npm ci"
|
|
51
|
+
: "npm install"
|
|
52
|
+
: pm === "pnpm"
|
|
53
|
+
? "pnpm install --frozen-lockfile"
|
|
54
|
+
: existsSync(join(root, ".yarnrc.yml"))
|
|
55
|
+
? "yarn install --immutable"
|
|
56
|
+
: "yarn install --frozen-lockfile";
|
|
57
|
+
const scripts = stringList("node", options, "scripts") ??
|
|
58
|
+
(existsSync(join(root, "nest-cli.json")) ? NEST_CI_SCRIPTS : CI_SCRIPTS).filter((name) => name === "test" ? hasTest : pkg.scripts?.[name] !== undefined);
|
|
59
|
+
return {
|
|
60
|
+
id: "node",
|
|
61
|
+
staged,
|
|
62
|
+
test: hasTest ? `${pm} test` : null,
|
|
63
|
+
install: `${pm} install`,
|
|
64
|
+
gitignore: ["Node"],
|
|
65
|
+
dependabot: ["npm"],
|
|
66
|
+
ci: {
|
|
67
|
+
workflow: "stack-node.yml",
|
|
68
|
+
with: {
|
|
69
|
+
"node-versions": JSON.stringify(stringList("node", options, "versions") ?? ["22", "24"]),
|
|
70
|
+
os: JSON.stringify(stringList("node", options, "os") ?? ["ubuntu-latest"]),
|
|
71
|
+
"package-manager": pm,
|
|
72
|
+
"install-command": ciInstall,
|
|
73
|
+
// setup-node can only cache npm here: pnpm and yarn come from corepack after it runs
|
|
74
|
+
cache: pm === "npm" && npmLock ? "npm" : "",
|
|
75
|
+
scripts: JSON.stringify(scripts),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
release: { type: "node", version: typeof pkg.version === "string" ? pkg.version : null },
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { checkKeys, filesMatching, optionalString, readText, stringList } from "./support.js";
|
|
4
|
+
const OPTION_KEYS = ["versions", "os", "test"];
|
|
5
|
+
/** The `version = "…"` of the `[project]` table, if any. */
|
|
6
|
+
function projectVersion(pyproject) {
|
|
7
|
+
const table = pyproject.split(/^\[/m).find((section) => section.startsWith("project]"));
|
|
8
|
+
return table ? (/^version\s*=\s*"([^"]+)"/m.exec(table)?.[1] ?? null) : null;
|
|
9
|
+
}
|
|
10
|
+
export const pythonStack = {
|
|
11
|
+
id: "python",
|
|
12
|
+
detect: (root) => ["pyproject.toml", "requirements.txt", "setup.cfg"].some((f) => existsSync(join(root, f))),
|
|
13
|
+
async resolve(root, options = {}) {
|
|
14
|
+
checkKeys("python", options, OPTION_KEYS);
|
|
15
|
+
const has = (path) => existsSync(join(root, path));
|
|
16
|
+
const pyproject = (await readText(root, "pyproject.toml")) ?? "";
|
|
17
|
+
const uv = has("uv.lock");
|
|
18
|
+
const mypy = has("mypy.ini") || pyproject.includes("[tool.mypy]");
|
|
19
|
+
const hasTests = has("tests") || has("test") || filesMatching(root, ".", /^test_.*\.py$/).length > 0;
|
|
20
|
+
const customTest = optionalString("python", options, "test");
|
|
21
|
+
const pytest = customTest === undefined && hasTests;
|
|
22
|
+
const tools = ["ruff", ...(mypy ? ["mypy"] : []), ...(pytest ? ["pytest"] : [])];
|
|
23
|
+
const run = uv ? "uv run " : "";
|
|
24
|
+
const projectInstall = [];
|
|
25
|
+
if (!uv) {
|
|
26
|
+
if (has("requirements.txt"))
|
|
27
|
+
projectInstall.push("python -m pip install -r requirements.txt");
|
|
28
|
+
if (has("requirements-dev.txt"))
|
|
29
|
+
projectInstall.push("python -m pip install -r requirements-dev.txt");
|
|
30
|
+
if (/^\[project\]/m.test(pyproject))
|
|
31
|
+
projectInstall.push("python -m pip install -e .");
|
|
32
|
+
}
|
|
33
|
+
const ciInstall = uv
|
|
34
|
+
? `uv sync --frozen && uv pip install ${tools.join(" ")}`
|
|
35
|
+
: [...projectInstall, `python -m pip install ${tools.join(" ")}`].join(" && ");
|
|
36
|
+
const test = customTest ?? (pytest ? (uv ? "uv run pytest" : "python -m pytest") : null);
|
|
37
|
+
const commands = [
|
|
38
|
+
`${run}ruff format --check .`,
|
|
39
|
+
`${run}ruff check .`,
|
|
40
|
+
...(mypy ? [`${run}mypy .`] : []),
|
|
41
|
+
...(test ? [test] : []),
|
|
42
|
+
];
|
|
43
|
+
const ruff = uv ? "uvx ruff" : "ruff";
|
|
44
|
+
const staged = [
|
|
45
|
+
{ name: "python:ruff-format", glob: "*.py", run: `${ruff} format {staged_files}` },
|
|
46
|
+
{ name: "python:ruff-check", glob: "*.py", run: `${ruff} check --fix {staged_files}` },
|
|
47
|
+
];
|
|
48
|
+
return {
|
|
49
|
+
id: "python",
|
|
50
|
+
staged,
|
|
51
|
+
test,
|
|
52
|
+
install: uv ? "uv sync" : projectInstall.length > 0 ? projectInstall.join(" && ") : null,
|
|
53
|
+
gitignore: ["Python"],
|
|
54
|
+
dependabot: [uv ? "uv" : "pip"],
|
|
55
|
+
ci: {
|
|
56
|
+
workflow: "stack-python.yml",
|
|
57
|
+
with: {
|
|
58
|
+
"python-versions": JSON.stringify(stringList("python", options, "versions") ?? ["3.11", "3.12", "3.13"]),
|
|
59
|
+
os: JSON.stringify(stringList("python", options, "os") ?? ["ubuntu-latest"]),
|
|
60
|
+
manager: uv ? "uv" : "pip",
|
|
61
|
+
"install-command": ciInstall,
|
|
62
|
+
commands: JSON.stringify(commands),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
release: { type: "python", version: projectVersion(pyproject) },
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { checkKeys, filesMatching, optionalString } from "./support.js";
|
|
2
|
+
const OPTION_KEYS = ["test"];
|
|
3
|
+
const scripts = (root, pattern) => filesMatching(root, ".", pattern).length + filesMatching(root, "scripts", pattern).length > 0;
|
|
4
|
+
export const scriptStack = {
|
|
5
|
+
id: "script",
|
|
6
|
+
detect: (root) => scripts(root, /\.(sh|ps1)$/),
|
|
7
|
+
async resolve(root, options = {}) {
|
|
8
|
+
checkKeys("script", options, OPTION_KEYS);
|
|
9
|
+
const test = optionalString("script", options, "test") ?? null;
|
|
10
|
+
return {
|
|
11
|
+
id: "script",
|
|
12
|
+
// shfmt, ShellCheck and PSScriptAnalyzer are rarely installed locally; CI enforces them
|
|
13
|
+
staged: [],
|
|
14
|
+
test,
|
|
15
|
+
install: null,
|
|
16
|
+
gitignore: [],
|
|
17
|
+
dependabot: [],
|
|
18
|
+
ci: {
|
|
19
|
+
workflow: "stack-script.yml",
|
|
20
|
+
with: {
|
|
21
|
+
"shell-scripts": String(scripts(root, /\.sh$/)),
|
|
22
|
+
"powershell-scripts": String(scripts(root, /\.ps1$/)),
|
|
23
|
+
"test-command": test ?? "",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
release: { type: "simple", version: null },
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CONFIG_FILE } from "../config/load.js";
|
|
5
|
+
import { ConfigError } from "../errors.js";
|
|
6
|
+
export function checkKeys(stack, options, keys) {
|
|
7
|
+
for (const key of Object.keys(options)) {
|
|
8
|
+
if (!keys.includes(key)) {
|
|
9
|
+
throw new ConfigError(`${CONFIG_FILE}: stack_options.${stack}.${key} is not a known key (${keys.join(", ")})`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** A list option; YAML numbers such as `[22, 24]` count as strings. */
|
|
14
|
+
export function stringList(stack, options, key) {
|
|
15
|
+
const value = options[key];
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
const valid = Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === "string" || typeof v === "number");
|
|
19
|
+
if (!valid)
|
|
20
|
+
throw new ConfigError(`${CONFIG_FILE}: stack_options.${stack}.${key} must be a non-empty list of strings`);
|
|
21
|
+
return value.map(String);
|
|
22
|
+
}
|
|
23
|
+
export function optionalString(stack, options, key) {
|
|
24
|
+
const value = options[key];
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
28
|
+
throw new ConfigError(`${CONFIG_FILE}: stack_options.${stack}.${key} must be a non-empty string`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
/** Names of the entries of `dir` (relative to root) matching `pattern`, sorted; empty when `dir` is missing. */
|
|
33
|
+
export function filesMatching(root, dir, pattern) {
|
|
34
|
+
try {
|
|
35
|
+
return readdirSync(join(root, dir))
|
|
36
|
+
.filter((name) => pattern.test(name))
|
|
37
|
+
.sort();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function readText(root, file) {
|
|
44
|
+
try {
|
|
45
|
+
return await readFile(join(root, file), "utf8");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { outputId, YAML_HEADER } from "../model.js";
|
|
4
|
+
import { removeBlock, upsertBlock } from "./block.js";
|
|
5
|
+
import { hashText } from "./hash.js";
|
|
6
|
+
import { deleteAtPath, formatJson, setAtPath } from "./json.js";
|
|
7
|
+
import { targetOf, writeLock } from "./lock.js";
|
|
8
|
+
import { desiredText } from "./state.js";
|
|
9
|
+
import { deleteYamlKey, setYamlKey } from "./yaml.js";
|
|
10
|
+
async function readOrNull(path) {
|
|
11
|
+
try {
|
|
12
|
+
return await readFile(path, "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code === "ENOENT")
|
|
16
|
+
return null;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function put(path, text) {
|
|
21
|
+
await mkdir(dirname(path), { recursive: true });
|
|
22
|
+
await writeFile(path, text);
|
|
23
|
+
}
|
|
24
|
+
async function write(root, output) {
|
|
25
|
+
const path = join(root, output.path);
|
|
26
|
+
if (output.kind === "file" || output.kind === "seed")
|
|
27
|
+
return put(path, output.content);
|
|
28
|
+
const existing = await readOrNull(path);
|
|
29
|
+
if (output.kind === "block")
|
|
30
|
+
return put(path, upsertBlock(existing, output.id, output.body, output.comment));
|
|
31
|
+
if (output.kind === "yaml") {
|
|
32
|
+
return put(path, setYamlKey(existing, output.path, output.keyPath, output.value, { header: YAML_HEADER, order: output.order }));
|
|
33
|
+
}
|
|
34
|
+
const data = existing ? JSON.parse(existing) : {};
|
|
35
|
+
setAtPath(data, output.keyPath, output.value);
|
|
36
|
+
return put(path, formatJson(data, existing));
|
|
37
|
+
}
|
|
38
|
+
async function remove(root, target) {
|
|
39
|
+
const path = join(root, target.path);
|
|
40
|
+
if (target.kind === "seed")
|
|
41
|
+
return;
|
|
42
|
+
if (target.kind === "file")
|
|
43
|
+
return rm(path, { force: true });
|
|
44
|
+
const existing = await readOrNull(path);
|
|
45
|
+
if (existing === null)
|
|
46
|
+
return;
|
|
47
|
+
if (target.kind === "block")
|
|
48
|
+
return put(path, removeBlock(existing, target.id, target.comment));
|
|
49
|
+
if (target.kind === "yaml") {
|
|
50
|
+
const next = deleteYamlKey(existing, target.path, target.keyPath);
|
|
51
|
+
return next === null ? rm(path, { force: true }) : put(path, next);
|
|
52
|
+
}
|
|
53
|
+
const data = JSON.parse(existing);
|
|
54
|
+
deleteAtPath(data, target.keyPath);
|
|
55
|
+
return put(path, formatJson(data, existing));
|
|
56
|
+
}
|
|
57
|
+
export async function applySync(root, result, previous, standard) {
|
|
58
|
+
const previousEntries = new Map((previous?.entries ?? []).map((e) => [e.id, e]));
|
|
59
|
+
const entries = [];
|
|
60
|
+
for (const { output, action } of result.decisions) {
|
|
61
|
+
const id = outputId(output);
|
|
62
|
+
if (action === "create" || action === "write" || action === "adopt")
|
|
63
|
+
await write(root, output);
|
|
64
|
+
if (action === "conflict" && output.kind === "file")
|
|
65
|
+
await put(join(root, `${output.path}.repokeeper-new`), output.content);
|
|
66
|
+
if (action === "conflict") {
|
|
67
|
+
const kept = previousEntries.get(id);
|
|
68
|
+
if (kept)
|
|
69
|
+
entries.push(kept);
|
|
70
|
+
}
|
|
71
|
+
else if (action !== "unmanaged") {
|
|
72
|
+
entries.push({ id, module: output.module, hash: hashText(desiredText(output)), target: targetOf(output) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const removal of result.removals) {
|
|
76
|
+
if (removal.action === "delete")
|
|
77
|
+
await remove(root, removal.entry.target);
|
|
78
|
+
}
|
|
79
|
+
const lock = { lockVersion: 1, standard, entries };
|
|
80
|
+
await writeLock(root, lock);
|
|
81
|
+
return lock;
|
|
82
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { normalizeEol } from "./hash.js";
|
|
2
|
+
function comment(style, text) {
|
|
3
|
+
return style === "hash" ? `# ${text}` : `<!-- ${text} -->`;
|
|
4
|
+
}
|
|
5
|
+
export function startMarker(id, style) {
|
|
6
|
+
return comment(style, `repokeeper:start ${id}`);
|
|
7
|
+
}
|
|
8
|
+
export function endMarker(id, style) {
|
|
9
|
+
return comment(style, `repokeeper:end ${id}`);
|
|
10
|
+
}
|
|
11
|
+
function eolOf(text) {
|
|
12
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
13
|
+
}
|
|
14
|
+
function locate(lines, id, style) {
|
|
15
|
+
const start = lines.indexOf(startMarker(id, style));
|
|
16
|
+
if (start < 0)
|
|
17
|
+
return null;
|
|
18
|
+
const end = lines.indexOf(endMarker(id, style), start + 1);
|
|
19
|
+
return end < 0 ? null : [start, end];
|
|
20
|
+
}
|
|
21
|
+
export function readBlock(text, id, style) {
|
|
22
|
+
const lines = normalizeEol(text).split("\n");
|
|
23
|
+
const range = locate(lines, id, style);
|
|
24
|
+
return range ? lines.slice(range[0] + 1, range[1]).join("\n") : null;
|
|
25
|
+
}
|
|
26
|
+
export function upsertBlock(text, id, body, style) {
|
|
27
|
+
const eol = text === null ? "\n" : eolOf(text);
|
|
28
|
+
const blockLines = [startMarker(id, style), ...normalizeEol(body).split("\n"), endMarker(id, style)];
|
|
29
|
+
if (text === null || text.trim() === "")
|
|
30
|
+
return blockLines.join(eol) + eol;
|
|
31
|
+
const lines = text.split(eol);
|
|
32
|
+
const range = locate(lines, id, style);
|
|
33
|
+
if (range) {
|
|
34
|
+
lines.splice(range[0], range[1] - range[0] + 1, ...blockLines);
|
|
35
|
+
return lines.join(eol);
|
|
36
|
+
}
|
|
37
|
+
const base = text.endsWith(eol) ? text : text + eol;
|
|
38
|
+
return base + eol + blockLines.join(eol) + eol;
|
|
39
|
+
}
|
|
40
|
+
export function removeBlock(text, id, style) {
|
|
41
|
+
const eol = eolOf(text);
|
|
42
|
+
const lines = text.split(eol);
|
|
43
|
+
const range = locate(lines, id, style);
|
|
44
|
+
if (!range)
|
|
45
|
+
return text;
|
|
46
|
+
let [start] = range;
|
|
47
|
+
if (start > 0 && lines[start - 1] === "")
|
|
48
|
+
start -= 1;
|
|
49
|
+
lines.splice(start, range[1] - start + 1);
|
|
50
|
+
return lines.join(eol);
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { hashText } from "./hash.js";
|
|
2
|
+
import { desiredText } from "./state.js";
|
|
3
|
+
/** The update decision table from the spec, section 7. */
|
|
4
|
+
export function decide(output, currentText, entry, adopt) {
|
|
5
|
+
if (currentText === null)
|
|
6
|
+
return "create";
|
|
7
|
+
const current = hashText(currentText);
|
|
8
|
+
if (current === hashText(desiredText(output)))
|
|
9
|
+
return "unchanged";
|
|
10
|
+
if (entry)
|
|
11
|
+
return current === entry.hash ? "write" : "conflict";
|
|
12
|
+
return adopt ? "adopt" : "unmanaged";
|
|
13
|
+
}
|
|
14
|
+
/** For a lock entry the standard no longer produces. */
|
|
15
|
+
export function decideRemoval(entry, currentText) {
|
|
16
|
+
if (entry.target.kind === "seed")
|
|
17
|
+
return "gone"; // other tools own a seed once it exists
|
|
18
|
+
if (currentText === null)
|
|
19
|
+
return "gone";
|
|
20
|
+
return hashText(currentText) === entry.hash ? "delete" : "orphan-edited";
|
|
21
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function normalizeEol(text) {
|
|
3
|
+
return text.replace(/\r\n/g, "\n");
|
|
4
|
+
}
|
|
5
|
+
/** Hash of the LF-normalised text, so a checkout that converts to CRLF still matches. */
|
|
6
|
+
export function hashText(text) {
|
|
7
|
+
return createHash("sha256").update(normalizeEol(text)).digest("hex");
|
|
8
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2
|
+
export function getAtPath(obj, path) {
|
|
3
|
+
let current = obj;
|
|
4
|
+
for (const key of path) {
|
|
5
|
+
if (!isObject(current))
|
|
6
|
+
return undefined;
|
|
7
|
+
current = current[key];
|
|
8
|
+
}
|
|
9
|
+
return current;
|
|
10
|
+
}
|
|
11
|
+
export function setAtPath(obj, path, value) {
|
|
12
|
+
let current = obj;
|
|
13
|
+
for (const key of path.slice(0, -1)) {
|
|
14
|
+
const next = current[key];
|
|
15
|
+
if (!isObject(next))
|
|
16
|
+
current[key] = {};
|
|
17
|
+
current = current[key];
|
|
18
|
+
}
|
|
19
|
+
current[path[path.length - 1]] = value;
|
|
20
|
+
}
|
|
21
|
+
export function deleteAtPath(obj, path) {
|
|
22
|
+
const parent = getAtPath(obj, path.slice(0, -1));
|
|
23
|
+
if (isObject(parent))
|
|
24
|
+
delete parent[path[path.length - 1]];
|
|
25
|
+
}
|
|
26
|
+
/** Serialises with the original file's indentation, line ending and final newline. */
|
|
27
|
+
export function formatJson(value, original) {
|
|
28
|
+
const indent = original ? (/^([ \t]+)"/m.exec(original)?.[1] ?? 2) : 2;
|
|
29
|
+
const eol = original?.includes("\r\n") ? "\r\n" : "\n";
|
|
30
|
+
return JSON.stringify(value, null, indent).replace(/\n/g, eol) + eol;
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { LockError } from "../errors.js";
|
|
4
|
+
export const LOCK_FILE = ".repokeeper/lock.json";
|
|
5
|
+
export function targetOf(output) {
|
|
6
|
+
if (output.kind === "file")
|
|
7
|
+
return { kind: "file", path: output.path };
|
|
8
|
+
if (output.kind === "seed")
|
|
9
|
+
return { kind: "seed", path: output.path };
|
|
10
|
+
if (output.kind === "block")
|
|
11
|
+
return { kind: "block", path: output.path, id: output.id, comment: output.comment };
|
|
12
|
+
if (output.kind === "yaml")
|
|
13
|
+
return { kind: "yaml", path: output.path, keyPath: output.keyPath };
|
|
14
|
+
return { kind: "json", path: output.path, keyPath: output.keyPath };
|
|
15
|
+
}
|
|
16
|
+
export async function readLock(root) {
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = await readFile(join(root, LOCK_FILE), "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === "ENOENT")
|
|
23
|
+
return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(text);
|
|
28
|
+
if (data.lockVersion !== 1 || !Array.isArray(data.entries))
|
|
29
|
+
throw new Error("unexpected format");
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw new LockError(`${LOCK_FILE} is unreadable (${error.message}); run \`repokeeper init --relock\``);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function writeLock(root, lock) {
|
|
37
|
+
const path = join(root, LOCK_FILE);
|
|
38
|
+
await mkdir(dirname(path), { recursive: true });
|
|
39
|
+
await writeFile(path, `${JSON.stringify(lock, null, 2)}\n`);
|
|
40
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { UsageError } from "../errors.js";
|
|
4
|
+
import { readBlock } from "./block.js";
|
|
5
|
+
import { getAtPath } from "./json.js";
|
|
6
|
+
import { readYamlKey } from "./yaml.js";
|
|
7
|
+
/** The text repokeeper compares and hashes for an output. */
|
|
8
|
+
export function desiredText(output) {
|
|
9
|
+
if (output.kind === "file")
|
|
10
|
+
return output.content;
|
|
11
|
+
// a seed only has to exist, so present content always compares equal
|
|
12
|
+
if (output.kind === "seed")
|
|
13
|
+
return "";
|
|
14
|
+
if (output.kind === "block")
|
|
15
|
+
return output.body;
|
|
16
|
+
return JSON.stringify(output.value);
|
|
17
|
+
}
|
|
18
|
+
async function readText(root, path) {
|
|
19
|
+
try {
|
|
20
|
+
return await readFile(join(root, path), "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === "ENOENT")
|
|
24
|
+
return null;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The same comparable text read from disk, or null when absent. */
|
|
29
|
+
export async function readCurrent(root, target) {
|
|
30
|
+
const text = await readText(root, target.path);
|
|
31
|
+
if (text === null)
|
|
32
|
+
return null;
|
|
33
|
+
if (target.kind === "file")
|
|
34
|
+
return text;
|
|
35
|
+
if (target.kind === "seed")
|
|
36
|
+
return "";
|
|
37
|
+
if (target.kind === "block")
|
|
38
|
+
return readBlock(text, target.id, target.comment);
|
|
39
|
+
if (target.kind === "yaml")
|
|
40
|
+
return readYamlKey(text, target.keyPath, target.path);
|
|
41
|
+
let data;
|
|
42
|
+
try {
|
|
43
|
+
data = JSON.parse(text);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new UsageError(`${target.path} is not valid JSON: ${error.message}`);
|
|
47
|
+
}
|
|
48
|
+
const value = getAtPath(data, target.keyPath);
|
|
49
|
+
return value === undefined ? null : JSON.stringify(value);
|
|
50
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { outputId } from "../model.js";
|
|
2
|
+
import { decide, decideRemoval } from "./decide.js";
|
|
3
|
+
import { targetOf } from "./lock.js";
|
|
4
|
+
import { readCurrent } from "./state.js";
|
|
5
|
+
export async function computeSync(root, outputs, lock, options) {
|
|
6
|
+
const entries = new Map((lock?.entries ?? []).map((e) => [e.id, e]));
|
|
7
|
+
const decisions = [];
|
|
8
|
+
for (const output of outputs) {
|
|
9
|
+
const adopt = options.adopt === "all" || options.adopt.has(output.path);
|
|
10
|
+
let action = decide(output, await readCurrent(root, targetOf(output)), entries.get(outputId(output)), adopt);
|
|
11
|
+
if (action === "conflict" && options.accept.has(output.path))
|
|
12
|
+
action = "write";
|
|
13
|
+
decisions.push({ output, action });
|
|
14
|
+
}
|
|
15
|
+
const wanted = new Set(outputs.map(outputId));
|
|
16
|
+
const removals = [];
|
|
17
|
+
for (const entry of entries.values()) {
|
|
18
|
+
if (!wanted.has(entry.id))
|
|
19
|
+
removals.push({ entry, action: decideRemoval(entry, await readCurrent(root, entry.target)) });
|
|
20
|
+
}
|
|
21
|
+
return { decisions, removals };
|
|
22
|
+
}
|
|
23
|
+
const WRITES = ["create", "write", "adopt"];
|
|
24
|
+
/** Existing paths a sync would change; guarded against uncommitted edits. */
|
|
25
|
+
export function pathsToWrite(result) {
|
|
26
|
+
return [
|
|
27
|
+
...new Set([
|
|
28
|
+
...result.decisions.filter((d) => WRITES.includes(d.action)).map((d) => d.output.path),
|
|
29
|
+
...result.removals.filter((r) => r.action === "delete").map((r) => r.entry.target.path),
|
|
30
|
+
]),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Document, isMap, isScalar, parseDocument } from "yaml";
|
|
2
|
+
import { UsageError } from "../errors.js";
|
|
3
|
+
import { getAtPath } from "./json.js";
|
|
4
|
+
function parse(text, file) {
|
|
5
|
+
const doc = parseDocument(text);
|
|
6
|
+
const error = doc.errors[0];
|
|
7
|
+
if (error)
|
|
8
|
+
throw new UsageError(`${file} is not valid YAML: ${error.message.split("\n")[0]}`);
|
|
9
|
+
return doc;
|
|
10
|
+
}
|
|
11
|
+
function render(doc, original) {
|
|
12
|
+
const text = doc.toString({ lineWidth: 0, nullStr: "", flowCollectionPadding: false });
|
|
13
|
+
return original?.includes("\r\n") ? text.replace(/\r?\n/g, "\r\n") : text;
|
|
14
|
+
}
|
|
15
|
+
/** JSON text of the value at `keyPath`, or null when the key is absent. */
|
|
16
|
+
export function readYamlKey(text, keyPath, file) {
|
|
17
|
+
const value = getAtPath(parse(text, file).toJS() ?? {}, keyPath);
|
|
18
|
+
return value === undefined ? null : JSON.stringify(value);
|
|
19
|
+
}
|
|
20
|
+
export function setYamlKey(text, file, keyPath, value, options) {
|
|
21
|
+
const fresh = text === null || text.trim() === "";
|
|
22
|
+
const doc = text !== null && !fresh ? parse(text, file) : new Document({});
|
|
23
|
+
if (fresh)
|
|
24
|
+
doc.commentBefore = ` ${options.header}`;
|
|
25
|
+
doc.setIn(keyPath, doc.createNode(value));
|
|
26
|
+
const order = options.order;
|
|
27
|
+
if (order && isMap(doc.contents)) {
|
|
28
|
+
const rank = (key) => {
|
|
29
|
+
const index = order.indexOf(String(isScalar(key) ? key.value : key));
|
|
30
|
+
return index === -1 ? order.length : index;
|
|
31
|
+
};
|
|
32
|
+
doc.contents.items.sort((a, b) => rank(a.key) - rank(b.key));
|
|
33
|
+
}
|
|
34
|
+
return render(doc, text);
|
|
35
|
+
}
|
|
36
|
+
/** Removes the key and any parents it leaves empty; null when nothing is left. */
|
|
37
|
+
export function deleteYamlKey(text, file, keyPath) {
|
|
38
|
+
const doc = parse(text, file);
|
|
39
|
+
doc.deleteIn(keyPath);
|
|
40
|
+
for (let depth = keyPath.length - 1; depth > 0; depth--) {
|
|
41
|
+
const parent = doc.getIn(keyPath.slice(0, depth));
|
|
42
|
+
if (!isMap(parent) || parent.items.length > 0)
|
|
43
|
+
break;
|
|
44
|
+
doc.deleteIn(keyPath.slice(0, depth));
|
|
45
|
+
}
|
|
46
|
+
if (!isMap(doc.contents) || doc.contents.items.length === 0)
|
|
47
|
+
return null;
|
|
48
|
+
return render(doc, text);
|
|
49
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { normalizeEol } from "./sync/hash.js";
|
|
3
|
+
/** Reads a bundled template with LF endings; works from src/ (tests) and dist/ (package). */
|
|
4
|
+
export function readTemplate(relativePath) {
|
|
5
|
+
return normalizeEol(readFileSync(new URL(`../templates/${relativePath}`, import.meta.url), "utf8"));
|
|
6
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
export const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
3
|
+
/** The standard this build of repokeeper applies. Bump it whenever generated output changes. */
|
|
4
|
+
export const STANDARD_VERSION = "1.2.0";
|
|
5
|
+
export const TOOL_VERSIONS = {
|
|
6
|
+
lefthook: "2.1.14",
|
|
7
|
+
commitlintCli: "21.2.3",
|
|
8
|
+
commitlintConventional: "21.2.3",
|
|
9
|
+
};
|
|
10
|
+
/** Repository hosting the reusable workflows that generated CI files call. */
|
|
11
|
+
export const REUSABLE_REPO = "vannt-dev/repokeeper";
|
|
12
|
+
/** Moving tag callers pin to: the major version of this repokeeper (v0 during 0.x). */
|
|
13
|
+
export const WORKFLOW_REF = `v${PACKAGE_VERSION.split(".")[0]}`;
|
|
14
|
+
export function compareVersions(a, b) {
|
|
15
|
+
const pa = a.split(".").map(Number);
|
|
16
|
+
const pb = b.split(".").map(Number);
|
|
17
|
+
for (let i = 0; i < 3; i++) {
|
|
18
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
19
|
+
if (diff !== 0)
|
|
20
|
+
return diff;
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|