repokeeper 0.4.7 → 0.6.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.
Files changed (45) hide show
  1. package/.github/workflows/commitlint.yml +49 -0
  2. package/.github/workflows/release-please.yml +59 -0
  3. package/.github/workflows/repokeeper-check.yml +30 -0
  4. package/.github/workflows/stack-dart.yml +72 -0
  5. package/.github/workflows/stack-dotnet.yml +68 -0
  6. package/.github/workflows/stack-go.yml +59 -0
  7. package/.github/workflows/stack-java.yml +70 -0
  8. package/.github/workflows/stack-node.yml +85 -0
  9. package/.github/workflows/stack-php.yml +66 -0
  10. package/.github/workflows/stack-python.yml +70 -0
  11. package/.github/workflows/stack-ruby.yml +65 -0
  12. package/.github/workflows/stack-rust.yml +60 -0
  13. package/.github/workflows/stack-script.yml +87 -0
  14. package/README.md +60 -63
  15. package/dist/cli.js +56 -8
  16. package/dist/commands/context.js +26 -3
  17. package/dist/commands/eject.js +74 -0
  18. package/dist/commands/github.js +2 -0
  19. package/dist/commands/gitlab.js +46 -0
  20. package/dist/commands/init.js +110 -13
  21. package/dist/commands/presets.js +114 -0
  22. package/dist/commands/report.js +23 -3
  23. package/dist/commands/update.js +3 -2
  24. package/dist/config/load.js +8 -1
  25. package/dist/config/schema.js +45 -1
  26. package/dist/config/types.js +3 -3
  27. package/dist/duplicates.js +2 -2
  28. package/dist/errors.js +1 -1
  29. package/dist/git.js +33 -4
  30. package/dist/gitlab/api.js +50 -0
  31. package/dist/gitlab/settings.js +181 -0
  32. package/dist/model.js +8 -0
  33. package/dist/modules/commits.js +3 -2
  34. package/dist/modules/deps.js +1 -1
  35. package/dist/modules/health.js +5 -8
  36. package/dist/modules/hooks.js +7 -5
  37. package/dist/modules/release.js +18 -5
  38. package/dist/platforms/github.js +77 -13
  39. package/dist/platforms/gitlab.js +233 -0
  40. package/dist/platforms/index.js +5 -0
  41. package/dist/stacks/index.js +56 -0
  42. package/dist/stacks/support.js +19 -0
  43. package/dist/templates.js +13 -1
  44. package/dist/version.js +9 -0
  45. package/package.json +6 -2
