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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Van Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# repokeeper
|
|
2
|
+
|
|
3
|
+
Keep every repository on one maintained standard: Conventional Commits, git hooks, community health
|
|
4
|
+
files, editor and gitignore settings, Dependabot, CI and releases — applied once and kept in sync as
|
|
5
|
+
the standard evolves.
|
|
6
|
+
|
|
7
|
+
> Status: early development. Supported stacks: Node.js (including NestJS), Python, Dart and
|
|
8
|
+
> Flutter, shell and PowerShell scripts, Java (Maven and Gradle) and .NET, each with CI and releases.
|
|
9
|
+
> GitHub settings are on the way. See the [design](docs/superpowers/specs/2026-09-25-repokeeper-design.md).
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
repokeeper is not on npm yet. Until the first release, build it from source and link the command:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/vannt-dev/repokeeper.git
|
|
17
|
+
cd repokeeper && npm ci && npm run build && npm link
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then, in the repository you want to standardise (commit your work first — repokeeper refuses to
|
|
21
|
+
write over uncommitted or untracked files unless you pass `--force`):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
repokeeper init # detect the stack, write .repokeeper.yml, apply the standard
|
|
25
|
+
repokeeper check # report drift; exits 1 when the repository has drifted
|
|
26
|
+
repokeeper update # move to the latest standard without overwriting your edits
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`init` never overwrites a file you already have: it reports it as unmanaged. Pass
|
|
30
|
+
`--adopt <path>` to let repokeeper manage it, or list it under `owned` in `.repokeeper.yml` to keep it
|
|
31
|
+
yours. Every write command accepts `--dry-run`.
|
|
32
|
+
|
|
33
|
+
Requires Node.js 22.12 or newer.
|
|
34
|
+
|
|
35
|
+
## CI and releases
|
|
36
|
+
|
|
37
|
+
`ci.yml` and `release.yml` call reusable workflows from this repository (`stack-node.yml`,
|
|
38
|
+
`commitlint.yml`, `release-please.yml`) at the moving major tag, so fixes reach every repository
|
|
39
|
+
without a pull request. repokeeper owns the `name`, `on` and `permissions` keys and the jobs it
|
|
40
|
+
adds; jobs you add yourself are left alone.
|
|
41
|
+
|
|
42
|
+
`release.yml` runs [release-please](https://github.com/googleapis/release-please): it keeps a release
|
|
43
|
+
pull request open, and merging it tags the release and updates `CHANGELOG.md`. Two settings make this
|
|
44
|
+
work:
|
|
45
|
+
|
|
46
|
+
- In the repository settings, under Actions → General, allow GitHub Actions to create and approve
|
|
47
|
+
pull requests.
|
|
48
|
+
- Optionally add a `RELEASE_PLEASE_TOKEN` secret (a fine-grained token with contents, pull requests
|
|
49
|
+
and issues write access). Without it the release pull request is opened with `GITHUB_TOKEN`, and
|
|
50
|
+
GitHub does not run CI on pull requests opened that way.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { checkCommand } from "./commands/check.js";
|
|
6
|
+
import { initCommand } from "./commands/init.js";
|
|
7
|
+
import { updateCommand } from "./commands/update.js";
|
|
8
|
+
import { STACK_IDS } from "./config/types.js";
|
|
9
|
+
import { RepokeeperError, UsageError } from "./errors.js";
|
|
10
|
+
import { PACKAGE_VERSION } from "./version.js";
|
|
11
|
+
export const USAGE = [
|
|
12
|
+
"usage: repokeeper <command> [options]",
|
|
13
|
+
"",
|
|
14
|
+
"commands:",
|
|
15
|
+
" init detect stacks, write .repokeeper.yml and apply the standard",
|
|
16
|
+
" check report drift from the standard without writing (exit 1 on drift)",
|
|
17
|
+
" update move to the standard of this repokeeper version and resync",
|
|
18
|
+
"",
|
|
19
|
+
"options:",
|
|
20
|
+
" --dry-run show what would change without writing",
|
|
21
|
+
" --force write even when target files have uncommitted changes",
|
|
22
|
+
" --adopt <path> let repokeeper manage an existing file (repeatable); --adopt-all for every file",
|
|
23
|
+
" --accept <path> take repokeeper's version of a locally edited file (update, repeatable)",
|
|
24
|
+
" --stack <id> stack to use instead of detection (init, repeatable)",
|
|
25
|
+
" --relock rebuild .repokeeper/lock.json from the current files (init)",
|
|
26
|
+
" --json machine-readable output (check)",
|
|
27
|
+
" -v, --version print the version",
|
|
28
|
+
].join("\n");
|
|
29
|
+
const toPosix = (path) => path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
30
|
+
export async function run(argv, io) {
|
|
31
|
+
try {
|
|
32
|
+
const { values, positionals } = parseArgs({
|
|
33
|
+
args: argv,
|
|
34
|
+
allowPositionals: true,
|
|
35
|
+
strict: true,
|
|
36
|
+
options: {
|
|
37
|
+
"dry-run": { type: "boolean", default: false },
|
|
38
|
+
force: { type: "boolean", default: false },
|
|
39
|
+
adopt: { type: "string", multiple: true, default: [] },
|
|
40
|
+
"adopt-all": { type: "boolean", default: false },
|
|
41
|
+
accept: { type: "string", multiple: true, default: [] },
|
|
42
|
+
stack: { type: "string", multiple: true, default: [] },
|
|
43
|
+
relock: { type: "boolean", default: false },
|
|
44
|
+
json: { type: "boolean", default: false },
|
|
45
|
+
version: { type: "boolean", short: "v", default: false },
|
|
46
|
+
help: { type: "boolean", short: "h", default: false },
|
|
47
|
+
},
|
|
48
|
+
}).valueOf();
|
|
49
|
+
if (values.version) {
|
|
50
|
+
io.out(PACKAGE_VERSION);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
const [command] = positionals;
|
|
54
|
+
if (values.help || command === undefined) {
|
|
55
|
+
io.out(USAGE);
|
|
56
|
+
return command === undefined && !values.help ? 2 : 0;
|
|
57
|
+
}
|
|
58
|
+
const stacks = values.stack;
|
|
59
|
+
for (const stack of stacks) {
|
|
60
|
+
if (!STACK_IDS.includes(stack)) {
|
|
61
|
+
throw new UsageError(`unknown stack ${stack}; expected one of ${STACK_IDS.join(", ")}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const options = {
|
|
65
|
+
dryRun: values["dry-run"],
|
|
66
|
+
force: values.force,
|
|
67
|
+
adopt: values.adopt.map(toPosix),
|
|
68
|
+
adoptAll: values["adopt-all"],
|
|
69
|
+
accept: values.accept.map(toPosix),
|
|
70
|
+
stacks: stacks,
|
|
71
|
+
relock: values.relock,
|
|
72
|
+
json: values.json,
|
|
73
|
+
};
|
|
74
|
+
if (command === "init")
|
|
75
|
+
return await initCommand(io.cwd, options, io);
|
|
76
|
+
if (command === "check")
|
|
77
|
+
return await checkCommand(io.cwd, options, io);
|
|
78
|
+
if (command === "update")
|
|
79
|
+
return await updateCommand(io.cwd, options, io);
|
|
80
|
+
throw new UsageError(`unknown command: ${command}`);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error instanceof RepokeeperError) {
|
|
84
|
+
io.err(`repokeeper: ${error.message}`);
|
|
85
|
+
if (error instanceof UsageError)
|
|
86
|
+
io.err(USAGE);
|
|
87
|
+
return error.exitCode;
|
|
88
|
+
}
|
|
89
|
+
if (error instanceof TypeError && "code" in error && String(error.code).startsWith("ERR_PARSE_ARGS")) {
|
|
90
|
+
io.err(`repokeeper: ${error.message}`);
|
|
91
|
+
io.err(USAGE);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const invokedPath = process.argv[1];
|
|
98
|
+
if (invokedPath !== undefined && realpathSync(invokedPath) === fileURLToPath(import.meta.url)) {
|
|
99
|
+
run(process.argv.slice(2), {
|
|
100
|
+
cwd: process.cwd(),
|
|
101
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
102
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
103
|
+
}).then((code) => {
|
|
104
|
+
process.exitCode = code;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { loadConfig } from "../config/load.js";
|
|
2
|
+
import { UsageError } from "../errors.js";
|
|
3
|
+
import { outputId } from "../model.js";
|
|
4
|
+
import { planOutputs } from "../plan.js";
|
|
5
|
+
import { readLock } from "../sync/lock.js";
|
|
6
|
+
import { computeSync } from "../sync/sync.js";
|
|
7
|
+
import { compareVersions, STANDARD_VERSION } from "../version.js";
|
|
8
|
+
import { buildContext } from "./context.js";
|
|
9
|
+
import { hasDrift, printResult } from "./report.js";
|
|
10
|
+
export function assertSupportedStandard(standard) {
|
|
11
|
+
if (compareVersions(standard, STANDARD_VERSION) > 0) {
|
|
12
|
+
throw new UsageError(`this repository uses standard ${standard}, newer than ${STANDARD_VERSION}; upgrade repokeeper`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function checkCommand(root, options, io) {
|
|
16
|
+
const config = await loadConfig(root);
|
|
17
|
+
assertSupportedStandard(config.standard);
|
|
18
|
+
const lock = await readLock(root);
|
|
19
|
+
if (!lock) {
|
|
20
|
+
io.err("repokeeper: .repokeeper/lock.json is missing; run `repokeeper init --relock`");
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
const ctx = await buildContext(root, config);
|
|
24
|
+
const result = await computeSync(root, planOutputs(ctx), lock, { adopt: new Set(), accept: new Set() });
|
|
25
|
+
const behind = config.standard !== STANDARD_VERSION || lock.standard !== STANDARD_VERSION;
|
|
26
|
+
const clean = !hasDrift(result) && !behind;
|
|
27
|
+
if (options.json) {
|
|
28
|
+
io.out(JSON.stringify({
|
|
29
|
+
clean,
|
|
30
|
+
standard: { config: config.standard, lock: lock.standard, current: STANDARD_VERSION },
|
|
31
|
+
items: [
|
|
32
|
+
...result.decisions
|
|
33
|
+
.filter((d) => d.action !== "unchanged")
|
|
34
|
+
.map((d) => ({ id: outputId(d.output), path: d.output.path, action: d.action })),
|
|
35
|
+
...result.removals.map((r) => ({ id: r.entry.id, path: r.entry.target.path, action: r.action })),
|
|
36
|
+
],
|
|
37
|
+
}));
|
|
38
|
+
return clean ? 0 : 1;
|
|
39
|
+
}
|
|
40
|
+
printResult(io, result);
|
|
41
|
+
if (behind)
|
|
42
|
+
io.out(`standard ${config.standard} applied, ${STANDARD_VERSION} available (run \`repokeeper update\`)`);
|
|
43
|
+
io.out(clean ? "repository matches the standard" : "repository has drifted from the standard");
|
|
44
|
+
return clean ? 0 : 1;
|
|
45
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { repoInfo } from "../git.js";
|
|
2
|
+
import { githubPlatform } from "../platforms/github.js";
|
|
3
|
+
import { getStackPack } from "../stacks/index.js";
|
|
4
|
+
export async function buildContext(root, config, repo) {
|
|
5
|
+
const stacks = await Promise.all(config.stacks.map((id) => getStackPack(id).resolve(root, config.stack_options[id] ?? {})));
|
|
6
|
+
return { config, stacks, platform: githubPlatform, repo: repo ?? (await repoInfo(root)) };
|
|
7
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CONFIG_FILE, loadConfig, renderConfig } from "../config/load.js";
|
|
5
|
+
import { defaultConfig } from "../config/types.js";
|
|
6
|
+
import { UsageError } from "../errors.js";
|
|
7
|
+
import { dirtyPaths, gitUserName, repoInfo } from "../git.js";
|
|
8
|
+
import { outputId } from "../model.js";
|
|
9
|
+
import { planOutputs } from "../plan.js";
|
|
10
|
+
import { detectStacks } from "../stacks/index.js";
|
|
11
|
+
import { applySync } from "../sync/apply.js";
|
|
12
|
+
import { hashText } from "../sync/hash.js";
|
|
13
|
+
import { targetOf, writeLock } from "../sync/lock.js";
|
|
14
|
+
import { readCurrent } from "../sync/state.js";
|
|
15
|
+
import { computeSync, pathsToWrite } from "../sync/sync.js";
|
|
16
|
+
import { STANDARD_VERSION } from "../version.js";
|
|
17
|
+
import { buildContext } from "./context.js";
|
|
18
|
+
import { printResult } from "./report.js";
|
|
19
|
+
export async function guardUncommitted(root, result, force) {
|
|
20
|
+
if (force)
|
|
21
|
+
return;
|
|
22
|
+
const dirty = await dirtyPaths(root, pathsToWrite(result));
|
|
23
|
+
if (dirty.length === 0)
|
|
24
|
+
return;
|
|
25
|
+
const names = dirty.map((d) => (d.untracked ? `${d.path} (untracked)` : d.path)).join(", ");
|
|
26
|
+
const hint = dirty.some((d) => d.untracked)
|
|
27
|
+
? "; untracked files are protected too, so commit them first (git add -A && git commit), or pass --force"
|
|
28
|
+
: "; commit or stash them, or pass --force";
|
|
29
|
+
throw new UsageError(`uncommitted changes in ${names}${hint}`);
|
|
30
|
+
}
|
|
31
|
+
export async function initCommand(root, options, io) {
|
|
32
|
+
if (options.relock)
|
|
33
|
+
return relock(root, options, io);
|
|
34
|
+
if (existsSync(join(root, CONFIG_FILE))) {
|
|
35
|
+
throw new UsageError(`${CONFIG_FILE} already exists; run \`repokeeper update\` or \`repokeeper check\``);
|
|
36
|
+
}
|
|
37
|
+
const stacks = options.stacks.length > 0 ? options.stacks : await detectStacks(root);
|
|
38
|
+
if (stacks.length === 0)
|
|
39
|
+
throw new UsageError("no supported stack detected; pass --stack node");
|
|
40
|
+
const repo = await repoInfo(root);
|
|
41
|
+
const holder = (await gitUserName(root)) ?? repo.owner ?? "the project authors";
|
|
42
|
+
const config = defaultConfig({
|
|
43
|
+
stacks,
|
|
44
|
+
standard: STANDARD_VERSION,
|
|
45
|
+
copyright: `${new Date().getFullYear()} ${holder}`,
|
|
46
|
+
contact: repo.owner ? `https://github.com/${repo.owner}` : "the repository maintainers",
|
|
47
|
+
codeowners: repo.owner ? [`@${repo.owner}`] : [],
|
|
48
|
+
});
|
|
49
|
+
const ctx = await buildContext(root, config, repo);
|
|
50
|
+
const adopt = options.adoptAll ? "all" : new Set(options.adopt);
|
|
51
|
+
const result = await computeSync(root, planOutputs(ctx), null, { adopt, accept: new Set() });
|
|
52
|
+
if (!options.dryRun)
|
|
53
|
+
await guardUncommitted(root, result, options.force);
|
|
54
|
+
printResult(io, result);
|
|
55
|
+
if (options.dryRun) {
|
|
56
|
+
io.out("dry run: nothing written");
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
await writeFile(join(root, CONFIG_FILE), renderConfig(config));
|
|
60
|
+
await applySync(root, result, null, STANDARD_VERSION);
|
|
61
|
+
io.out(`applied standard ${STANDARD_VERSION}; wrote ${CONFIG_FILE}`);
|
|
62
|
+
io.out(`next: install dependencies (this installs the git hooks), then commit with "chore(repokeeper): apply standard ${STANDARD_VERSION}"`);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
/** Records the current content of every planned output as repokeeper's own, rebuilding a lost or corrupt lock. */
|
|
66
|
+
async function relock(root, options, io) {
|
|
67
|
+
const config = await loadConfig(root);
|
|
68
|
+
const ctx = await buildContext(root, config);
|
|
69
|
+
const lock = { lockVersion: 1, standard: config.standard, entries: [] };
|
|
70
|
+
for (const output of planOutputs(ctx)) {
|
|
71
|
+
const current = await readCurrent(root, targetOf(output));
|
|
72
|
+
if (current === null)
|
|
73
|
+
continue;
|
|
74
|
+
lock.entries.push({
|
|
75
|
+
id: outputId(output),
|
|
76
|
+
module: output.module,
|
|
77
|
+
hash: hashText(current),
|
|
78
|
+
target: targetOf(output),
|
|
79
|
+
});
|
|
80
|
+
io.out(`recorded ${output.path}`);
|
|
81
|
+
}
|
|
82
|
+
if (options.dryRun) {
|
|
83
|
+
io.out("dry run: nothing written");
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
await writeLock(root, lock);
|
|
87
|
+
io.out(`rebuilt .repokeeper/lock.json with ${lock.entries.length} entries`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describeOutput } from "../model.js";
|
|
2
|
+
const HINTS = {
|
|
3
|
+
conflict: (p) => `edited locally; take repokeeper's version with --accept ${p}, or add it to owned in .repokeeper.yml`,
|
|
4
|
+
unmanaged: (p) => `exists and is not managed; let repokeeper manage it with --adopt ${p}, or add it to owned`,
|
|
5
|
+
};
|
|
6
|
+
export function printResult(io, result) {
|
|
7
|
+
let unchanged = 0;
|
|
8
|
+
for (const { output, action } of result.decisions) {
|
|
9
|
+
if (action === "unchanged") {
|
|
10
|
+
unchanged++;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const hint = HINTS[action]?.(output.path);
|
|
14
|
+
io.out(`${action.padEnd(10)} ${describeOutput(output)}${hint ? ` (${hint})` : ""}`);
|
|
15
|
+
}
|
|
16
|
+
for (const { entry, action } of result.removals) {
|
|
17
|
+
if (action === "gone")
|
|
18
|
+
continue;
|
|
19
|
+
const label = action === "delete" ? "delete" : "kept";
|
|
20
|
+
const note = action === "delete" ? "" : " (no longer generated, but edited locally; left in place)";
|
|
21
|
+
io.out(`${label.padEnd(10)} ${entry.target.path}${note}`);
|
|
22
|
+
}
|
|
23
|
+
io.out(`${unchanged} output(s) already match the standard`);
|
|
24
|
+
}
|
|
25
|
+
export function hasDrift(result) {
|
|
26
|
+
return result.decisions.some((d) => d.action !== "unchanged") || result.removals.length > 0;
|
|
27
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CONFIG_FILE, loadConfig, setStandard } from "../config/load.js";
|
|
4
|
+
import { UsageError } from "../errors.js";
|
|
5
|
+
import { planOutputs } from "../plan.js";
|
|
6
|
+
import { applySync } from "../sync/apply.js";
|
|
7
|
+
import { readLock } from "../sync/lock.js";
|
|
8
|
+
import { computeSync } from "../sync/sync.js";
|
|
9
|
+
import { STANDARD_VERSION } from "../version.js";
|
|
10
|
+
import { assertSupportedStandard } from "./check.js";
|
|
11
|
+
import { buildContext } from "./context.js";
|
|
12
|
+
import { guardUncommitted } from "./init.js";
|
|
13
|
+
import { printResult } from "./report.js";
|
|
14
|
+
export async function updateCommand(root, options, io) {
|
|
15
|
+
const config = await loadConfig(root);
|
|
16
|
+
assertSupportedStandard(config.standard);
|
|
17
|
+
const lock = await readLock(root);
|
|
18
|
+
if (!lock)
|
|
19
|
+
throw new UsageError(".repokeeper/lock.json is missing; run `repokeeper init --relock` first");
|
|
20
|
+
const ctx = await buildContext(root, { ...config, standard: STANDARD_VERSION });
|
|
21
|
+
const adopt = options.adoptAll ? "all" : new Set(options.adopt);
|
|
22
|
+
const result = await computeSync(root, planOutputs(ctx), lock, { adopt, accept: new Set(options.accept) });
|
|
23
|
+
if (!options.dryRun)
|
|
24
|
+
await guardUncommitted(root, result, options.force);
|
|
25
|
+
printResult(io, result);
|
|
26
|
+
if (options.dryRun) {
|
|
27
|
+
io.out("dry run: nothing written");
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
await applySync(root, result, lock, STANDARD_VERSION);
|
|
31
|
+
if (config.standard !== STANDARD_VERSION) {
|
|
32
|
+
const path = join(root, CONFIG_FILE);
|
|
33
|
+
await writeFile(path, setStandard(await readFile(path, "utf8"), STANDARD_VERSION));
|
|
34
|
+
}
|
|
35
|
+
const conflicts = result.decisions.filter((d) => d.action === "conflict");
|
|
36
|
+
if (conflicts.length > 0) {
|
|
37
|
+
io.out(`${conflicts.length} file(s) kept because they were edited locally; new versions are beside them as *.repokeeper-new`);
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
io.out(`repository is on standard ${STANDARD_VERSION}; commit with "chore(repokeeper): update standard to ${STANDARD_VERSION}"`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Ajv } from "ajv";
|
|
4
|
+
import { isNode, LineCounter, parseDocument, stringify } from "yaml";
|
|
5
|
+
import { ConfigError } from "../errors.js";
|
|
6
|
+
import { configSchema } from "./schema.js";
|
|
7
|
+
export const CONFIG_FILE = ".repokeeper.yml";
|
|
8
|
+
const validate = new Ajv({ allErrors: false }).compile(configSchema);
|
|
9
|
+
export async function loadConfig(root) {
|
|
10
|
+
let text;
|
|
11
|
+
try {
|
|
12
|
+
text = await readFile(join(root, CONFIG_FILE), "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new ConfigError(`${CONFIG_FILE} not found; run \`repokeeper init\` first`);
|
|
16
|
+
}
|
|
17
|
+
return parseConfig(text);
|
|
18
|
+
}
|
|
19
|
+
export function parseConfig(text) {
|
|
20
|
+
const lineCounter = new LineCounter();
|
|
21
|
+
const doc = parseDocument(text, { lineCounter });
|
|
22
|
+
const syntax = doc.errors[0];
|
|
23
|
+
if (syntax) {
|
|
24
|
+
const line = syntax.linePos?.[0]?.line ?? 1;
|
|
25
|
+
throw new ConfigError(`${CONFIG_FILE}:${line}: ${syntax.message.split("\n")[0]}`);
|
|
26
|
+
}
|
|
27
|
+
const data = doc.toJS() ?? {};
|
|
28
|
+
if (!validate(data)) {
|
|
29
|
+
const error = validate.errors?.[0];
|
|
30
|
+
const path = pathOf(error);
|
|
31
|
+
throw new ConfigError(`${CONFIG_FILE}:${lineOf(doc, lineCounter, path)}: ${describe(error, path)}`);
|
|
32
|
+
}
|
|
33
|
+
return withDefaults(data);
|
|
34
|
+
}
|
|
35
|
+
export function renderConfig(config) {
|
|
36
|
+
return `# repokeeper configuration: https://github.com/vannt-dev/repokeeper\n${stringify(config)}`;
|
|
37
|
+
}
|
|
38
|
+
/** Rewrites `standard:` in place, keeping every comment and the rest of the layout. */
|
|
39
|
+
export function setStandard(text, version) {
|
|
40
|
+
const doc = parseDocument(text);
|
|
41
|
+
doc.set("standard", version);
|
|
42
|
+
return doc.toString();
|
|
43
|
+
}
|
|
44
|
+
function withDefaults(data) {
|
|
45
|
+
const modules = (data.modules ?? {});
|
|
46
|
+
if (modules.health === undefined) {
|
|
47
|
+
throw new ConfigError(`${CONFIG_FILE}: modules.health is required; set it to false to leave community health files alone`);
|
|
48
|
+
}
|
|
49
|
+
const config = {
|
|
50
|
+
schema: data.schema,
|
|
51
|
+
standard: data.standard,
|
|
52
|
+
platform: data.platform,
|
|
53
|
+
stacks: data.stacks,
|
|
54
|
+
modules: {
|
|
55
|
+
editorconfig: modules.editorconfig ?? true,
|
|
56
|
+
commits: modules.commits ?? true,
|
|
57
|
+
hooks: modules.hooks ?? true,
|
|
58
|
+
ci: modules.ci ?? true,
|
|
59
|
+
release: modules.release ?? true,
|
|
60
|
+
deps: modules.deps ?? true,
|
|
61
|
+
gitignore: modules.gitignore ?? true,
|
|
62
|
+
health: modules.health,
|
|
63
|
+
},
|
|
64
|
+
owned: data.owned ?? [],
|
|
65
|
+
stack_options: data.stack_options ?? {},
|
|
66
|
+
};
|
|
67
|
+
if (data.github !== undefined)
|
|
68
|
+
config.github = data.github;
|
|
69
|
+
return config;
|
|
70
|
+
}
|
|
71
|
+
function pathOf(error) {
|
|
72
|
+
const segments = error.instancePath
|
|
73
|
+
.split("/")
|
|
74
|
+
.slice(1)
|
|
75
|
+
.map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"))
|
|
76
|
+
.map((s) => (/^\d+$/.test(s) ? Number(s) : s));
|
|
77
|
+
if (error.keyword === "additionalProperties") {
|
|
78
|
+
segments.push(error.params.additionalProperty);
|
|
79
|
+
}
|
|
80
|
+
return segments;
|
|
81
|
+
}
|
|
82
|
+
function describe(error, path) {
|
|
83
|
+
const name = path.length > 0 ? path.join(".") : "(root)";
|
|
84
|
+
if (error.keyword === "additionalProperties")
|
|
85
|
+
return `${name} is not a known key`;
|
|
86
|
+
if (error.keyword === "enum") {
|
|
87
|
+
const allowed = error.params.allowedValues;
|
|
88
|
+
return `${name} must be one of: ${allowed.join(", ")}`;
|
|
89
|
+
}
|
|
90
|
+
return `${name} ${error.message ?? "is invalid"}`;
|
|
91
|
+
}
|
|
92
|
+
function lineOf(doc, lineCounter, path) {
|
|
93
|
+
for (let length = path.length; length > 0; length--) {
|
|
94
|
+
const node = doc.getIn(path.slice(0, length), true);
|
|
95
|
+
if (isNode(node) && node.range)
|
|
96
|
+
return lineCounter.linePos(node.range[0]).line;
|
|
97
|
+
}
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { STACK_IDS } from "./types.js";
|
|
2
|
+
const flag = { type: "boolean" };
|
|
3
|
+
export const configSchema = {
|
|
4
|
+
type: "object",
|
|
5
|
+
additionalProperties: false,
|
|
6
|
+
required: ["schema", "standard", "platform", "stacks"],
|
|
7
|
+
properties: {
|
|
8
|
+
schema: { const: 1 },
|
|
9
|
+
standard: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
|
|
10
|
+
platform: { enum: ["github"] },
|
|
11
|
+
stacks: { type: "array", minItems: 1, uniqueItems: true, items: { enum: [...STACK_IDS] } },
|
|
12
|
+
modules: {
|
|
13
|
+
type: "object",
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
properties: {
|
|
16
|
+
editorconfig: flag,
|
|
17
|
+
commits: flag,
|
|
18
|
+
hooks: flag,
|
|
19
|
+
ci: flag,
|
|
20
|
+
release: flag,
|
|
21
|
+
deps: flag,
|
|
22
|
+
gitignore: flag,
|
|
23
|
+
health: {
|
|
24
|
+
anyOf: [
|
|
25
|
+
{ const: false },
|
|
26
|
+
{
|
|
27
|
+
type: "object",
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
required: ["license", "copyright", "contact", "codeowners"],
|
|
30
|
+
properties: {
|
|
31
|
+
license: { anyOf: [{ const: false }, { type: "string", minLength: 1 }] },
|
|
32
|
+
copyright: { type: "string", minLength: 1 },
|
|
33
|
+
contact: { type: "string", minLength: 1 },
|
|
34
|
+
codeowners: { type: "array", items: { type: "string", pattern: "^@" } },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
owned: { type: "array", items: { type: "string", minLength: 1 } },
|
|
42
|
+
stack_options: { type: "object" },
|
|
43
|
+
github: { type: "object" },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const STACK_IDS = ["node", "python", "dart", "script", "java", "dotnet"];
|
|
2
|
+
export function defaultConfig(input) {
|
|
3
|
+
return {
|
|
4
|
+
schema: 1,
|
|
5
|
+
standard: input.standard,
|
|
6
|
+
platform: "github",
|
|
7
|
+
stacks: input.stacks,
|
|
8
|
+
modules: {
|
|
9
|
+
editorconfig: true,
|
|
10
|
+
commits: true,
|
|
11
|
+
hooks: true,
|
|
12
|
+
ci: true,
|
|
13
|
+
release: true,
|
|
14
|
+
deps: true,
|
|
15
|
+
gitignore: true,
|
|
16
|
+
health: { license: "MIT", copyright: input.copyright, contact: input.contact, codeowners: input.codeowners },
|
|
17
|
+
},
|
|
18
|
+
owned: [],
|
|
19
|
+
stack_options: {},
|
|
20
|
+
};
|
|
21
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class RepokeeperError extends Error {
|
|
2
|
+
exitCode;
|
|
3
|
+
constructor(message, exitCode) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.exitCode = exitCode;
|
|
6
|
+
this.name = new.target.name;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** `.repokeeper.yml` is missing or invalid. */
|
|
10
|
+
export class ConfigError extends RepokeeperError {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message, 2);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** The command cannot run as requested. */
|
|
16
|
+
export class UsageError extends RepokeeperError {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message, 2);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** `.repokeeper/lock.json` is unreadable; the repository state is unknown, which counts as drift. */
|
|
22
|
+
export class LockError extends RepokeeperError {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message, 1);
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
async function git(root, args) {
|
|
6
|
+
try {
|
|
7
|
+
const { stdout } = await execFileAsync("git", args, { cwd: root, encoding: "utf8" });
|
|
8
|
+
return stdout;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function isGitRepo(root) {
|
|
15
|
+
return (await git(root, ["rev-parse", "--is-inside-work-tree"]))?.trim() === "true";
|
|
16
|
+
}
|
|
17
|
+
export function parseRemoteUrl(url) {
|
|
18
|
+
const match = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url.trim());
|
|
19
|
+
return match ? { owner: match[1], name: match[2] } : null;
|
|
20
|
+
}
|
|
21
|
+
export async function repoInfo(root) {
|
|
22
|
+
const url = await git(root, ["remote", "get-url", "origin"]);
|
|
23
|
+
return (url ? parseRemoteUrl(url) : null) ?? { owner: null, name: basename(root) };
|
|
24
|
+
}
|
|
25
|
+
export async function gitUserName(root) {
|
|
26
|
+
return (await git(root, ["config", "user.name"]))?.trim() || null;
|
|
27
|
+
}
|
|
28
|
+
/** Paths among `paths` with uncommitted changes, including untracked files. Empty outside git. */
|
|
29
|
+
export async function dirtyPaths(root, paths) {
|
|
30
|
+
if (paths.length === 0 || !(await isGitRepo(root)))
|
|
31
|
+
return [];
|
|
32
|
+
const out = await git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...paths]);
|
|
33
|
+
if (!out)
|
|
34
|
+
return [];
|
|
35
|
+
const tokens = out.split("\0").filter(Boolean);
|
|
36
|
+
const dirty = [];
|
|
37
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
38
|
+
const token = tokens[i];
|
|
39
|
+
dirty.push({ path: token.slice(3), untracked: token.startsWith("??") });
|
|
40
|
+
if (token.startsWith("R") || token.startsWith("C"))
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
return dirty;
|
|
44
|
+
}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const MANAGED_HEADER = "Managed by repokeeper (https://github.com/vannt-dev/repokeeper). Edits are reported by `repokeeper check`.";
|
|
2
|
+
/** Header of YAML files repokeeper creates but owns only in part. */
|
|
3
|
+
export const YAML_HEADER = `${MANAGED_HEADER} Keys and jobs you add yourself are left alone.`;
|
|
4
|
+
export function outputId(output) {
|
|
5
|
+
if (output.kind === "file")
|
|
6
|
+
return `file:${output.path}`;
|
|
7
|
+
if (output.kind === "seed")
|
|
8
|
+
return `seed:${output.path}`;
|
|
9
|
+
if (output.kind === "block")
|
|
10
|
+
return `block:${output.path}#${output.id}`;
|
|
11
|
+
if (output.kind === "yaml")
|
|
12
|
+
return `yaml:${output.path}#${JSON.stringify(output.keyPath)}`;
|
|
13
|
+
return `json:${output.path}#${JSON.stringify(output.keyPath)}`;
|
|
14
|
+
}
|
|
15
|
+
export function describeOutput(output) {
|
|
16
|
+
if (output.kind === "file" || output.kind === "seed")
|
|
17
|
+
return output.path;
|
|
18
|
+
if (output.kind === "block")
|
|
19
|
+
return `${output.path} (block ${output.id})`;
|
|
20
|
+
return `${output.path} (${output.keyPath.join(".")})`;
|
|
21
|
+
}
|