@@ -0,0 +1,65 @@
1
+ name: stack-ruby
2
+
3
+ on:
4
+ workflow_call:
5
+ inputs:
6
+ ruby-versions:
7
+ description: JSON list of Ruby versions
8
+ type: string
9
+ default: '["3.3","3.4"]'
10
+ os:
11
+ description: JSON list of runner labels
12
+ type: string
13
+ default: '["ubuntu-latest"]'
14
+ install-command:
15
+ description: Command that installs dependencies
16
+ type: string
17
+ default: bundle install
18
+ commands:
19
+ description: JSON list of shell commands run in order
20
+ type: string
21
+ default: "[]"
22
+ working-directory:
23
+ description: Directory holding the Gemfile
24
+ type: string
25
+ default: .
26
+
27
+ permissions:
28
+ contents: read
29
+
30
+ jobs:
31
+ ruby:
32
+ name: ruby ${{ matrix.ruby }} (${{ matrix.os }})
33
+ strategy:
34
+ fail-fast: false
35
+ matrix:
36
+ os: ${{ fromJSON(inputs.os) }}
37
+ ruby: ${{ fromJSON(inputs.ruby-versions) }}
38
+ runs-on: ${{ matrix.os }}
39
+ permissions:
40
+ contents: read
41
+ defaults:
42
+ run:
43
+ shell: bash
44
+ working-directory: ${{ inputs.working-directory }}
45
+ steps:
46
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
47
+ with:
48
+ persist-credentials: false
49
+ - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
50
+ with:
51
+ ruby-version: ${{ matrix.ruby }}
52
+ working-directory: ${{ inputs.working-directory }}
53
+ - name: Install dependencies
54
+ env:
55
+ INSTALL: ${{ inputs.install-command }}
56
+ run: bash -c "$INSTALL"
57
+ - name: Run checks
58
+ env:
59
+ COMMANDS: ${{ inputs.commands }}
60
+ run: |
61
+ node -e 'for (const c of JSON.parse(process.env.COMMANDS)) console.log(c)' | while IFS= read -r command; do
62
+ echo "::group::$command"
63
+ bash -c "$command" </dev/null
64
+ echo "::endgroup::"
65
+ done
@@ -0,0 +1,60 @@
1
+ name: stack-rust
2
+
3
+ on:
4
+ workflow_call:
5
+ inputs:
6
+ rust-versions:
7
+ description: JSON list of rustup toolchains ("stable", "1.85.0")
8
+ type: string
9
+ default: '["stable"]'
10
+ os:
11
+ description: JSON list of runner labels
12
+ type: string
13
+ default: '["ubuntu-latest"]'
14
+ commands:
15
+ description: JSON list of shell commands run in order
16
+ type: string
17
+ default: '["cargo test --all-targets"]'
18
+ working-directory:
19
+ description: Directory holding Cargo.toml
20
+ type: string
21
+ default: .
22
+
23
+ permissions:
24
+ contents: read
25
+
26
+ jobs:
27
+ rust:
28
+ name: rust ${{ matrix.rust }} (${{ matrix.os }})
29
+ strategy:
30
+ fail-fast: false
31
+ matrix:
32
+ os: ${{ fromJSON(inputs.os) }}
33
+ rust: ${{ fromJSON(inputs.rust-versions) }}
34
+ runs-on: ${{ matrix.os }}
35
+ permissions:
36
+ contents: read
37
+ defaults:
38
+ run:
39
+ shell: bash
40
+ working-directory: ${{ inputs.working-directory }}
41
+ steps:
42
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
43
+ with:
44
+ persist-credentials: false
45
+ - name: Install the toolchain
46
+ env:
47
+ TOOLCHAIN: ${{ matrix.rust }}
48
+ # rustup ships on the hosted runners
49
+ run: |
50
+ rustup toolchain install "$TOOLCHAIN" --profile minimal --component rustfmt,clippy
51
+ rustup default "$TOOLCHAIN"
52
+ - name: Run checks
53
+ env:
54
+ COMMANDS: ${{ inputs.commands }}
55
+ run: |
56
+ node -e 'for (const c of JSON.parse(process.env.COMMANDS)) console.log(c)' | while IFS= read -r command; do
57
+ echo "::group::$command"
58
+ bash -c "$command" </dev/null
59
+ echo "::endgroup::"
60
+ done
@@ -0,0 +1,87 @@
1
+ name: stack-script
2
+
3
+ on:
4
+ workflow_call:
5
+ inputs:
6
+ shell-scripts:
7
+ description: '"true" to run ShellCheck and shfmt on *.sh'
8
+ type: string
9
+ default: "true"
10
+ powershell-scripts:
11
+ description: '"true" to run PSScriptAnalyzer on *.ps1'
12
+ type: string
13
+ default: "false"
14
+ test-command:
15
+ description: Test command run on Linux and Windows; empty for none
16
+ type: string
17
+ default: ""
18
+ working-directory:
19
+ description: Directory the checks start from
20
+ type: string
21
+ default: .
22
+
23
+ permissions:
24
+ contents: read
25
+
26
+ jobs:
27
+ shell:
28
+ if: inputs.shell-scripts == 'true'
29
+ runs-on: ubuntu-latest
30
+ permissions:
31
+ contents: read
32
+ defaults:
33
+ run:
34
+ working-directory: ${{ inputs.working-directory }}
35
+ steps:
36
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
37
+ with:
38
+ persist-credentials: false
39
+ - name: ShellCheck
40
+ run: git ls-files -z '*.sh' | xargs -0 --no-run-if-empty shellcheck
41
+ - name: shfmt
42
+ run: |
43
+ curl -fsSL -o "$RUNNER_TEMP/shfmt" https://github.com/mvdan/sh/releases/download/v3.14.1/shfmt_v3.14.1_linux_amd64
44
+ chmod +x "$RUNNER_TEMP/shfmt"
45
+ git ls-files -z '*.sh' | xargs -0 --no-run-if-empty "$RUNNER_TEMP/shfmt" -d
46
+ powershell:
47
+ if: inputs.powershell-scripts == 'true'
48
+ runs-on: windows-latest
49
+ permissions:
50
+ contents: read
51
+ defaults:
52
+ run:
53
+ working-directory: ${{ inputs.working-directory }}
54
+ steps:
55
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
56
+ with:
57
+ persist-credentials: false
58
+ - name: PSScriptAnalyzer
59
+ shell: pwsh
60
+ run: |
61
+ Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Force -Scope CurrentUser
62
+ # a repository settings file decides the rules; otherwise only errors and warnings fail the job
63
+ $issues = if (Test-Path PSScriptAnalyzerSettings.psd1) {
64
+ Invoke-ScriptAnalyzer -Path . -Recurse -Settings PSScriptAnalyzerSettings.psd1
65
+ } else {
66
+ Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error, Warning
67
+ }
68
+ $issues | Format-Table -AutoSize
69
+ if ($issues) { exit 1 }
70
+ test:
71
+ if: inputs.test-command != ''
72
+ strategy:
73
+ fail-fast: false
74
+ matrix:
75
+ os: [ubuntu-latest, windows-latest]
76
+ runs-on: ${{ matrix.os }}
77
+ permissions:
78
+ contents: read
79
+ defaults:
80
+ run:
81
+ shell: bash
82
+ working-directory: ${{ inputs.working-directory }}
83
+ steps:
84
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
85
+ with:
86
+ persist-credentials: false
87
+ - run: ${{ inputs.test-command }}
package/README.md CHANGED
@@ -6,12 +6,40 @@ the standard evolves.
6
6
 
7
7
  > Status: early development. Supported stacks: Node.js (including NestJS), Python, Dart and
8
8
  > Flutter, shell and PowerShell scripts, Java (Maven and Gradle), Kotlin, .NET, Go, Rust, PHP and
9
- > Ruby, each with CI and releases, plus GitHub settings through `repokeeper github apply`. See the
9
+ > Ruby, each with CI and releases, plus GitHub settings through `repokeeper github apply`. GitLab is
10
+ > supported for Node.js and Go projects, with project settings through `repokeeper gitlab apply`. See the
10
11
  > [design](docs/superpowers/specs/2026-09-25-repokeeper-design.md).
11
12
 
13
+ ## How it works
14
+
15
+ ```mermaid
16
+ flowchart LR
17
+ config[".repokeeper.yml<br/>stacks, modules, owned"]
18
+ tool(["repokeeper<br/>init, update, check"])
19
+ files["Managed files<br/>.editorconfig, .gitignore, lefthook.yml,<br/>commitlint, CONTRIBUTING, LICENSE,<br/>dependabot.yml, ci.yml, release.yml"]
20
+ lock[".repokeeper/lock.json<br/>what repokeeper wrote"]
21
+ workflows["Reusable workflows<br/>vannt-dev/repokeeper at v0,<br/>or pinned, mirrored, local"]
22
+ config --> tool
23
+ tool -->|"init, update: write,<br/>keep your edits"| files
24
+ tool -.->|"check: compare,<br/>exit 1 on drift"| files
25
+ tool --> lock
26
+ files -->|"ci.yml, release.yml call"| workflows
27
+ ```
28
+
29
+ One file describes the repository (`.repokeeper.yml`); repokeeper turns it into the managed files and
30
+ remembers what it wrote, so it can tell your edits from its own. `check` only compares; `update`
31
+ moves the files to a newer standard and leaves alone what you changed or listed under `owned`.
32
+
12
33
  ## Usage
13
34
 
14
- repokeeper is not on npm yet. Until the first release, build it from source and link the command:
35
+ repokeeper is [on npm](https://www.npmjs.com/package/repokeeper). Install it once, or run it without
36
+ installing by putting `npx` in front of each command below:
37
+
38
+ ```bash
39
+ npm install --global repokeeper
40
+ ```
41
+
42
+ To work on repokeeper itself, build it from source and link the command instead:
15
43
 
16
44
  ```bash
17
45
  git clone https://github.com/vannt-dev/repokeeper.git
@@ -33,6 +61,29 @@ yours. `owned` also takes a single key of a shared file, such as `.github/workfl
33
61
  `package.json#devDependencies.lefthook`; repokeeper then leaves that key, and everything under it,
34
62
  alone. Every write command accepts `--dry-run`.
35
63
 
64
+ `init` applies the whole standard unless you say otherwise:
65
+
66
+ - `repokeeper init --preset essential` writes only the editor settings, `.gitignore` and the CI
67
+ workflow. `standard` is the default. `strict` adds the drift check to CI and, on GitHub, writes
68
+ branch protection (pull requests with one approval, no force push, the commit check required) and
69
+ Dependabot security updates into `.repokeeper.yml`; `repokeeper github apply` then puts them in
70
+ place. There is no preset that promises code or secret scanning: repokeeper does not set those up.
71
+ - `repokeeper init --interactive` (or `-i`) asks about each part in turn, naming the files it would
72
+ write, shows the result, and asks once more before writing anything. Enter keeps the preset's
73
+ answer. Add `--dry-run` to go through the questions without the possibility of writing.
74
+
75
+ Either way the choice ends up as plain `modules:` switches in `.repokeeper.yml`, which you can change
76
+ later and apply with `repokeeper update`.
77
+
78
+ You can also start from the file instead of from detection: put a `.repokeeper.yml` you wrote
79
+ yourself in the repository and run `repokeeper init`. When the file is there and repokeeper has not
80
+ applied anything yet, `init` applies it as it is written. Nothing is detected, `--stack`,
81
+ `--platform` and `--preset` are refused, and the only line it changes is `standard:`, which it sets
82
+ to the standard it applied.
83
+
84
+ The [playground](https://vannt-dev.github.io/repokeeper/) writes that file for you: tick the stacks
85
+ and the parts you want, and it shows the `.repokeeper.yml` and the command to run.
86
+
36
87
  `init` reads the default branch from `origin/HEAD` and records it as `github.default_branch` when it
37
88
  isn't `main`. The release manifest starts from the latest `vX.Y.Z` tag when the stack has no version
38
89
  of its own.
@@ -48,68 +99,14 @@ projects, which the dotnet CLI can't build.
48
99
 
49
100
  Requires Node.js 22.12 or newer.
50
101
 
51
- ## CI and releases
52
-
53
- `ci.yml` and `release.yml` call reusable workflows from this repository (`stack-node.yml`,
54
- `commitlint.yml`, `release-please.yml`) at the moving major tag, so fixes reach every repository
55
- without a pull request. repokeeper owns the `name`, `on`, `permissions` and `concurrency` keys and
56
- the jobs it adds; jobs you add yourself are left alone, and so is the formatting of the rest of the
57
- file. A new push to a pull request cancels that pull request's earlier `ci` run; runs on the default
58
- branch always finish.
59
-
60
- The script stack runs ShellCheck and `shfmt -d` on `*.sh` (format with `shfmt -w` before pushing)
61
- and PSScriptAnalyzer on `*.ps1`, which fails on errors and warnings (not on information-level rules). To
62
- choose the rules yourself, add a `PSScriptAnalyzerSettings.psd1` at the repository root; the job then
63
- uses it instead of its own filter, so keep `Severity` in it unless you want information-level rules too:
64
-
65
- ```powershell
66
- @{
67
- Severity = @('Error', 'Warning')
68
- # installers print for the person running them
69
- ExcludeRules = @('PSAvoidUsingWriteHost')
70
- }
71
- ```
72
-
73
- `release.yml` runs [release-please](https://github.com/googleapis/release-please): it keeps a release
74
- pull request open, and merging it tags the release and updates `CHANGELOG.md`. Two settings make this
75
- work:
76
-
77
- - In the repository settings, under Actions → General, allow GitHub Actions to create and approve
78
- pull requests.
79
- - Optionally add a `RELEASE_PLEASE_TOKEN` secret (a fine-grained token with contents, pull requests
80
- and issues write access). Without it the release pull request is opened with `GITHUB_TOKEN`, and
81
- GitHub holds its `pull_request` runs until someone approves them; the `release-pr-ci` job approves
82
- them, so the pull request gets its checks and required checks in a ruleset can pass.
83
-
84
- A repository with no release yet (manifest at `0.0.0`) gets `initial-version: 0.1.0`, so its first
85
- release is 0.1.0 rather than release-please's default 1.0.0.
86
-
87
- Set `modules.drift: true` to add a `repokeeper` job to `ci.yml` that runs `repokeeper check` with
88
- the version that wrote the standard, so a pull request that edits a managed file fails until the edit
89
- is resolved.
90
-
91
- ## GitHub settings
92
-
93
- `repokeeper github apply` brings the repository's settings in line with the `github:` section of
94
- `.repokeeper.yml`. Only the keys you write are managed; anything left out stays as it is.
95
-
96
- ```yaml
97
- github:
98
- description: Keeps repositories on one standard
99
- topics: [cli, conventional-commits]
100
- merge: { squash: true, merge_commit: false, rebase: false, delete_branch_on_merge: true }
101
- security: { dependabot_alerts: true, dependabot_security_updates: true }
102
- protect: # a ruleset named "repokeeper" on the default branch; false removes it
103
- require_pull_request: true
104
- required_approvals: 0
105
- required_checks: ["commits / commitlint"] # check names exactly as pull requests show them
106
- allow_force_push: false
107
- ```
102
+ ## Guides
108
103
 
109
- It prints every change first and applies them with `--yes`, or after you confirm in a terminal;
110
- `--dry-run` only prints. The token comes from `GITHUB_TOKEN` or `gh auth token` and needs admin
111
- access to the repository. Legacy branch protection, visibility, secrets and collaborators are never
112
- touched.
104
+ | Guide | What it covers |
105
+ | --- | --- |
106
+ | [CI and releases](docs/ci-and-releases.md) | The workflows repokeeper writes, pinning or mirroring the reusable workflows, `repokeeper eject`, script linting, release-please, the drift check |
107
+ | [Monorepos](docs/monorepos.md) | Stacks in folders: `stack_options.<stack>.directory`, what runs where, the limits |
108
+ | [GitHub settings](docs/github-settings.md) | `repokeeper github apply`: description, topics, merge settings, security, branch protection |
109
+ | [GitLab](docs/gitlab.md) | What differs on GitLab, the two jobs that need a token, and `repokeeper gitlab apply`: merge settings, branch protection, the Renovate schedule |
113
110
 
114
111
  ## Pilots
115
112
 
package/dist/cli.js CHANGED
@@ -4,8 +4,11 @@ import { createInterface } from "node:readline/promises";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { parseArgs } from "node:util";
6
6
  import { checkCommand } from "./commands/check.js";
7
+ import { ejectCommand } from "./commands/eject.js";
7
8
  import { githubApplyCommand } from "./commands/github.js";
9
+ import { gitlabApplyCommand } from "./commands/gitlab.js";
8
10
  import { initCommand } from "./commands/init.js";
11
+ import { PRESET_SUMMARY, PRESETS } from "./commands/presets.js";
9
12
  import { updateCommand } from "./commands/update.js";
10
13
  import { STACK_IDS } from "./config/types.js";
11
14
  import { RepokeeperError, UsageError } from "./errors.js";
@@ -14,20 +17,26 @@ export const USAGE = [
14
17
  "usage: repokeeper <command> [options]",
15
18
  "",
16
19
  "commands:",
17
- " init detect stacks, write .repokeeper.yml and apply the standard",
20
+ " init detect stacks, write .repokeeper.yml and apply the standard (or apply a .repokeeper.yml you wrote)",
18
21
  " check report drift from the standard without writing (exit 1 on drift)",
19
22
  " update move to the standard of this repokeeper version and resync",
20
23
  " github apply diff the GitHub settings against .repokeeper.yml and apply them",
24
+ " gitlab apply the same for a GitLab project: settings, branch protection, the Renovate schedule",
25
+ " eject copy the reusable CI workflows into this repository and call them from there",
21
26
  "",
22
27
  "options:",
23
28
  " --dry-run show what would change without writing",
24
29
  " --force write even when target files have uncommitted changes",
25
30
  " --adopt <path> let repokeeper manage an existing file (repeatable); --adopt-all for every file",
26
31
  " --accept <path> take repokeeper's version of a locally edited file (update, repeatable)",
27
- " --stack <id> stack to use instead of detection (init, repeatable)",
32
+ " --stack <id> stack to use instead of detection (init, repeatable); id:folder for a stack in a folder",
33
+ " --platform <id> github or gitlab, instead of detection from the origin remote (init)",
34
+ ` --preset <name> ${PRESETS.map((name) => `${name}: ${PRESET_SUMMARY[name]}`).join("; ")} (init)`,
35
+ " -i, --interactive ask which parts to apply, naming the files of each, and confirm before writing (init)",
28
36
  " --relock rebuild .repokeeper/lock.json from the current files (init)",
37
+ " --to <folder> write every reusable workflow into another repository's folder instead (eject)",
29
38
  " --json machine-readable output (check)",
30
- " -y, --yes apply without asking (github apply)",
39
+ " -y, --yes apply without asking (github apply, gitlab apply)",
31
40
  " -v, --version print the version",
32
41
  ].join("\n");
33
42
  const toPosix = (path) => path.replace(/\\/g, "/").replace(/^\.\//, "");
@@ -44,7 +53,11 @@ export async function run(argv, io) {
44
53
  "adopt-all": { type: "boolean", default: false },
45
54
  accept: { type: "string", multiple: true, default: [] },
46
55
  stack: { type: "string", multiple: true, default: [] },
56
+ platform: { type: "string" },
57
+ preset: { type: "string" },
58
+ interactive: { type: "boolean", short: "i", default: false },
47
59
  relock: { type: "boolean", default: false },
60
+ to: { type: "string" },
48
61
  json: { type: "boolean", default: false },
49
62
  yes: { type: "boolean", short: "y", default: false },
50
63
  version: { type: "boolean", short: "v", default: false },
@@ -60,12 +73,34 @@ export async function run(argv, io) {
60
73
  io.out(USAGE);
61
74
  return command === undefined && !values.help ? 2 : 0;
62
75
  }
63
- const stacks = values.stack;
64
- for (const stack of stacks) {
65
- if (!STACK_IDS.includes(stack)) {
76
+ // `go:backend` names the folder of a monorepo the stack lives in
77
+ const stackDirectories = {};
78
+ const stacks = values.stack.map((value) => {
79
+ const [stack, ...rest] = value.split(":");
80
+ if (stack === undefined || !STACK_IDS.includes(stack)) {
66
81
  throw new UsageError(`unknown stack ${stack}; expected one of ${STACK_IDS.join(", ")}`);
67
82
  }
83
+ const directory = toPosix(rest.join(":")).replace(/\/+$/, "");
84
+ if (directory !== "" && directory !== ".")
85
+ stackDirectories[stack] = directory;
86
+ return stack;
87
+ });
88
+ const platform = values.platform;
89
+ if (platform !== undefined && platform !== "github" && platform !== "gitlab") {
90
+ throw new UsageError(`unknown platform ${platform}; expected github or gitlab`);
68
91
  }
92
+ if (platform !== undefined && command !== "init")
93
+ throw new UsageError("--platform is only for init");
94
+ const preset = values.preset;
95
+ if (preset !== undefined && !PRESETS.includes(preset)) {
96
+ throw new UsageError(`unknown preset ${preset}; expected one of ${PRESETS.join(", ")}`);
97
+ }
98
+ if ((preset !== undefined || values.interactive) && command !== "init") {
99
+ throw new UsageError("--preset and --interactive are only for init");
100
+ }
101
+ const to = values.to;
102
+ if (to !== undefined && command !== "eject")
103
+ throw new UsageError("--to is only for eject");
69
104
  const options = {
70
105
  dryRun: values["dry-run"],
71
106
  force: values.force,
@@ -73,9 +108,14 @@ export async function run(argv, io) {
73
108
  adoptAll: values["adopt-all"],
74
109
  accept: values.accept.map(toPosix),
75
110
  stacks: stacks,
111
+ stackDirectories,
76
112
  relock: values.relock,
77
113
  json: values.json,
78
114
  yes: values.yes,
115
+ ...(platform !== undefined ? { platform } : {}),
116
+ ...(to !== undefined ? { to } : {}),
117
+ ...(preset !== undefined ? { preset: preset } : {}),
118
+ interactive: values.interactive,
79
119
  };
80
120
  if (command === "init")
81
121
  return await initCommand(io.cwd, options, io);
@@ -83,11 +123,18 @@ export async function run(argv, io) {
83
123
  return await checkCommand(io.cwd, options, io);
84
124
  if (command === "update")
85
125
  return await updateCommand(io.cwd, options, io);
126
+ if (command === "eject")
127
+ return await ejectCommand(io.cwd, options, io);
86
128
  if (command === "github") {
87
129
  if (positionals[1] !== "apply")
88
130
  throw new UsageError("usage: repokeeper github apply [--dry-run] [--yes]");
89
131
  return await githubApplyCommand(io.cwd, options, io);
90
132
  }
133
+ if (command === "gitlab") {
134
+ if (positionals[1] !== "apply")
135
+ throw new UsageError("usage: repokeeper gitlab apply [--dry-run] [--yes]");
136
+ return await gitlabApplyCommand(io.cwd, options, io);
137
+ }
91
138
  throw new UsageError(`unknown command: ${command}`);
92
139
  }
93
140
  catch (error) {
@@ -105,10 +152,11 @@ export async function run(argv, io) {
105
152
  throw error;
106
153
  }
107
154
  }
108
- async function askYesNo(question) {
155
+ async function askYesNo(question, fallback = false) {
109
156
  const rl = createInterface({ input: process.stdin, output: process.stdout });
110
157
  try {
111
- return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim());
158
+ const answer = (await rl.question(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).trim();
159
+ return answer === "" ? fallback : /^y(es)?$/i.test(answer);
112
160
  }
113
161
  finally {
114
162
  rl.close();
@@ -1,7 +1,30 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG_FILE } from "../config/load.js";
4
+ import { ConfigError, UsageError } from "../errors.js";
1
5
  import { repoInfo } from "../git.js";
2
- import { githubPlatform } from "../platforms/github.js";
6
+ import { platformFor } from "../platforms/index.js";
3
7
  import { getStackPack } from "../stacks/index.js";
8
+ import { stackDirectory } from "../stacks/support.js";
4
9
  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)) };
10
+ const platform = platformFor(config.platform);
11
+ const unsupported = config.stacks.find((id) => !platform.stacks.includes(id));
12
+ if (unsupported) {
13
+ throw new UsageError(`stack "${unsupported}" is not supported on ${platform.id} yet (supported: ${platform.stacks.join(", ")})`);
14
+ }
15
+ const stacks = await Promise.all(config.stacks.map(async (id) => {
16
+ // `directory` is read here, for every stack alike; the rest of the options are the stack's own
17
+ const { directory: _directory, ...options } = config.stack_options[id] ?? {};
18
+ const directory = stackDirectory(id, config.stack_options[id] ?? {});
19
+ if (directory === undefined)
20
+ return getStackPack(id).resolve(root, options);
21
+ if (platform.id !== "github") {
22
+ throw new UsageError(`a stack in a folder of its own (stack_options.${id}.directory) is not supported on ${platform.id} yet`);
23
+ }
24
+ if (!existsSync(join(root, directory))) {
25
+ throw new ConfigError(`${CONFIG_FILE}: stack_options.${id}.directory: the folder ${directory} does not exist`);
26
+ }
27
+ return { ...(await getStackPack(id).resolve(join(root, directory), options)), directory };
28
+ }));
29
+ return { config, stacks, platform, repo: repo ?? (await repoInfo(root, config.platform)) };
7
30
  }
@@ -0,0 +1,74 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { parseDocument } from "yaml";
5
+ import { CONFIG_FILE, loadConfig } from "../config/load.js";
6
+ import { UsageError } from "../errors.js";
7
+ import { listWorkflows, readWorkflow } from "../templates.js";
8
+ import { PACKAGE_VERSION, REUSABLE_REPO } from "../version.js";
9
+ import { updateCommand } from "./update.js";
10
+ const WORKFLOWS = ".github/workflows";
11
+ /** Sets `github.workflows.source: local` in place, keeping every comment and the rest of the layout. */
12
+ export function setLocalWorkflows(text) {
13
+ const doc = parseDocument(text);
14
+ doc.setIn(["github", "workflows", "source"], "local");
15
+ // a ref belongs to a repository; the files of this one are called as they are on each commit
16
+ doc.deleteIn(["github", "workflows", "ref"]);
17
+ return doc.toString();
18
+ }
19
+ /**
20
+ * Stops depending on repokeeper's repository for CI.
21
+ *
22
+ * Without `--to`: this repository gets its own copies of the reusable workflows its CI and release
23
+ * call, and the callers are pointed at them. With `--to <folder>`: every reusable workflow is
24
+ * written into that folder, for a repository of the organisation that the others then call.
25
+ */
26
+ export async function ejectCommand(root, options, io) {
27
+ if (options.to !== undefined)
28
+ return ejectTo(resolve(root, options.to), options, io);
29
+ const config = await loadConfig(root);
30
+ if (config.platform !== "github") {
31
+ throw new UsageError("eject is for GitHub: on GitLab every job is already generated into .gitlab-ci.yml");
32
+ }
33
+ const local = {
34
+ ...config,
35
+ github: { ...config.github, workflows: { source: "local" } },
36
+ };
37
+ const code = await updateCommand(root, options, io, {
38
+ config: local,
39
+ async beforeWrite() {
40
+ const path = join(root, CONFIG_FILE);
41
+ await writeFile(path, setLocalWorkflows(await readFile(path, "utf8")));
42
+ },
43
+ });
44
+ if (code === 0 && !options.dryRun) {
45
+ io.out(`the reusable workflows are now files of this repository, under ${WORKFLOWS}/, and yours to maintain`);
46
+ }
47
+ return code;
48
+ }
49
+ async function ejectTo(target, options, io) {
50
+ const directory = join(target, WORKFLOWS);
51
+ const files = listWorkflows();
52
+ const existing = files.filter((file) => existsSync(join(directory, file)));
53
+ if (existing.length > 0 && !options.force) {
54
+ throw new UsageError(`${existing.map((file) => `${WORKFLOWS}/${file}`).join(", ")} already exist in ${target}; pass --force to replace them`);
55
+ }
56
+ for (const file of files)
57
+ io.out(`${existing.includes(file) ? "replace" : "create "} ${WORKFLOWS}/${file}`);
58
+ if (options.dryRun) {
59
+ io.out("dry run: nothing written");
60
+ return 0;
61
+ }
62
+ await mkdir(directory, { recursive: true });
63
+ for (const file of files) {
64
+ await writeFile(join(directory, file), `# Copied from repokeeper ${PACKAGE_VERSION} (https://github.com/${REUSABLE_REPO}).\n${readWorkflow(file)}`);
65
+ }
66
+ io.out(`wrote ${files.length} reusable workflows to ${directory}`);
67
+ io.out("next: commit and push them in that repository, and allow other repositories to call its workflows");
68
+ io.out("next: in each repository that should call them, add to .repokeeper.yml and run `repokeeper update`:");
69
+ io.out(" github:");
70
+ io.out(" workflows:");
71
+ io.out(" source: your-org/that-repository");
72
+ io.out(" ref: main # or a tag or commit of that repository");
73
+ return 0;
74
+ }
@@ -6,6 +6,8 @@ import { planGithub } from "../github/settings.js";
6
6
  /** `repokeeper github apply`: diff the repository's GitHub settings against `github:` and apply on confirmation. */
7
7
  export async function githubApplyCommand(root, options, io) {
8
8
  const config = await loadConfig(root);
9
+ if (config.platform !== "github")
10
+ throw new UsageError(`this repository uses the ${config.platform} platform`);
9
11
  const repo = await repoInfo(root);
10
12
  if (!repo.owner)
11
13
  throw new UsageError("the origin remote is not a GitHub repository");
@@ -0,0 +1,46 @@
1
+ import { loadConfig } from "../config/load.js";
2
+ import { UsageError } from "../errors.js";
3
+ import { repoInfo } from "../git.js";
4
+ import { gitlabRestApi, resolveGitlabToken } from "../gitlab/api.js";
5
+ import { planGitlab } from "../gitlab/settings.js";
6
+ /** `repokeeper gitlab apply`: diff the project's GitLab settings against `gitlab:` and apply on confirmation. */
7
+ export async function gitlabApplyCommand(root, options, io) {
8
+ const config = await loadConfig(root);
9
+ if (config.platform !== "gitlab")
10
+ throw new UsageError(`this repository uses the ${config.platform} platform`);
11
+ const repo = await repoInfo(root, "gitlab");
12
+ if (!repo.owner)
13
+ throw new UsageError("the origin remote is not a GitLab project");
14
+ const path = `${repo.owner}/${repo.name}`;
15
+ const host = repo.host ?? "gitlab.com";
16
+ const api = io.gitlabApi ?? gitlabRestApi(await resolveGitlabToken(host), host);
17
+ // the jobs that wait for a secret: reported when it is missing, never created
18
+ const secrets = [
19
+ ...(config.modules.release ? [{ key: "GITLAB_TOKEN", job: "release" }] : []),
20
+ ...(config.modules.deps ? [{ key: "RENOVATE_TOKEN", job: "renovate" }] : []),
21
+ ];
22
+ const { changes, notes } = await planGitlab(api, path, config.gitlab ?? {}, secrets);
23
+ if (changes.length === 0)
24
+ io.out(`GitLab settings of ${path} match .repokeeper.yml`);
25
+ for (const change of changes)
26
+ io.out(`change ${change.setting}: ${change.from} -> ${change.to}`);
27
+ for (const note of notes)
28
+ io.out(`note: ${note}`);
29
+ if (changes.length === 0)
30
+ return 0;
31
+ if (options.dryRun) {
32
+ io.out("dry run: nothing applied");
33
+ return 0;
34
+ }
35
+ const question = `apply ${changes.length} change(s) to ${path}?`;
36
+ const confirmed = options.yes || (io.confirm ? await io.confirm(question) : false);
37
+ if (!confirmed) {
38
+ io.out("nothing applied; pass --yes to apply");
39
+ return 1;
40
+ }
41
+ for (const change of changes) {
42
+ await change.apply(api);
43
+ io.out(`applied ${change.setting}`);
44
+ }
45
+ return 0;
46
+ }