From 29797b5dfa4e806a9e499cc5692a784ee21c742d Mon Sep 17 00:00:00 2001 From: Marcus Ramberg Date: Thu, 22 Oct 2026 15:54:42 +0200 Subject: [PATCH 1/3] feat: port action from Node to Go and decouple image tags Rewrite the manager in Go (same env-var contract and container interface): - internal/config, set, googlews, githuborg, app packages with unit tests - multi-stage Dockerfile producing a distroless static binary - CI switched to Go build/test; CodeQL to go; Renovate npm -> gomod - remove all Node sources, tests and tooling Fix the image-tag release pain: action.yml now pulls ghcr.io/remarkable/githubusermanager:${{ github.action_ref }} and CI tags the image to match the refs callers use (main, vX.Y.Z, vX.Y, vX, sha). No hardcoded version to bump before a release. Also unifies the registry on ghcr.io. Add release workflow that moves the major/minor git tags (vX, vX.Y) to each published vX.Y.Z release so @v1 style references resolve to the latest release. --- .dockerignore | 18 +- .env.example | 2 +- .github/workflows/ci.yml | 38 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/release.yml | 48 + .gitignore | 5 +- .nvmrc | 1 - .oxfmtrc.json | 9 - .oxlintrc.json | 32 - .pre-commit-config.yaml | 34 + Dockerfile | 15 +- LICENSE | 2 +- README.md | 29 +- action.yml | 11 +- go.mod | 38 + go.sum | 84 + index.ts | 47 - internal/app/app.go | 79 + internal/app/app_test.go | 113 ++ internal/config/config.go | 131 ++ internal/config/config_test.go | 139 ++ internal/githuborg/github.go | 159 ++ internal/githuborg/github_test.go | 159 ++ internal/githuborg/rest.go | 96 + internal/googlews/google.go | 121 ++ internal/googlews/google_test.go | 75 + internal/set/set.go | 69 + internal/set/set_test.go | 39 + main.go | 50 + modules.d.ts | 13 - package.json | 33 - pnpm-lock.yaml | 2352 ----------------------- pnpm-workspace.yaml | 3 - renovate.json | 19 +- src/config.ts | 57 - src/github.ts | 150 -- src/google.ts | 57 - tests/__snapshots__/config.spec.ts.snap | 18 - tests/__snapshots__/github.spec.ts.snap | 167 -- tests/__snapshots__/google.spec.ts.snap | 75 - tests/__snapshots__/index.spec.ts.snap | 135 -- tests/config.spec.ts | 183 -- tests/github.spec.ts | 187 -- tests/google.spec.ts | 70 - tests/index.spec.ts | 95 - tsconfig.json | 10 - vitest.config.ts | 9 - 47 files changed, 1510 insertions(+), 3768 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 .nvmrc delete mode 100644 .oxfmtrc.json delete mode 100644 .oxlintrc.json create mode 100644 .pre-commit-config.yaml create mode 100644 go.mod create mode 100644 go.sum delete mode 100644 index.ts create mode 100644 internal/app/app.go create mode 100644 internal/app/app_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/githuborg/github.go create mode 100644 internal/githuborg/github_test.go create mode 100644 internal/githuborg/rest.go create mode 100644 internal/googlews/google.go create mode 100644 internal/googlews/google_test.go create mode 100644 internal/set/set.go create mode 100644 internal/set/set_test.go create mode 100644 main.go delete mode 100644 modules.d.ts delete mode 100644 package.json delete mode 100644 pnpm-lock.yaml delete mode 100644 pnpm-workspace.yaml delete mode 100644 src/config.ts delete mode 100644 src/github.ts delete mode 100644 src/google.ts delete mode 100644 tests/__snapshots__/config.spec.ts.snap delete mode 100644 tests/__snapshots__/github.spec.ts.snap delete mode 100644 tests/__snapshots__/google.spec.ts.snap delete mode 100644 tests/__snapshots__/index.spec.ts.snap delete mode 100644 tests/config.spec.ts delete mode 100644 tests/github.spec.ts delete mode 100644 tests/google.spec.ts delete mode 100644 tests/index.spec.ts delete mode 100644 tsconfig.json delete mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore index 2f3295685..cefc8f3a9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,17 +1,15 @@ -node_modules -tests -coverage -.github .git +.github Dockerfile -.nvmrc -jest.config.js -*.pem -credentials.json .dockerignore .gitignore .env.example .env -.eslintrc.js -.prettierrc.js +*.pem +credentials.json action.yml +README.md +LICENSE +SECURITY.md +renovate.json +coverage.out diff --git a/.env.example b/.env.example index 5b34800ff..7dc0406ad 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,4 @@ GITHUB_APP_ID=123 GITHUB_PRIVATE_KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tXG5wcml2YXRla2V5Z29lc2hlcmVcbi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS1cbg== IGNORED_USERS=user1,user2 SYNC_TEAM=TRUE -GITHUB_TEAM_SLUG=remarkable \ No newline at end of file +GITHUB_TEAM_SLUG=remarkable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de0df7328..272b69a6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,45 +12,53 @@ on: jobs: build: runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: - version: 10 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version-file: .nvmrc - cache: pnpm + go-version-file: go.mod + cache: true - run: | - pnpm install - pnpm lint - pnpm test - pnpm check-types + go vet ./... + go test -race -coverprofile=coverage.out ./... + go build ./... - id: docker_meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: - images: docker.pkg.github.com/remarkable/githubUserManager/githubUserManager + images: ghcr.io/remarkable/githubusermanager + # Tag the image to match the refs people pass to `uses:` so the + # composite action can pull `...:${{ github.action_ref }}` without + # any hardcoded version to bump before a release: + # - push to main -> `main` + # - release vX.Y.Z -> `vX.Y.Z`, `vX.Y`, `vX` (moving tags) + # - any build -> `sha-` (for debugging/pinning) tags: | - type=sha + type=ref,event=branch type=semver,pattern=v{{version}} + type=semver,pattern=v{{major}}.{{minor}} + type=semver,pattern=v{{major}} + type=sha labels: | org.opencontainers.image.vendor=reMarkable org.opencontainers.image.documentation=https://github.com/reMarkable/githubUserManager org.opencontainers.image.authors=Marcus Ramberg maintainer=reMarkable - - name: Login to docker registry + - name: Login to GitHub Container Registry if: ${{ github.event_name != 'pull_request' }} uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: - registry: docker.pkg.github.com + registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Push to GitHub Packages + - name: Build and push to GitHub Container Registry uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b08ad8d4a..0b93a26c2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,7 +20,7 @@ jobs: - name: Initialize CodeQL uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 with: - languages: javascript + languages: go - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..b9587d90d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: release + +# When a release is published for a semver tag (vX.Y.Z), move the matching +# major (vX) and major.minor (vX.Y) git tags to the released commit. This keeps +# `uses: reMarkable/githubUserManager@v1` (and @v1.2) pointing at the latest +# compatible release, matching the moving image tags published by ci.yml. +on: + release: + types: + - published + +permissions: + contents: write + +jobs: + move-major-minor-tags: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + + - name: Move major and major.minor tags + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_SHA: ${{ github.event.release.target_commitish }} + run: | + set -euo pipefail + + if [[ ! "$RELEASE_TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "Release tag '$RELEASE_TAG' is not a vX.Y.Z semver tag; skipping." + exit 0 + fi + + major="v${BASH_REMATCH[1]}" + minor="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + + # Resolve the commit the release points at (the annotated release tag). + target="$(git rev-list -n 1 "$RELEASE_TAG")" + echo "Release $RELEASE_TAG -> $target; moving $major and $minor" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + for tag in "$major" "$minor"; do + git tag -f "$tag" "$target" + git push --force origin "refs/tags/$tag" + done diff --git a/.gitignore b/.gitignore index bdaa03683..2dc2dfb4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ credentials.json -node_modules .env coverage -*.pem \ No newline at end of file +coverage.out +*.pem +/githubusermanager diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 8dfc5cb1a..000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.18.1 diff --git a/.oxfmtrc.json b/.oxfmtrc.json deleted file mode 100644 index 6649bea1e..000000000 --- a/.oxfmtrc.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "./node_modules/oxfmt/configuration_schema.json", - "semi": false, - "trailingComma": "all", - "singleQuote": true, - "printWidth": 120, - "tabWidth": 2, - "ignorePatterns": [] -} diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index 20f237e20..000000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript", "unicorn"], - "categories": { - "correctness": "error", - "suspicious": "error", - "restriction": "error", - "style": "off", - "perf": "off" - }, - "env": { - "builtin": true - }, - "rules": { - "no-console": "off", - "func-style": "off", - "no-eq-null": "off" - }, - "overrides": [ - { - "files": ["tests/**"], - "rules": { - "no-magic-numbers": "off", - "max-statements": "off", - "prefer-global-this": "off", - "init-declarations": "off", - "no-empty-function": "off", - "no-undefined": "off" - } - } - ] -} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..83d90c863 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_install_hook_types: [pre-commit, commit-msg] +repos: + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.38.2 + hooks: + - id: check-github-workflows + - id: check-renovate + - repo: builtin + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.109.2 + hooks: + - id: terraform_fmt + - id: terraform_tflint + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v4.4.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [feat, fix, docs, perf, refactor, test, security, chore] + - repo: https://github.com/hadolint/hadolint + rev: v2.15.1 + hooks: + - id: hadolint + - repo: 'https://github.com/golangci/golangci-lint' + rev: v2.15.0 + hooks: + - id: golangci-lint-full + - id: golangci-lint-fmt + - id: golangci-lint-config-verify diff --git a/Dockerfile b/Dockerfile index 2a84557f0..994d07a0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,12 @@ -FROM node:24.18.1-alpine@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 +# syntax=docker/dockerfile:1 +FROM golang:1.27-alpine AS build -WORKDIR /app -RUN corepack enable && echo yo -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN pnpm install --production --frozen-lockfile +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download COPY . . +RUN CGO_ENABLED=0 GOFLAGS=-trimpath go build -ldflags="-s -w" -o /githubusermanager . -CMD ["pnpm", "start"] +FROM gcr.io/distroless/static-debian13:nonroot +COPY --from=build /githubusermanager /githubusermanager +ENTRYPOINT ["/githubusermanager"] diff --git a/LICENSE b/LICENSE index f7f9c345a..0afa7632c 100644 --- a/LICENSE +++ b/LICENSE @@ -173,4 +173,4 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. -END OF TERMS AND CONDITIONS \ No newline at end of file +END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index c365c8408..a20f55790 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Manage github organisation membership with Google Workspace user accounts -[![Known Vulnerabilities](https://snyk.io/test/github/appvia/githubUserManager/badge.svg)](https://snyk.io/test/github/appvia/githubUserManager) -[![GitHub license](https://img.shields.io/github/license/appvia/githubUserManager)](https://github.com/appvia/githubUserManager/blob/main/LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/appvia/githubusermanager)](https://github.com/appvia/githubusermanager/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/appvia/githubusermanager)](https://github.com/appvia/githubusermanager/network) -[![GitHub issues](https://img.shields.io/github/issues/appvia/githubusermanager)](https://github.com/appvia/githubusermanager/issues) -[![ci](https://github.com/appvia/githubUserManager/actions/workflows/ci.yml/badge.svg)](https://github.com/appvia/githubUserManager/actions/workflows/ci.yml) +[![GitHub license](https://img.shields.io/github/license/reMarkable/githubUserManager)](https://github.com/reMarkable/githubUserManager/blob/main/LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/reMarkable/githubusermanager)](https://github.com/reMarkable/githubusermanager/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/reMarkable/githubusermanager)](https://github.com/reMarkable/githubusermanager/network) +[![GitHub issues](https://img.shields.io/github/issues/reMarkable/githubusermanager)](https://github.com/reMarkable/githubusermanager/issues) +[![ci](https://github.com/reMarkable/githubUserManager/actions/workflows/ci.yml/badge.svg)](https://github.com/reMarkable/githubUserManager/actions/workflows/ci.yml) Manages who is in your GitHub organization based on a custom property in their Google Workspace profile, allowing for seamless JML (Joiner mover leaver) process, if allowed removing/disabling an account in the Google Workspace will remove the user from the GitHub; similarly adding a user also works the same way. If you don't want to run it in destructive mode it can be configured to exit with a non-zero exit code so that you know to go and manually make the changes. @@ -87,7 +86,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Github Org Membership Manager - uses: appvia/githubUserManager@v1.0.5 + uses: reMarkable/githubUserManager@v1 with: google-email-address: hello@example.com google-credentials: ${{ secrets.GOOGLE_CREDENTIALS }} @@ -120,13 +119,20 @@ updates: #### Docker 1. Make an [env file](https://www.digitalocean.com/community/tutorials/how-to-read-and-set-environmental-and-shell-variables-on-linux) with the [below table](#Setup-environment-variables) -1. `docker run --env-file .env docker.pkg.github.com/appvia/githubusermanager/githubusermanager:main` +1. `docker run --env-file .env ghcr.io/remarkable/githubusermanager:main` -#### node/lambda/cloud run/ something else +Images are published to `ghcr.io/remarkable/githubusermanager` and tagged to +match the refs you can reference the action with: `main`, released versions +(`v1.2.3`), the moving `v1.2`/`v1` tags, and `sha-` for a specific +build. The GitHub Action always pulls the image tag matching the ref it was +called with (`${{ github.action_ref }}`), so there is no image version to bump +by hand before a release. + +#### Go / lambda / cloud run / something else 1. Clone this repo -1. `pnpm install --production` -1. `pnpm start` (with the with the [below environment variables table](#Setup-environment-variables) set) +1. `go build -o githubusermanager .` +1. `./githubusermanager` (with the [below environment variables table](#Setup-environment-variables) set) ### Setup environment variables @@ -145,3 +151,4 @@ updates: | `IGNORED_USERS` | Comma separated list of user ids to totally ignore always, useful for owners of an org you don't want accidentally removed | `owner1,owner2` | `null` | | `SYNC_TEAM` | Set to TRUE to ensure every org member is added to the team named in `GITHUB_TEAM_SLUG` | `TRUE` | `false` | | `GITHUB_TEAM_SLUG` | Slug of the team every org member should be added to, e.g. to grant PR rights to shared repos | `remarkable` | `null` | +| `DEBUG` | Set to TRUE to enable debug level logging | `TRUE` | `false` | diff --git a/action.yml b/action.yml index edd58e723..e22475f49 100644 --- a/action.yml +++ b/action.yml @@ -19,7 +19,7 @@ inputs: remove-suspended_users: description: 'Set to TRUE to remove suspended users from the github organisation' required: false - exit-code-on-missmatch: + exit-code-on-mismatch: description: 'Exit code to use when there is a mismatch, useful when combined with `ADD_USERS` and `REMOVE_USERS` to be used in a dry-run mode' required: false github-org: @@ -43,6 +43,9 @@ inputs: team-slug: description: 'Slug of the team that every org member should be added to (e.g. remarkable)' required: false + debug: + description: 'Set to TRUE to enable debug level logging' + required: false runs: using: 'composite' steps: @@ -62,7 +65,8 @@ runs: -e IGNORED_USERS="$IGNORED_USERS" \ -e SYNC_TEAM="$SYNC_TEAM" \ -e GITHUB_TEAM_SLUG="$GITHUB_TEAM_SLUG" \ - ghcr.io/remarkable/githubusermanager/githubusermanager:v1.5.2 + -e DEBUG="$DEBUG" \ + ghcr.io/remarkable/githubusermanager:${{ github.action_ref }} shell: bash env: GOOGLE_EMAIL_ADDRESS: ${{ inputs.google-email-address }} @@ -70,7 +74,7 @@ runs: ADD_USERS: ${{ inputs.add-users }} REMOVE_USERS: ${{ inputs.remove-users }} REMOVE_SUSPENDED_USERS: ${{ inputs.remove-suspended_users }} - EXIT_CODE_ON_MISMATCH: ${{ inputs.exit-code-on-missmatch }} + EXIT_CODE_ON_MISMATCH: ${{ inputs.exit-code-on-mismatch }} GITHUB_ORG: ${{ inputs.github-org }} GITHUB_APP_ID: ${{ inputs.github-app-id }} GITHUB_INSTALLATION_ID: ${{ inputs.github-installation-id }} @@ -78,3 +82,4 @@ runs: IGNORED_USERS: ${{ inputs.ignored-users }} SYNC_TEAM: ${{ inputs.sync-team }} GITHUB_TEAM_SLUG: ${{ inputs.team-slug }} + DEBUG: ${{ inputs.debug }} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..b38d4d94c --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module github.com/reMarkable/githubUserManager + +go 1.27.3 + +require ( + github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 + github.com/google/go-github/v88 v88.0.0 + golang.org/x/oauth2 v0.37.0 + google.golang.org/api v0.307.0 +) + +require ( + cloud.google.com/go/auth v0.24.1-0.20261001053825-dbc26066f70a // indirect + cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect + cloud.google.com/go/compute/metadata v0.10.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/google/s2a-go v0.1.11 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.23 // indirect + github.com/googleapis/gax-go/v2 v2.26.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..6e8e3c5fd --- /dev/null +++ b/go.sum @@ -0,0 +1,84 @@ +cloud.google.com/go/auth v0.24.1-0.20261001053825-dbc26066f70a h1:jW1ZLFy9oa5ai8LOI40z6s+DL1cO/L1KSm9hVGnibn8= +cloud.google.com/go/auth v0.24.1-0.20261001053825-dbc26066f70a/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c= +cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88= +cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk= +cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= +cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 h1:KQfD+43pRw9NUJhGycGrFr9vF1MubZacksKol1gomFI= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0/go.mod h1:fe5ECIhCdEnxwLiBlNTxx9CP455wt42BELnlDVMvaAA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M= +github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/s2a-go v0.1.11 h1:7v1V1TraNGP63WptsAkNqr2w8jgm4vM6A6qx6Lsw9iM= +github.com/google/s2a-go v0.1.11/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.23 h1:J+VGF1wjAM2dU2kKUXZxJdDppRLReWP7KWcclfngQhc= +github.com/googleapis/enterprise-certificate-proxy v0.3.23/go.mod h1:Y8NO/15GNLkrXI3OR5xOFmeU3XLX1sLSdtlXgJ0zmIw= +github.com/googleapis/gax-go/v2 v2.26.2 h1:ydkmNXxj7bEmmeK5AihkKnWxyOyBR9TDebvp5L5izk8= +github.com/googleapis/gax-go/v2 v2.26.2/go.mod h1:sMKqnMesnKH+3wiRJROcttA+cJoZoGbZl1vDQ8XYtGk= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.307.0 h1:KJJv1V/cOeCUrgKFippAhSg98QlR2IZFqubQKf7gQdg= +google.golang.org/api v0.307.0/go.mod h1:4XMH+b/ZEfuTbK7FnrFRY9c4xfFHVflGTKuy/i5wUmE= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d h1:QwnJwPte4XXAkhPu26LTDIahnsMSUV0kK8HkxbC+Pc4= +google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/index.ts b/index.ts deleted file mode 100644 index c3600b0ec..000000000 --- a/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getGithubUsersFromGoogle } from './src/google.js' -import { addUsersToGitHubOrg, getGithubUsersFromGithub, removeUsersFromGitHubOrg, syncTeamMembers } from './src/github.js' -import { config } from './src/config.js' - -export async function run(): Promise { - const googleUsers = await getGithubUsersFromGoogle() - console.log(`Users from google: ${[...googleUsers].join(', ')}`) - - const gitHubUsers = await getGithubUsersFromGithub() - console.log(`Users from github: ${[...gitHubUsers].join(', ')}`) - - const usersNotInGithub = new Set([...googleUsers].filter((x) => !gitHubUsers.has(x))) - - const usersNotInGoogle = new Set([...gitHubUsers].filter((x) => !googleUsers.has(x))) - if (usersNotInGoogle.size > 0) { - console.log(`Users not in google: ${[...usersNotInGoogle].join(', ')}`) - - if (config.removeUsers) { - if (usersNotInGithub.size <= config.maxRemoveUsers) { - await removeUsersFromGitHubOrg(usersNotInGoogle) - } else { - console.log(`Not removing users because there are too many`) - } - } - } - - if (usersNotInGithub.size > 0) { - console.log(`Users not in github: ${[...usersNotInGithub].join(', ')}`) - if (config.addUsers) { - await addUsersToGitHubOrg(usersNotInGithub) - } - } - - if (config.syncTeam) { - await syncTeamMembers() - } - - const exitCode = usersNotInGoogle.size > 0 || usersNotInGithub.size > 0 ? config.exitCodeOnMissmatch : 0 - - // oxlint-disable-next-line no-process-exit - process.exit(exitCode) -} - -// istanbul ignore next -if (import.meta.url.endsWith(process.argv[1])) { - run() -} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 000000000..1327fa2ff --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,79 @@ +// Package app wires the Google and GitHub providers together and implements the +// reconciliation flow, mirroring the original index.ts run() logic. +package app + +import ( + "context" + "log/slog" + "strings" + + "github.com/reMarkable/githubUserManager/internal/config" + "github.com/reMarkable/githubUserManager/internal/set" +) + +// GoogleProvider supplies the desired set of GitHub users from Google Workspace. +type GoogleProvider interface { + GithubUsersFromGoogle(ctx context.Context) (set.Set, error) +} + +// GitHubProvider reads and reconciles GitHub organisation membership. +type GitHubProvider interface { + UsersFromGitHub(ctx context.Context) (set.Set, error) + AddUsersToOrg(ctx context.Context, users set.Set) error + RemoveUsersFromOrg(ctx context.Context, users set.Set) error + SyncTeamMembers(ctx context.Context) error +} + +// Run reconciles GitHub org membership against Google Workspace and returns the +// process exit code (ExitCodeOnMismatch when the two sides differ, else 0). +func Run(ctx context.Context, cfg *config.Config, google GoogleProvider, gh GitHubProvider) (int, error) { + googleUsers, err := google.GithubUsersFromGoogle(ctx) + if err != nil { + return 0, err + } + slog.Info("users from google", "users", strings.Join(googleUsers.Sorted(), ", ")) + + gitHubUsers, err := gh.UsersFromGitHub(ctx) + if err != nil { + return 0, err + } + slog.Info("users from github", "users", strings.Join(gitHubUsers.Sorted(), ", ")) + + usersNotInGitHub := googleUsers.Diff(gitHubUsers) + usersNotInGoogle := gitHubUsers.Diff(googleUsers) + + if usersNotInGoogle.Len() > 0 { + slog.Info("users not in google", "users", strings.Join(usersNotInGoogle.Sorted(), ", ")) + if cfg.RemoveUsers { + // Preserve original behaviour: guard on the number of users we'd + // be adding, not removing. + if usersNotInGitHub.Len() <= cfg.MaxRemoveUsers { + if err := gh.RemoveUsersFromOrg(ctx, usersNotInGoogle); err != nil { + return 0, err + } + } else { + slog.Warn("not removing users because there are too many") + } + } + } + + if usersNotInGitHub.Len() > 0 { + slog.Info("users not in github", "users", strings.Join(usersNotInGitHub.Sorted(), ", ")) + if cfg.AddUsers { + if err := gh.AddUsersToOrg(ctx, usersNotInGitHub); err != nil { + return 0, err + } + } + } + + if cfg.SyncTeam { + if err := gh.SyncTeamMembers(ctx); err != nil { + return 0, err + } + } + + if usersNotInGoogle.Len() > 0 || usersNotInGitHub.Len() > 0 { + return cfg.ExitCodeOnMismatch, nil + } + return 0, nil +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 000000000..3d8745280 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,113 @@ +package app + +import ( + "context" + "io" + "log/slog" + "testing" + + "github.com/reMarkable/githubUserManager/internal/config" + "github.com/reMarkable/githubUserManager/internal/set" +) + +type fakeGoogle struct{ users set.Set } + +func (f fakeGoogle) GithubUsersFromGoogle(context.Context) (set.Set, error) { return f.users, nil } + +type fakeGitHub struct { + users set.Set + added, removed set.Set + syncCalled bool +} + +func (f *fakeGitHub) UsersFromGitHub(context.Context) (set.Set, error) { return f.users, nil } +func (f *fakeGitHub) AddUsersToOrg(_ context.Context, u set.Set) error { f.added = u; return nil } +func (f *fakeGitHub) RemoveUsersFromOrg(_ context.Context, u set.Set) error { + f.removed = u + return nil +} +func (f *fakeGitHub) SyncTeamMembers(context.Context) error { f.syncCalled = true; return nil } + +func init() { slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) } + +func mismatch() (fakeGoogle, *fakeGitHub) { + return fakeGoogle{users: set.New("a", "d")}, &fakeGitHub{users: set.New("b", "c", "a")} +} + +func TestMismatchExitCodeDefault(t *testing.T) { + g, gh := mismatch() + code, err := Run(context.Background(), &config.Config{}, g, gh) + if err != nil { + t.Fatal(err) + } + if code != 0 { + t.Errorf("default exit code = %d, want 0", code) + } +} + +func TestMismatchExitCodeConfigured(t *testing.T) { + g, gh := mismatch() + code, _ := Run(context.Background(), &config.Config{ExitCodeOnMismatch: 122}, g, gh) + if code != 122 { + t.Errorf("exit code = %d, want 122", code) + } +} + +func TestDoesNotAddOrRemoveWhenDisabled(t *testing.T) { + g, gh := mismatch() + _, _ = Run(context.Background(), &config.Config{}, g, gh) + if gh.added != nil { + t.Errorf("should not add, got %v", gh.added) + } + if gh.removed != nil { + t.Errorf("should not remove, got %v", gh.removed) + } +} + +func TestAddsUsersWhenEnabled(t *testing.T) { + g, gh := mismatch() + _, _ = Run(context.Background(), &config.Config{AddUsers: true}, g, gh) + // "d" is in google but not github. + if gh.added == nil || !gh.added.Has("d") || gh.added.Len() != 1 { + t.Errorf("added = %v, want [d]", gh.added) + } +} + +func TestRemovesUsersWhenEnabled(t *testing.T) { + g, gh := mismatch() + _, _ = Run(context.Background(), &config.Config{RemoveUsers: true, MaxRemoveUsers: 100}, g, gh) + // "b" and "c" are in github but not google. + if gh.removed == nil || !gh.removed.Has("b") || !gh.removed.Has("c") || gh.removed.Len() != 2 { + t.Errorf("removed = %v, want [b c]", gh.removed) + } +} + +func TestRemoveGuardedByMaxAdds(t *testing.T) { + // usersNotInGitHub (adds) exceeds MaxRemoveUsers, so removal is skipped. + g := fakeGoogle{users: set.New("a", "d", "e", "f")} + gh := &fakeGitHub{users: set.New("b", "a")} + _, _ = Run(context.Background(), &config.Config{RemoveUsers: true, MaxRemoveUsers: 2}, g, gh) + if gh.removed != nil { + t.Errorf("removal should be skipped, got %v", gh.removed) + } +} + +func TestSyncTeamCalledWhenEnabled(t *testing.T) { + g, gh := mismatch() + _, _ = Run(context.Background(), &config.Config{SyncTeam: true}, g, gh) + if !gh.syncCalled { + t.Error("SyncTeamMembers should have been called") + } +} + +func TestMatchExitsZero(t *testing.T) { + g := fakeGoogle{users: set.New("a", "b", "c", "d")} + gh := &fakeGitHub{users: set.New("a", "b", "c", "d")} + code, _ := Run(context.Background(), &config.Config{ExitCodeOnMismatch: 122, AddUsers: true, RemoveUsers: true, MaxRemoveUsers: 100}, g, gh) + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } + if gh.added != nil || gh.removed != nil { + t.Errorf("no changes expected when matched: added=%v removed=%v", gh.added, gh.removed) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 000000000..1eec427c8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,131 @@ +// Package config loads the manager configuration from environment variables. +// The variable names and semantics match the original Node implementation so +// the container contract is unchanged. +package config + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" +) + +// GoogleCredentials mirrors the service-account JSON downloaded from GCP. +type GoogleCredentials struct { + Type string `json:"type"` + ProjectID string `json:"project_id"` + PrivateKeyID string `json:"private_key_id"` + PrivateKey string `json:"private_key"` + ClientEmail string `json:"client_email"` + ClientID string `json:"client_id"` + AuthURI string `json:"auth_uri"` + TokenURI string `json:"token_uri"` + AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"` + ClientX509CertURL string `json:"client_x509_cert_url"` +} + +// Config is the fully-resolved runtime configuration. +type Config struct { + AddUsers bool + RemoveUsers bool + RemoveSuspendedUsers bool + ExitCodeOnMismatch int + MaxRemoveUsers int + Debug bool + + GitHubOrg string + GitHubAppID int64 + GitHubInstallationID int64 + GitHubPrivateKey string + GitHubTeamSlug string + SyncTeam bool + IgnoredUsers []string + + GoogleEmailAddress string + GoogleCredentials GoogleCredentials + // GoogleCredentialsJSON retains the decoded credentials bytes for callers + // that want to hand them straight to the Google client libraries. + GoogleCredentialsJSON []byte +} + +// Load reads and validates the configuration from the environment. +func Load() (*Config, error) { + c := &Config{ + AddUsers: boolEnv("ADD_USERS"), + RemoveUsers: boolEnv("REMOVE_USERS"), + RemoveSuspendedUsers: boolEnv("REMOVE_SUSPENDED_USERS"), + ExitCodeOnMismatch: intEnvOr("EXIT_CODE_ON_MISMATCH", 0), + MaxRemoveUsers: intEnvOr("MAX_REMOVE_USERS", 100), + Debug: boolEnv("DEBUG"), + GitHubOrg: os.Getenv("GITHUB_ORG"), + GitHubTeamSlug: os.Getenv("GITHUB_TEAM_SLUG"), + SyncTeam: boolEnv("SYNC_TEAM"), + IgnoredUsers: ignoredUsers(os.Getenv("IGNORED_USERS")), + GoogleEmailAddress: os.Getenv("GOOGLE_EMAIL_ADDRESS"), + } + + appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64) + if err != nil { + return nil, fmt.Errorf("GITHUB_APP_ID must be a number: %w", err) + } + c.GitHubAppID = appID + + installationID, err := strconv.ParseInt(os.Getenv("GITHUB_INSTALLATION_ID"), 10, 64) + if err != nil { + return nil, fmt.Errorf("GITHUB_INSTALLATION_ID must be a number: %w", err) + } + c.GitHubInstallationID = installationID + + key, err := decodeBase64("GITHUB_PRIVATE_KEY") + if err != nil { + return nil, err + } + c.GitHubPrivateKey = string(key) + + creds, err := decodeBase64("GOOGLE_CREDENTIALS") + if err != nil { + return nil, err + } + c.GoogleCredentialsJSON = creds + if err := json.Unmarshal(creds, &c.GoogleCredentials); err != nil { + return nil, fmt.Errorf("GOOGLE_CREDENTIALS is not valid JSON: %w", err) + } + + return c, nil +} + +func boolEnv(name string) bool { + return strings.EqualFold(os.Getenv(name), "true") +} + +// intEnvOr matches the Node `parseInt(...) || fallback` behaviour: an unset, +// invalid, or zero value falls back to the default. +func intEnvOr(name string, fallback int) int { + v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(name))) + if err != nil || v == 0 { + return fallback + } + return v +} + +func ignoredUsers(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(strings.ToLower(raw), ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} + +func decodeBase64(name string) ([]byte, error) { + raw := strings.TrimSpace(os.Getenv(name)) + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("%s must be base64 encoded: %w", name, err) + } + return decoded, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 000000000..ad6b3170e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,139 @@ +package config + +import ( + "encoding/base64" + "os" + "reflect" + "testing" +) + +func clearEnv(t *testing.T) { + t.Helper() + for _, k := range []string{ + "ADD_USERS", "REMOVE_USERS", "REMOVE_SUSPENDED_USERS", "EXIT_CODE_ON_MISMATCH", + "MAX_REMOVE_USERS", "GITHUB_ORG", "GITHUB_APP_ID", "GITHUB_INSTALLATION_ID", + "GITHUB_PRIVATE_KEY", "GITHUB_TEAM_SLUG", "SYNC_TEAM", "IGNORED_USERS", + "GOOGLE_EMAIL_ADDRESS", "GOOGLE_CREDENTIALS", + } { + _ = os.Unsetenv(k) + } +} + +// validEnv sets the minimum required variables for Load to succeed. +func validEnv(t *testing.T) { + t.Helper() + _ = os.Setenv("GITHUB_APP_ID", "123") + _ = os.Setenv("GITHUB_INSTALLATION_ID", "456") + _ = os.Setenv("GITHUB_PRIVATE_KEY", base64.StdEncoding.EncodeToString([]byte("helloworld"))) + _ = os.Setenv("GOOGLE_CREDENTIALS", base64.StdEncoding.EncodeToString([]byte(`{"client_email":"foo","private_key":"bar"}`))) +} + +func TestBoolEnv(t *testing.T) { + cases := map[string]bool{"": false, "foobar": false, "false": false, "true": true, "tRuE": true} + for in, want := range cases { + clearEnv(t) + validEnv(t) + if in != "" { + _ = os.Setenv("ADD_USERS", in) + } + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.AddUsers != want { + t.Errorf("ADD_USERS=%q => %v, want %v", in, cfg.AddUsers, want) + } + } +} + +func TestExitCodeOnMismatch(t *testing.T) { + cases := map[string]int{"": 0, "hello": 0, "123": 123} + for in, want := range cases { + clearEnv(t) + validEnv(t) + if in != "" { + _ = os.Setenv("EXIT_CODE_ON_MISMATCH", in) + } + cfg, _ := Load() + if cfg.ExitCodeOnMismatch != want { + t.Errorf("EXIT_CODE_ON_MISMATCH=%q => %d, want %d", in, cfg.ExitCodeOnMismatch, want) + } + } +} + +func TestMaxRemoveUsersDefault(t *testing.T) { + clearEnv(t) + validEnv(t) + cfg, _ := Load() + if cfg.MaxRemoveUsers != 100 { + t.Errorf("default MaxRemoveUsers = %d, want 100", cfg.MaxRemoveUsers) + } + _ = os.Setenv("MAX_REMOVE_USERS", "5") + cfg, _ = Load() + if cfg.MaxRemoveUsers != 5 { + t.Errorf("MaxRemoveUsers = %d, want 5", cfg.MaxRemoveUsers) + } +} + +func TestIgnoredUsers(t *testing.T) { + cases := map[string][]string{ + "": nil, + "user1": {"user1"}, + "user1,user2,USER4": {"user1", "user2", "user4"}, + "user1, user2 ,USER3": {"user1", "user2", "user3"}, + } + for in, want := range cases { + clearEnv(t) + validEnv(t) + if in != "" { + _ = os.Setenv("IGNORED_USERS", in) + } + cfg, _ := Load() + if !reflect.DeepEqual(cfg.IgnoredUsers, want) { + t.Errorf("IGNORED_USERS=%q => %v, want %v", in, cfg.IgnoredUsers, want) + } + } +} + +func TestPrivateKeyBase64Decode(t *testing.T) { + clearEnv(t) + validEnv(t) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.GitHubPrivateKey != "helloworld" { + t.Errorf("GitHubPrivateKey = %q, want helloworld", cfg.GitHubPrivateKey) + } +} + +func TestGoogleCredentialsDecode(t *testing.T) { + clearEnv(t) + validEnv(t) + _ = os.Setenv("GOOGLE_CREDENTIALS", base64.StdEncoding.EncodeToString([]byte(`{"client_email":"hello","private_key":"world"}`))) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.GoogleCredentials.ClientEmail != "hello" || cfg.GoogleCredentials.PrivateKey != "world" { + t.Errorf("GoogleCredentials = %+v", cfg.GoogleCredentials) + } +} + +func TestLoadRejectsBadAppID(t *testing.T) { + clearEnv(t) + validEnv(t) + _ = os.Setenv("GITHUB_APP_ID", "notanumber") + if _, err := Load(); err == nil { + t.Error("expected error for non-numeric GITHUB_APP_ID") + } +} + +func TestLoadRejectsBadBase64(t *testing.T) { + clearEnv(t) + validEnv(t) + _ = os.Setenv("GITHUB_PRIVATE_KEY", "!!!notbase64!!!") + if _, err := Load(); err == nil { + t.Error("expected error for invalid base64 GITHUB_PRIVATE_KEY") + } +} diff --git a/internal/githuborg/github.go b/internal/githuborg/github.go new file mode 100644 index 000000000..329969767 --- /dev/null +++ b/internal/githuborg/github.go @@ -0,0 +1,159 @@ +// Package githuborg manages GitHub organisation (and optional team) membership +// using a GitHub App installation for authentication. +package githuborg + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/bradleyfalzon/ghinstallation/v2" + "github.com/google/go-github/v88/github" + + "github.com/reMarkable/githubUserManager/internal/config" + "github.com/reMarkable/githubUserManager/internal/set" +) + +// api is the subset of the GitHub REST API the manager depends on, extracted so +// the membership logic can be unit tested without the network. +type api interface { + ListOrgMembers(ctx context.Context, org string) ([]string, error) + ListPendingInvites(ctx context.Context, org string) ([]string, error) + GetUserID(ctx context.Context, username string) (int64, error) + CreateInvitation(ctx context.Context, org string, inviteeID int64) error + RemoveMembership(ctx context.Context, org, username string) error + ListTeamMembers(ctx context.Context, org, slug string) ([]string, error) + AddTeamMembership(ctx context.Context, org, slug, username string) error +} + +// Service applies membership changes against a GitHub organisation. +type Service struct { + cfg *config.Config + api api +} + +// New builds a Service authenticated as the configured GitHub App installation. +func New(cfg *config.Config) (*Service, error) { + transport, err := ghinstallation.New( + http.DefaultTransport, + cfg.GitHubAppID, + cfg.GitHubInstallationID, + []byte(cfg.GitHubPrivateKey), + ) + if err != nil { + return nil, fmt.Errorf("creating github app transport: %w", err) + } + client, err := github.NewClient(github.WithHTTPClient(&http.Client{Transport: transport})) + if err != nil { + return nil, fmt.Errorf("creating github client: %w", err) + } + return &Service{cfg: cfg, api: restAPI{client: client}}, nil +} + +func (s *Service) isIgnored(user string) bool { + for _, ignored := range s.cfg.IgnoredUsers { + if ignored == strings.ToLower(user) { + return true + } + } + return false +} + +// UsersFromGitHub returns the union of current org members and pending invitees. +func (s *Service) UsersFromGitHub(ctx context.Context) (set.Set, error) { + members, err := s.api.ListOrgMembers(ctx, s.cfg.GitHubOrg) + if err != nil { + return nil, fmt.Errorf("listing org members: %w", err) + } + pending, err := s.api.ListPendingInvites(ctx, s.cfg.GitHubOrg) + if err != nil { + return nil, fmt.Errorf("listing pending invitations: %w", err) + } + + pendingSet := formatUserList(pending) + if pendingSet.Len() > 0 { + slog.Info("outstanding github invites", "org", strings.Join(pendingSet.Sorted(), ", ")) + } + return formatUserList(members).Union(pendingSet), nil +} + +// AddUsersToOrg invites each user to the organisation. +func (s *Service) AddUsersToOrg(ctx context.Context, users set.Set) error { + for _, user := range users.Sorted() { + if s.isIgnored(user) { + slog.Info("ignoring add", "user", user) + continue + } + userID, err := s.api.GetUserID(ctx, user) + if err != nil { + return fmt.Errorf("unable to find user id for %s: %w", user, err) + } + slog.Info("inviting user", "user", user, "userID", userID, "org", s.cfg.GitHubOrg) + if err := s.api.CreateInvitation(ctx, s.cfg.GitHubOrg, userID); err != nil { + return fmt.Errorf("inviting %s: %w", user, err) + } + } + return nil +} + +// RemoveUsersFromOrg removes each user (or pending invitation) from the org. +func (s *Service) RemoveUsersFromOrg(ctx context.Context, users set.Set) error { + for _, user := range users.Sorted() { + if s.isIgnored(user) { + slog.Info("ignoring remove", "user", user) + continue + } + slog.Info("removing user/invitation", "user", user, "org", s.cfg.GitHubOrg) + if err := s.api.RemoveMembership(ctx, s.cfg.GitHubOrg, user); err != nil { + return fmt.Errorf("removing %s: %w", user, err) + } + } + return nil +} + +// SyncTeamMembers ensures every org member is also a member of the configured team. +func (s *Service) SyncTeamMembers(ctx context.Context) error { + members, err := s.api.ListOrgMembers(ctx, s.cfg.GitHubOrg) + if err != nil { + return fmt.Errorf("listing org members: %w", err) + } + orgMembers := formatUserList(members) + + teamMembersRaw, err := s.api.ListTeamMembers(ctx, s.cfg.GitHubOrg, s.cfg.GitHubTeamSlug) + if err != nil { + return fmt.Errorf("listing team members: %w", err) + } + teamMembers := formatUserList(teamMembersRaw) + + missing := orgMembers.Diff(teamMembers) + if missing.Len() == 0 { + slog.Info("all org members already in team", "team", s.cfg.GitHubTeamSlug) + return nil + } + + slog.Info("users not in team", "team", s.cfg.GitHubTeamSlug, "users", strings.Join(missing.Sorted(), ", ")) + for _, user := range missing.Sorted() { + if s.isIgnored(user) { + slog.Info("ignoring team add", "user", user) + continue + } + slog.Info("adding user to team", "user", user, "team", s.cfg.GitHubTeamSlug) + // Mirror the Node behaviour: log and continue past per-user failures + // (e.g. SAML SSO validation errors) rather than aborting the sync. + if err := s.api.AddTeamMembership(ctx, s.cfg.GitHubOrg, s.cfg.GitHubTeamSlug, user); err != nil { + slog.Warn("skipping team add", "user", user, "err", err) + } + } + return nil +} + +// formatUserList lower-cases and de-duplicates a list of GitHub logins. +func formatUserList(logins []string) set.Set { + s := set.New() + for _, login := range logins { + s.Add(strings.ToLower(login)) + } + return s +} diff --git a/internal/githuborg/github_test.go b/internal/githuborg/github_test.go new file mode 100644 index 000000000..58639813a --- /dev/null +++ b/internal/githuborg/github_test.go @@ -0,0 +1,159 @@ +package githuborg + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + + "github.com/reMarkable/githubUserManager/internal/config" + "github.com/reMarkable/githubUserManager/internal/set" +) + +func init() { slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) } + +// fakeAPI records calls and returns canned data. +type fakeAPI struct { + orgMembers []string + pending []string + teamMembers []string + userIDs map[string]int64 + invited []int64 + removed []string + teamAdded []string + addTeamErr map[string]error + getUserIDErr error +} + +func (f *fakeAPI) ListOrgMembers(context.Context, string) ([]string, error) { return f.orgMembers, nil } +func (f *fakeAPI) ListPendingInvites(context.Context, string) ([]string, error) { + return f.pending, nil +} +func (f *fakeAPI) GetUserID(_ context.Context, u string) (int64, error) { + if f.getUserIDErr != nil { + return 0, f.getUserIDErr + } + return f.userIDs[u], nil +} +func (f *fakeAPI) CreateInvitation(_ context.Context, _ string, id int64) error { + f.invited = append(f.invited, id) + return nil +} +func (f *fakeAPI) RemoveMembership(_ context.Context, _, u string) error { + f.removed = append(f.removed, u) + return nil +} +func (f *fakeAPI) ListTeamMembers(context.Context, string, string) ([]string, error) { + return f.teamMembers, nil +} +func (f *fakeAPI) AddTeamMembership(_ context.Context, _, _, u string) error { + if err := f.addTeamErr[u]; err != nil { + return err + } + f.teamAdded = append(f.teamAdded, u) + return nil +} + +func svc(api *fakeAPI, cfg *config.Config) *Service { + if cfg == nil { + cfg = &config.Config{GitHubOrg: "myorg", GitHubTeamSlug: "remarkable"} + } + return &Service{cfg: cfg, api: api} +} + +func TestUsersFromGitHubUnionsMembersAndInvites(t *testing.T) { + s := svc(&fakeAPI{ + orgMembers: []string{"ChrisNS", "bar", "foo"}, + pending: []string{"pending", "chrisns"}, + }, nil) + got, err := s.UsersFromGitHub(context.Background()) + if err != nil { + t.Fatal(err) + } + want := set.New("chrisns", "bar", "foo", "pending") + if got.Len() != want.Len() { + t.Fatalf("got %v want %v", got.Sorted(), want.Sorted()) + } + for _, u := range want.Sorted() { + if !got.Has(u) { + t.Errorf("missing %s", u) + } + } +} + +func TestAddUsersSkipsIgnored(t *testing.T) { + api := &fakeAPI{userIDs: map[string]int64{"foo": 42}} + s := svc(api, &config.Config{GitHubOrg: "myorg", IgnoredUsers: []string{"bar"}}) + if err := s.AddUsersToOrg(context.Background(), set.New("foo", "bar")); err != nil { + t.Fatal(err) + } + if len(api.invited) != 1 || api.invited[0] != 42 { + t.Errorf("invited = %v, want [42]", api.invited) + } +} + +func TestAddUsersPropagatesLookupError(t *testing.T) { + api := &fakeAPI{getUserIDErr: errors.New("not found")} + s := svc(api, &config.Config{GitHubOrg: "myorg"}) + if err := s.AddUsersToOrg(context.Background(), set.New("foo")); err == nil { + t.Error("expected error when user lookup fails") + } +} + +func TestRemoveUsersSkipsIgnored(t *testing.T) { + api := &fakeAPI{} + s := svc(api, &config.Config{GitHubOrg: "myorg", IgnoredUsers: []string{"foo"}}) + if err := s.RemoveUsersFromOrg(context.Background(), set.New("foo", "bar")); err != nil { + t.Fatal(err) + } + if len(api.removed) != 1 || api.removed[0] != "bar" { + t.Errorf("removed = %v, want [bar]", api.removed) + } +} + +func TestSyncTeamAddsMissing(t *testing.T) { + api := &fakeAPI{ + orgMembers: []string{"chrisns", "foo", "bar"}, + teamMembers: []string{"chrisns"}, + } + s := svc(api, nil) + if err := s.SyncTeamMembers(context.Background()); err != nil { + t.Fatal(err) + } + want := set.New("foo", "bar") + if len(api.teamAdded) != 2 { + t.Fatalf("teamAdded = %v, want foo,bar", api.teamAdded) + } + for _, u := range api.teamAdded { + if !want.Has(u) { + t.Errorf("unexpected team add %s", u) + } + } +} + +func TestSyncTeamNoopWhenComplete(t *testing.T) { + api := &fakeAPI{orgMembers: []string{"chrisns"}, teamMembers: []string{"chrisns"}} + s := svc(api, nil) + if err := s.SyncTeamMembers(context.Background()); err != nil { + t.Fatal(err) + } + if len(api.teamAdded) != 0 { + t.Errorf("teamAdded = %v, want none", api.teamAdded) + } +} + +func TestSyncTeamContinuesPastErrors(t *testing.T) { + api := &fakeAPI{ + orgMembers: []string{"foo", "bar"}, + teamMembers: []string{}, + addTeamErr: map[string]error{"bar": errors.New("Validation Failed: no_saml_sso")}, + } + s := svc(api, nil) + if err := s.SyncTeamMembers(context.Background()); err != nil { + t.Fatalf("sync should not abort on per-user error: %v", err) + } + if len(api.teamAdded) != 1 || api.teamAdded[0] != "foo" { + t.Errorf("teamAdded = %v, want [foo]", api.teamAdded) + } +} diff --git a/internal/githuborg/rest.go b/internal/githuborg/rest.go new file mode 100644 index 000000000..5bb4aa7d3 --- /dev/null +++ b/internal/githuborg/rest.go @@ -0,0 +1,96 @@ +package githuborg + +import ( + "context" + "log/slog" + + "github.com/google/go-github/v88/github" +) + +// restAPI adapts the go-github client to the api interface, handling pagination. +type restAPI struct { + client *github.Client +} + +const perPage = 100 + +func (r restAPI) ListOrgMembers(ctx context.Context, org string) ([]string, error) { + opts := &github.ListMembersOptions{ListOptions: github.ListOptions{PerPage: perPage}} + var logins []string + for { + members, resp, err := r.client.Organizations.ListMembers(ctx, org, opts) + if err != nil { + return nil, err + } + for _, m := range members { + logins = append(logins, m.GetLogin()) + } + if resp.NextPage == 0 { + return logins, nil + } + opts.Page = resp.NextPage + } +} + +func (r restAPI) ListPendingInvites(ctx context.Context, org string) ([]string, error) { + opts := &github.ListOptions{PerPage: perPage} + var logins []string + for { + invites, resp, err := r.client.Organizations.ListPendingOrgInvitations(ctx, org, opts) + if err != nil { + return nil, err + } + for _, inv := range invites { + logins = append(logins, inv.GetLogin()) + } + if resp.NextPage == 0 { + return logins, nil + } + opts.Page = resp.NextPage + } +} + +func (r restAPI) GetUserID(ctx context.Context, username string) (int64, error) { + slog.Debug("looking up user", "user", username) + user, _, err := r.client.Users.Get(ctx, username) + if err != nil { + return 0, err + } + slog.Debug("resolved user id", "user", username, "userID", user.GetID()) + return user.GetID(), nil +} + +func (r restAPI) CreateInvitation(ctx context.Context, org string, inviteeID int64) error { + _, _, err := r.client.Organizations.CreateOrgInvitation(ctx, org, &github.CreateOrgInvitationOptions{ + InviteeID: new(inviteeID), + }) + return err +} + +func (r restAPI) RemoveMembership(ctx context.Context, org, username string) error { + _, err := r.client.Organizations.RemoveOrgMembership(ctx, username, org) + return err +} + +func (r restAPI) ListTeamMembers(ctx context.Context, org, slug string) ([]string, error) { + opts := &github.TeamListTeamMembersOptions{ListOptions: github.ListOptions{PerPage: perPage}} + var logins []string + for { + members, resp, err := r.client.Teams.ListTeamMembersBySlug(ctx, org, slug, opts) + if err != nil { + return nil, err + } + for _, m := range members { + logins = append(logins, m.GetLogin()) + } + if resp.NextPage == 0 { + return logins, nil + } + opts.Page = resp.NextPage + } +} + +func (r restAPI) AddTeamMembership(ctx context.Context, org, slug, username string) error { + _, _, err := r.client.Teams.AddTeamMembershipBySlug(ctx, org, slug, username, nil) + return err +} diff --git a/internal/googlews/google.go b/internal/googlews/google.go new file mode 100644 index 000000000..c87958d1b --- /dev/null +++ b/internal/googlews/google.go @@ -0,0 +1,121 @@ +// Package googlews reads the GitHub usernames stored against Google Workspace +// users via a multi-valued custom schema attribute (Accounts.github). +package googlews + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "golang.org/x/oauth2/google" + admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/option" + + "github.com/reMarkable/githubUserManager/internal/config" + "github.com/reMarkable/githubUserManager/internal/set" +) + +const scope = admin.AdminDirectoryUserReadonlyScope + +// Service fetches GitHub usernames from Google Workspace. +type Service struct { + cfg *config.Config + users usersListCaller +} + +// usersListCaller is the slice of the Admin SDK we depend on, extracted as an +// interface so the pagination logic can be unit tested without the network. +type usersListCaller interface { + List(ctx context.Context, pageToken string) (*admin.Users, error) +} + +// New builds a Service backed by the real Admin Directory API using JWT +// domain-wide delegation (impersonating the configured admin email address). +func New(ctx context.Context, cfg *config.Config) (*Service, error) { + jwtCfg, err := google.JWTConfigFromJSON(cfg.GoogleCredentialsJSON, scope) + if err != nil { + return nil, fmt.Errorf("parsing google credentials: %w", err) + } + jwtCfg.Subject = cfg.GoogleEmailAddress + + svc, err := admin.NewService(ctx, option.WithHTTPClient(jwtCfg.Client(ctx))) + if err != nil { + return nil, fmt.Errorf("creating admin directory service: %w", err) + } + + return &Service{cfg: cfg, users: apiCaller{svc: svc, cfg: cfg}}, nil +} + +// GithubUsersFromGoogle returns the set of GitHub usernames linked across all +// Google Workspace users, paging through the directory. +func (s *Service) GithubUsersFromGoogle(ctx context.Context) (set.Set, error) { + accounts := set.New() + pageToken := "" + for { + resp, err := s.users.List(ctx, pageToken) + if err != nil { + return nil, fmt.Errorf("listing google users: %w", err) + } + for _, u := range formatUserList(resp.Users) { + accounts.Add(u) + } + pageToken = resp.NextPageToken + if pageToken == "" { + break + } + } + return accounts, nil +} + +// formatUserList extracts the lower-cased, trimmed github account values from +// the users' Accounts custom schema. CustomSchemas values arrive as raw JSON. +func formatUserList(users []*admin.User) []string { + var out []string + for _, u := range users { + if u == nil { + continue + } + raw, ok := u.CustomSchemas["Accounts"] + if !ok { + continue + } + var parsed struct { + Github []struct { + Value string `json:"value"` + } `json:"github"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + continue + } + for _, acc := range parsed.Github { + v := strings.TrimSpace(strings.ToLower(acc.Value)) + if v != "" { + out = append(out, v) + } + } + } + return out +} + +type apiCaller struct { + svc *admin.Service + cfg *config.Config +} + +func (a apiCaller) List(ctx context.Context, pageToken string) (*admin.Users, error) { + call := a.svc.Users.List(). + Customer("my_customer"). + CustomFieldMask("Accounts"). + Projection("custom"). + Fields("users(customSchemas/Accounts/github(value)),nextPageToken"). + MaxResults(250). + Context(ctx) + if a.cfg.RemoveSuspendedUsers { + call = call.Query("isSuspended=false") + } + if pageToken != "" { + call = call.PageToken(pageToken) + } + return call.Do() +} diff --git a/internal/googlews/google_test.go b/internal/googlews/google_test.go new file mode 100644 index 000000000..8f5c1e02c --- /dev/null +++ b/internal/googlews/google_test.go @@ -0,0 +1,75 @@ +package googlews + +import ( + "context" + "testing" + + admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/googleapi" + + "github.com/reMarkable/githubUserManager/internal/set" +) + +func user(accountsJSON string) *admin.User { + u := &admin.User{CustomSchemas: map[string]googleapi.RawMessage{}} + if accountsJSON != "" { + u.CustomSchemas["Accounts"] = googleapi.RawMessage(accountsJSON) + } + return u +} + +func TestFormatUserList(t *testing.T) { + users := []*admin.User{ + user(`{"github":[{"value":"chrisns"}]}`), + user(`{"github":[{"value":"Foo"},{"value":"tar"}]}`), + user(`{"github":[{"value":"foo"},{"value":"bar"}]}`), + user(`{"github":[{"value":"userspace "}]}`), + user(`{"github":[{}]}`), + user(`{"github":[]}`), + user(`{}`), + user(""), + nil, + } + got := set.New() + for _, u := range formatUserList(users) { + got.Add(u) + } + want := set.New("chrisns", "foo", "tar", "bar", "userspace") + if got.Len() != want.Len() { + t.Fatalf("got %v want %v", got.Sorted(), want.Sorted()) + } + for _, u := range want.Sorted() { + if !got.Has(u) { + t.Errorf("missing %s", u) + } + } +} + +// fakeUsers implements usersListCaller with in-memory paging. +type fakeUsers struct{ pages []*admin.Users } + +func (f *fakeUsers) List(_ context.Context, token string) (*admin.Users, error) { + idx := 0 + if token != "" { + for i, p := range f.pages { + if p.NextPageToken == token { + idx = i + 1 + } + } + } + return f.pages[idx], nil +} + +func TestGithubUsersFromGooglePaginates(t *testing.T) { + s := &Service{users: &fakeUsers{pages: []*admin.Users{ + {Users: []*admin.User{user(`{"github":[{"value":"a"}]}`)}, NextPageToken: "p2"}, + {Users: []*admin.User{user(`{"github":[{"value":"b"},{"value":"a"}]}`)}}, + }}} + got, err := s.GithubUsersFromGoogle(context.Background()) + if err != nil { + t.Fatal(err) + } + if got.Len() != 2 || !got.Has("a") || !got.Has("b") { + t.Errorf("got %v, want [a b]", got.Sorted()) + } +} diff --git a/internal/set/set.go b/internal/set/set.go new file mode 100644 index 000000000..5f3c47644 --- /dev/null +++ b/internal/set/set.go @@ -0,0 +1,69 @@ +// Package set provides a small string-set helper used throughout the manager. +// Values are compared case-sensitively; callers are expected to normalise +// (lower-case/trim) before inserting, mirroring the original Node implementation. +package set + +import "sort" + +// Set is an unordered collection of unique strings. +type Set map[string]struct{} + +// New builds a Set from the given values, skipping empty strings. +func New(values ...string) Set { + s := make(Set, len(values)) + for _, v := range values { + s.Add(v) + } + return s +} + +// Add inserts a non-empty value into the set. +func (s Set) Add(value string) { + if value == "" { + return + } + s[value] = struct{}{} +} + +// Has reports whether value is present. +func (s Set) Has(value string) bool { + _, ok := s[value] + return ok +} + +// Len returns the number of elements. +func (s Set) Len() int { return len(s) } + +// Diff returns the elements in s that are not in other. +func (s Set) Diff(other Set) Set { + out := make(Set) + for v := range s { + if !other.Has(v) { + out.Add(v) + } + } + return out +} + +// Union returns the elements present in either s or other. +func (s Set) Union(other Set) Set { + out := make(Set, len(s)+len(other)) + for v := range s { + out.Add(v) + } + for v := range other { + out.Add(v) + } + return out +} + +// Sorted returns the elements as a deterministically ordered slice, which keeps +// logging stable across runs (Go map iteration order is randomised). +func (s Set) Sorted() []string { + out := make([]string, 0, len(s)) + for v := range s { + out = append(out, v) + } + sort.Strings(out) + return out +} diff --git a/internal/set/set_test.go b/internal/set/set_test.go new file mode 100644 index 000000000..a536f4bfd --- /dev/null +++ b/internal/set/set_test.go @@ -0,0 +1,39 @@ +package set + +import ( + "reflect" + "testing" +) + +func TestNewSkipsEmpty(t *testing.T) { + s := New("a", "", "b", "a") + if s.Len() != 2 { + t.Errorf("Len = %d, want 2", s.Len()) + } + if s.Has("") { + t.Error("empty string should not be present") + } +} + +func TestDiff(t *testing.T) { + a := New("a", "b", "c") + b := New("b") + if got := a.Diff(b).Sorted(); !reflect.DeepEqual(got, []string{"a", "c"}) { + t.Errorf("Diff = %v", got) + } +} + +func TestUnion(t *testing.T) { + a := New("a", "b") + b := New("b", "c") + if got := a.Union(b).Sorted(); !reflect.DeepEqual(got, []string{"a", "b", "c"}) { + t.Errorf("Union = %v", got) + } +} + +func TestSortedDeterministic(t *testing.T) { + s := New("c", "a", "b") + if got := s.Sorted(); !reflect.DeepEqual(got, []string{"a", "b", "c"}) { + t.Errorf("Sorted = %v", got) + } +} diff --git a/main.go b/main.go new file mode 100644 index 000000000..7b9af3f46 --- /dev/null +++ b/main.go @@ -0,0 +1,50 @@ +// Command githubusermanager reconciles GitHub organisation membership against +// GitHub usernames stored on Google Workspace user accounts. +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + + "github.com/reMarkable/githubUserManager/internal/app" + "github.com/reMarkable/githubUserManager/internal/config" + "github.com/reMarkable/githubUserManager/internal/githuborg" + "github.com/reMarkable/githubUserManager/internal/googlews" +) + +func main() { + ctx := context.Background() + + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, "configuration error:", err) + os.Exit(1) + } + + level := slog.LevelInfo + if cfg.Debug { + level = slog.LevelDebug + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) + + google, err := googlews.New(ctx, cfg) + if err != nil { + fmt.Fprintln(os.Stderr, "google setup error:", err) + os.Exit(1) + } + + gh, err := githuborg.New(cfg) + if err != nil { + fmt.Fprintln(os.Stderr, "github setup error:", err) + os.Exit(1) + } + + exitCode, err := app.Run(ctx, cfg, google, gh) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + os.Exit(exitCode) +} diff --git a/modules.d.ts b/modules.d.ts deleted file mode 100644 index ffbf00279..000000000 --- a/modules.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare namespace NodeJS { - export interface ProcessEnv { - GOOGLE_EMAIL_ADDRESS: string - GOOGLE_CREDENTIALS: string - ADD_USERS: string - EXIT_CODE_ON_MISMATCH: string - GITHUB_ORG: string - GITHUB_INSTALLATION_ID: string - GITHUB_APP_ID: string - GITHUB_PRIVATE_KEY: string - IGNORED_USERS?: string - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 761684dff..000000000 --- a/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "githubusermanager", - "version": "1.0.0", - "description": "1. [Add a custom attribute on the users](https://support.google.com/a/answer/6208725?hl=en#zippy=%2Cadd-a-new-custom-attribute)", - "license": "MIT", - "author": "Chris Nesbitt-Smith", - "type": "module", - "main": "./index.js", - "scripts": { - "check-types": "tsc", - "lint": "oxlint .", - "lint-fix": "oxlint --fix .", - "format": "oxfmt .", - "start": "tsx index.ts", - "test": "vitest --config vitest.config.ts" - }, - "dependencies": { - "@octokit/auth-app": "^8.2.0", - "@octokit/rest": "^22.0.1", - "googleapis": "^171.4.0", - "tsx": "^4.21.0", - "typescript": "^6.0.3" - }, - "devDependencies": { - "@tsconfig/node24": "^24.0.5", - "@types/node": "^24.13.3", - "@vitest/coverage-v8": "^4.1.11", - "oxfmt": "^0.65.0", - "oxlint": "^1.80.0", - "oxlint-tsgolint": "^0.25.0", - "vitest": "^4.1.11" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 8c4a19a6e..000000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,2352 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@octokit/auth-app': - specifier: ^8.2.0 - version: 8.2.0 - '@octokit/rest': - specifier: ^22.0.1 - version: 22.0.1 - googleapis: - specifier: ^171.4.0 - version: 171.4.0(supports-color@7.2.0) - tsx: - specifier: ^4.21.0 - version: 4.21.0 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - devDependencies: - '@tsconfig/node24': - specifier: ^24.0.5 - version: 24.0.5 - '@types/node': - specifier: ^24.13.3 - version: 24.13.3 - '@vitest/coverage-v8': - specifier: ^4.1.11 - version: 4.1.11(vitest@4.1.11) - oxfmt: - specifier: ^0.65.0 - version: 0.65.0 - oxlint: - specifier: ^1.80.0 - version: 1.80.0(oxlint-tsgolint@0.25.0) - oxlint-tsgolint: - specifier: ^0.25.0 - version: 0.25.0 - vitest: - specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.0.9(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.21.0)) - -packages: - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@1.0.2': - resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} - engines: {node: '>=18'} - - '@emnapi/core@1.9.2': - resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} - - '@emnapi/runtime@1.9.2': - resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@octokit/auth-app@8.2.0': - resolution: {integrity: sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g==} - engines: {node: '>= 20'} - - '@octokit/auth-oauth-app@9.0.3': - resolution: {integrity: sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==} - engines: {node: '>= 20'} - - '@octokit/auth-oauth-device@8.0.3': - resolution: {integrity: sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==} - engines: {node: '>= 20'} - - '@octokit/auth-oauth-user@6.0.2': - resolution: {integrity: sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==} - engines: {node: '>= 20'} - - '@octokit/auth-token@6.0.0': - resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} - engines: {node: '>= 20'} - - '@octokit/core@7.0.6': - resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} - engines: {node: '>= 20'} - - '@octokit/endpoint@11.0.3': - resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} - engines: {node: '>= 20'} - - '@octokit/graphql@9.0.3': - resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} - engines: {node: '>= 20'} - - '@octokit/oauth-authorization-url@8.0.0': - resolution: {integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==} - engines: {node: '>= 20'} - - '@octokit/oauth-methods@6.0.2': - resolution: {integrity: sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==} - engines: {node: '>= 20'} - - '@octokit/openapi-types@27.0.0': - resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} - - '@octokit/plugin-paginate-rest@14.0.0': - resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} - engines: {node: '>= 20'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-request-log@6.0.0': - resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} - engines: {node: '>= 20'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-rest-endpoint-methods@17.0.0': - resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} - engines: {node: '>= 20'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/request-error@7.1.0': - resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} - engines: {node: '>= 20'} - - '@octokit/request@10.0.8': - resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} - engines: {node: '>= 20'} - - '@octokit/rest@22.0.1': - resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} - engines: {node: '>= 20'} - - '@octokit/types@16.0.0': - resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} - - '@oxc-project/types@0.126.0': - resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==} - - '@oxfmt/binding-android-arm-eabi@0.65.0': - resolution: {integrity: sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxfmt/binding-android-arm64@0.65.0': - resolution: {integrity: sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxfmt/binding-darwin-arm64@0.65.0': - resolution: {integrity: sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxfmt/binding-darwin-x64@0.65.0': - resolution: {integrity: sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxfmt/binding-freebsd-x64@0.65.0': - resolution: {integrity: sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxfmt/binding-linux-arm-gnueabihf@0.65.0': - resolution: {integrity: sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm-musleabihf@0.65.0': - resolution: {integrity: sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm64-gnu@0.65.0': - resolution: {integrity: sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-arm64-musl@0.65.0': - resolution: {integrity: sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-ppc64-gnu@0.65.0': - resolution: {integrity: sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-gnu@0.65.0': - resolution: {integrity: sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-musl@0.65.0': - resolution: {integrity: sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-s390x-gnu@0.65.0': - resolution: {integrity: sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-gnu@0.65.0': - resolution: {integrity: sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-musl@0.65.0': - resolution: {integrity: sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-openharmony-arm64@0.65.0': - resolution: {integrity: sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxfmt/binding-win32-arm64-msvc@0.65.0': - resolution: {integrity: sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxfmt/binding-win32-ia32-msvc@0.65.0': - resolution: {integrity: sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxfmt/binding-win32-x64-msvc@0.65.0': - resolution: {integrity: sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxlint-tsgolint/darwin-arm64@0.25.0': - resolution: {integrity: sha512-87opKlwFP8qS9WHAeETV+kA0fC9Oyj4sg7OxWdI4xQY0WC7zlN6BgG66uE5mvtN5mahkt/gL0i/AVEnX6POq2Q==} - cpu: [arm64] - os: [darwin] - - '@oxlint-tsgolint/darwin-x64@0.25.0': - resolution: {integrity: sha512-HJmuZexsrhqp4WmETn+Soq7Ogt5F0jirv+cYRSniIPe+d/x5beQzLX69xOLhQRE+8FLGETe7FahWMVP8x0dW4g==} - cpu: [x64] - os: [darwin] - - '@oxlint-tsgolint/linux-arm64@0.25.0': - resolution: {integrity: sha512-aNyYsPREvCJi3qjfBA0sQB7DhT3y/W5Ac2JI2D8IJynoTOAhVZj401Si6901oDajlBWyqJqqojudn0VgHB6+7A==} - cpu: [arm64] - os: [linux] - - '@oxlint-tsgolint/linux-x64@0.25.0': - resolution: {integrity: sha512-+60+VjK9Mch3uA5WlTdNHuAm5+WA7wPPjuWdPWlU0F6JJpYpGZXUpO1RPKuFEWsBpNbLcLeJ0LbCJ1doWu58NA==} - cpu: [x64] - os: [linux] - - '@oxlint-tsgolint/win32-arm64@0.25.0': - resolution: {integrity: sha512-r53TO+eHp/t53nnUkQJfrYYXODPAxmtf3RUFQG5XsE2hD21IunliOaAdZXP2UwzCx+r/fbNaEelqTaAHcDr57w==} - cpu: [arm64] - os: [win32] - - '@oxlint-tsgolint/win32-x64@0.25.0': - resolution: {integrity: sha512-vqe66B+gL9HarhyHemdlfC2VWT7eoA+o/ufZ7zT6AGHv64boyDZIJS3U+rpZo+ey4O7wZtiS/vYR2fWqDoFeBw==} - cpu: [x64] - os: [win32] - - '@oxlint/binding-android-arm-eabi@1.80.0': - resolution: {integrity: sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxlint/binding-android-arm64@1.80.0': - resolution: {integrity: sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxlint/binding-darwin-arm64@1.80.0': - resolution: {integrity: sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxlint/binding-darwin-x64@1.80.0': - resolution: {integrity: sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxlint/binding-freebsd-x64@1.80.0': - resolution: {integrity: sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxlint/binding-linux-arm-gnueabihf@1.80.0': - resolution: {integrity: sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm-musleabihf@1.80.0': - resolution: {integrity: sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm64-gnu@1.80.0': - resolution: {integrity: sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-arm64-musl@1.80.0': - resolution: {integrity: sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-ppc64-gnu@1.80.0': - resolution: {integrity: sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-gnu@1.80.0': - resolution: {integrity: sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-musl@1.80.0': - resolution: {integrity: sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-s390x-gnu@1.80.0': - resolution: {integrity: sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-gnu@1.80.0': - resolution: {integrity: sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-musl@1.80.0': - resolution: {integrity: sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxlint/binding-openharmony-arm64@1.80.0': - resolution: {integrity: sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxlint/binding-win32-arm64-msvc@1.80.0': - resolution: {integrity: sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxlint/binding-win32-ia32-msvc@1.80.0': - resolution: {integrity: sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxlint/binding-win32-x64-msvc@1.80.0': - resolution: {integrity: sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/binding-android-arm64@1.0.0-rc.16': - resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-rc.16': - resolution: {integrity: sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-rc.16': - resolution: {integrity: sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-rc.16': - resolution: {integrity: sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16': - resolution: {integrity: sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16': - resolution: {integrity: sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': - resolution: {integrity: sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': - resolution: {integrity: sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': - resolution: {integrity: sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': - resolution: {integrity: sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': - resolution: {integrity: sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': - resolution: {integrity: sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.16': - resolution: {integrity: sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16': - resolution: {integrity: sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.16': - resolution: {integrity: sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-rc.16': - resolution: {integrity: sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@tsconfig/node24@24.0.5': - resolution: {integrity: sha512-DWC54fGPuhZgg3Ighg4Y/A/bA3ZdB5hnzAuv/MkQ/X85sVsBsMQxu8t7khutdYeiNksBbFdDyeqv1HSW2Zl8aA==} - - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} - - '@vitest/coverage-v8@4.1.11': - resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} - peerDependencies: - '@vitest/browser': 4.1.11 - vitest: 4.1.11 - peerDependenciesMeta: - '@vitest/browser': - optional: true - - '@vitest/expect@4.1.11': - resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - - '@vitest/mocker@4.1.11': - resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.11': - resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - - '@vitest/runner@4.1.11': - resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - - '@vitest/snapshot@4.1.11': - resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - - '@vitest/spy@4.1.11': - resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - - '@vitest/utils@4.1.11': - resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} - - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - ast-v8-to-istanbul@1.0.0: - resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - before-after-hook@4.0.0: - resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} - - bignumber.js@9.3.1: - resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} - - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - - fast-content-type-parse@3.0.0: - resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - - formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gaxios@7.1.4: - resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} - engines: {node: '>=18'} - - gcp-metadata@8.1.2: - resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} - engines: {node: '>=18'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - - google-auth-library@10.6.2: - resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} - engines: {node: '>=18'} - - google-logging-utils@1.1.3: - resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} - engines: {node: '>=14'} - - googleapis-common@8.0.1: - resolution: {integrity: sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==} - engines: {node: '>=18.0.0'} - - googleapis@171.4.0: - resolution: {integrity: sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==} - engines: {node: '>=18'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} - engines: {node: '>= 0.4'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - js-tokens@10.0.0: - resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - - json-bigint@1.0.0: - resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} - - json-with-bigint@3.5.8: - resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} - - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magicast@0.5.2: - resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - deprecated: Use your platform's native DOMException instead - - node-fetch@3.3.2: - resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - - oxfmt@0.65.0: - resolution: {integrity: sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - svelte: ^5.0.0 - vite-plus: '*' - peerDependenciesMeta: - svelte: - optional: true - vite-plus: - optional: true - - oxlint-tsgolint@0.25.0: - resolution: {integrity: sha512-7DBpqyLZCfyoXiivyfzt9Xmju/K1RcN+Y1W7buEwrgRCWWF11v9alypPqWGZBmh2erDkKL/kVyhKUH2Px+t13A==} - hasBin: true - - oxlint@1.80.0: - resolution: {integrity: sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=7.0.2001' - vite-plus: '*' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - vite-plus: - optional: true - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} - engines: {node: ^10 || ^12 || >=14} - - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} - engines: {node: '>=0.6'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - rolldown@1.0.0-rc.16: - resolution: {integrity: sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@1.1.1: - resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} - engines: {node: '>=18'} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} - engines: {node: ^20.0.0 || >=22.0.0} - - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - - toad-cache@3.7.0: - resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} - engines: {node: '>=12'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true - - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - - universal-github-app-jwt@2.2.2: - resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==} - - universal-user-agent@7.0.3: - resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - - url-template@2.0.8: - resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} - - vite@8.0.9: - resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.11: - resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.11 - '@vitest/browser-preview': 4.1.11 - '@vitest/browser-webdriverio': 4.1.11 - '@vitest/coverage-istanbul': 4.1.11 - '@vitest/coverage-v8': 4.1.11 - '@vitest/ui': 4.1.11 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - -snapshots: - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@bcoe/v8-coverage@1.0.2': {} - - '@emnapi/core@1.9.2': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.9.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.27.7': - optional: true - - '@esbuild/android-arm64@0.27.7': - optional: true - - '@esbuild/android-arm@0.27.7': - optional: true - - '@esbuild/android-x64@0.27.7': - optional: true - - '@esbuild/darwin-arm64@0.27.7': - optional: true - - '@esbuild/darwin-x64@0.27.7': - optional: true - - '@esbuild/freebsd-arm64@0.27.7': - optional: true - - '@esbuild/freebsd-x64@0.27.7': - optional: true - - '@esbuild/linux-arm64@0.27.7': - optional: true - - '@esbuild/linux-arm@0.27.7': - optional: true - - '@esbuild/linux-ia32@0.27.7': - optional: true - - '@esbuild/linux-loong64@0.27.7': - optional: true - - '@esbuild/linux-mips64el@0.27.7': - optional: true - - '@esbuild/linux-ppc64@0.27.7': - optional: true - - '@esbuild/linux-riscv64@0.27.7': - optional: true - - '@esbuild/linux-s390x@0.27.7': - optional: true - - '@esbuild/linux-x64@0.27.7': - optional: true - - '@esbuild/netbsd-arm64@0.27.7': - optional: true - - '@esbuild/netbsd-x64@0.27.7': - optional: true - - '@esbuild/openbsd-arm64@0.27.7': - optional: true - - '@esbuild/openbsd-x64@0.27.7': - optional: true - - '@esbuild/openharmony-arm64@0.27.7': - optional: true - - '@esbuild/sunos-x64@0.27.7': - optional: true - - '@esbuild/win32-arm64@0.27.7': - optional: true - - '@esbuild/win32-ia32@0.27.7': - optional: true - - '@esbuild/win32-x64@0.27.7': - optional: true - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': - dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@tybys/wasm-util': 0.10.1 - optional: true - - '@octokit/auth-app@8.2.0': - dependencies: - '@octokit/auth-oauth-app': 9.0.3 - '@octokit/auth-oauth-user': 6.0.2 - '@octokit/request': 10.0.8 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 - toad-cache: 3.7.0 - universal-github-app-jwt: 2.2.2 - universal-user-agent: 7.0.3 - - '@octokit/auth-oauth-app@9.0.3': - dependencies: - '@octokit/auth-oauth-device': 8.0.3 - '@octokit/auth-oauth-user': 6.0.2 - '@octokit/request': 10.0.8 - '@octokit/types': 16.0.0 - universal-user-agent: 7.0.3 - - '@octokit/auth-oauth-device@8.0.3': - dependencies: - '@octokit/oauth-methods': 6.0.2 - '@octokit/request': 10.0.8 - '@octokit/types': 16.0.0 - universal-user-agent: 7.0.3 - - '@octokit/auth-oauth-user@6.0.2': - dependencies: - '@octokit/auth-oauth-device': 8.0.3 - '@octokit/oauth-methods': 6.0.2 - '@octokit/request': 10.0.8 - '@octokit/types': 16.0.0 - universal-user-agent: 7.0.3 - - '@octokit/auth-token@6.0.0': {} - - '@octokit/core@7.0.6': - dependencies: - '@octokit/auth-token': 6.0.0 - '@octokit/graphql': 9.0.3 - '@octokit/request': 10.0.8 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 - before-after-hook: 4.0.0 - universal-user-agent: 7.0.3 - - '@octokit/endpoint@11.0.3': - dependencies: - '@octokit/types': 16.0.0 - universal-user-agent: 7.0.3 - - '@octokit/graphql@9.0.3': - dependencies: - '@octokit/request': 10.0.8 - '@octokit/types': 16.0.0 - universal-user-agent: 7.0.3 - - '@octokit/oauth-authorization-url@8.0.0': {} - - '@octokit/oauth-methods@6.0.2': - dependencies: - '@octokit/oauth-authorization-url': 8.0.0 - '@octokit/request': 10.0.8 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 - - '@octokit/openapi-types@27.0.0': {} - - '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/types': 16.0.0 - - '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - - '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/types': 16.0.0 - - '@octokit/request-error@7.1.0': - dependencies: - '@octokit/types': 16.0.0 - - '@octokit/request@10.0.8': - dependencies: - '@octokit/endpoint': 11.0.3 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 - fast-content-type-parse: 3.0.0 - json-with-bigint: 3.5.8 - universal-user-agent: 7.0.3 - - '@octokit/rest@22.0.1': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) - '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) - '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) - - '@octokit/types@16.0.0': - dependencies: - '@octokit/openapi-types': 27.0.0 - - '@oxc-project/types@0.126.0': {} - - '@oxfmt/binding-android-arm-eabi@0.65.0': - optional: true - - '@oxfmt/binding-android-arm64@0.65.0': - optional: true - - '@oxfmt/binding-darwin-arm64@0.65.0': - optional: true - - '@oxfmt/binding-darwin-x64@0.65.0': - optional: true - - '@oxfmt/binding-freebsd-x64@0.65.0': - optional: true - - '@oxfmt/binding-linux-arm-gnueabihf@0.65.0': - optional: true - - '@oxfmt/binding-linux-arm-musleabihf@0.65.0': - optional: true - - '@oxfmt/binding-linux-arm64-gnu@0.65.0': - optional: true - - '@oxfmt/binding-linux-arm64-musl@0.65.0': - optional: true - - '@oxfmt/binding-linux-ppc64-gnu@0.65.0': - optional: true - - '@oxfmt/binding-linux-riscv64-gnu@0.65.0': - optional: true - - '@oxfmt/binding-linux-riscv64-musl@0.65.0': - optional: true - - '@oxfmt/binding-linux-s390x-gnu@0.65.0': - optional: true - - '@oxfmt/binding-linux-x64-gnu@0.65.0': - optional: true - - '@oxfmt/binding-linux-x64-musl@0.65.0': - optional: true - - '@oxfmt/binding-openharmony-arm64@0.65.0': - optional: true - - '@oxfmt/binding-win32-arm64-msvc@0.65.0': - optional: true - - '@oxfmt/binding-win32-ia32-msvc@0.65.0': - optional: true - - '@oxfmt/binding-win32-x64-msvc@0.65.0': - optional: true - - '@oxlint-tsgolint/darwin-arm64@0.25.0': - optional: true - - '@oxlint-tsgolint/darwin-x64@0.25.0': - optional: true - - '@oxlint-tsgolint/linux-arm64@0.25.0': - optional: true - - '@oxlint-tsgolint/linux-x64@0.25.0': - optional: true - - '@oxlint-tsgolint/win32-arm64@0.25.0': - optional: true - - '@oxlint-tsgolint/win32-x64@0.25.0': - optional: true - - '@oxlint/binding-android-arm-eabi@1.80.0': - optional: true - - '@oxlint/binding-android-arm64@1.80.0': - optional: true - - '@oxlint/binding-darwin-arm64@1.80.0': - optional: true - - '@oxlint/binding-darwin-x64@1.80.0': - optional: true - - '@oxlint/binding-freebsd-x64@1.80.0': - optional: true - - '@oxlint/binding-linux-arm-gnueabihf@1.80.0': - optional: true - - '@oxlint/binding-linux-arm-musleabihf@1.80.0': - optional: true - - '@oxlint/binding-linux-arm64-gnu@1.80.0': - optional: true - - '@oxlint/binding-linux-arm64-musl@1.80.0': - optional: true - - '@oxlint/binding-linux-ppc64-gnu@1.80.0': - optional: true - - '@oxlint/binding-linux-riscv64-gnu@1.80.0': - optional: true - - '@oxlint/binding-linux-riscv64-musl@1.80.0': - optional: true - - '@oxlint/binding-linux-s390x-gnu@1.80.0': - optional: true - - '@oxlint/binding-linux-x64-gnu@1.80.0': - optional: true - - '@oxlint/binding-linux-x64-musl@1.80.0': - optional: true - - '@oxlint/binding-openharmony-arm64@1.80.0': - optional: true - - '@oxlint/binding-win32-arm64-msvc@1.80.0': - optional: true - - '@oxlint/binding-win32-ia32-msvc@1.80.0': - optional: true - - '@oxlint/binding-win32-x64-msvc@1.80.0': - optional: true - - '@rolldown/binding-android-arm64@1.0.0-rc.16': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-rc.16': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-rc.16': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-rc.16': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.16': - dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.16': - optional: true - - '@rolldown/pluginutils@1.0.0-rc.16': {} - - '@standard-schema/spec@1.1.0': {} - - '@tsconfig/node24@24.0.5': {} - - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.8': {} - - '@types/node@24.13.3': - dependencies: - undici-types: 7.18.2 - - '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.11 - ast-v8-to-istanbul: 1.0.0 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.2 - obug: 2.1.1 - std-env: 4.1.0 - tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.0.9(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.21.0)) - - '@vitest/expect@4.1.11': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.11(vite@8.0.9(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.21.0))': - dependencies: - '@vitest/spy': 4.1.11 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.9(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.21.0) - - '@vitest/pretty-format@4.1.11': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.11': - dependencies: - '@vitest/utils': 4.1.11 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.11': - dependencies: - '@vitest/pretty-format': 4.1.11 - '@vitest/utils': 4.1.11 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.11': {} - - '@vitest/utils@4.1.11': - dependencies: - '@vitest/pretty-format': 4.1.11 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - - agent-base@7.1.4: {} - - assertion-error@2.0.1: {} - - ast-v8-to-istanbul@1.0.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - estree-walker: 3.0.3 - js-tokens: 10.0.0 - - base64-js@1.5.1: {} - - before-after-hook@4.0.0: {} - - bignumber.js@9.3.1: {} - - buffer-equal-constant-time@1.0.1: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - chai@6.2.2: {} - - convert-source-map@2.0.0: {} - - data-uri-to-buffer@4.0.1: {} - - debug@4.4.3(supports-color@7.2.0): - dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 - - detect-libc@2.1.2: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@2.0.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - expect-type@1.3.0: {} - - extend@3.0.2: {} - - fast-content-type-parse@3.0.0: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fetch-blob@3.2.0: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.3.3 - - formdata-polyfill@4.0.10: - dependencies: - fetch-blob: 3.2.0 - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gaxios@7.1.4(supports-color@7.2.0): - dependencies: - extend: 3.0.2 - https-proxy-agent: 7.0.6(supports-color@7.2.0) - node-fetch: 3.3.2 - transitivePeerDependencies: - - supports-color - - gcp-metadata@8.1.2(supports-color@7.2.0): - dependencies: - gaxios: 7.1.4(supports-color@7.2.0) - google-logging-utils: 1.1.3 - json-bigint: 1.0.0 - transitivePeerDependencies: - - supports-color - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-tsconfig@4.14.0: - dependencies: - resolve-pkg-maps: 1.0.0 - - google-auth-library@10.6.2(supports-color@7.2.0): - dependencies: - base64-js: 1.5.1 - ecdsa-sig-formatter: 1.0.11 - gaxios: 7.1.4(supports-color@7.2.0) - gcp-metadata: 8.1.2(supports-color@7.2.0) - google-logging-utils: 1.1.3 - jws: 4.0.1 - transitivePeerDependencies: - - supports-color - - google-logging-utils@1.1.3: {} - - googleapis-common@8.0.1(supports-color@7.2.0): - dependencies: - extend: 3.0.2 - gaxios: 7.1.4(supports-color@7.2.0) - google-auth-library: 10.6.2(supports-color@7.2.0) - qs: 6.15.1 - url-template: 2.0.8 - transitivePeerDependencies: - - supports-color - - googleapis@171.4.0(supports-color@7.2.0): - dependencies: - google-auth-library: 10.6.2(supports-color@7.2.0) - googleapis-common: 8.0.1(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - - gopd@1.2.0: {} - - has-flag@4.0.0: {} - - has-symbols@1.1.0: {} - - hasown@2.0.3: - dependencies: - function-bind: 1.1.2 - - html-escaper@2.0.2: {} - - https-proxy-agent@7.0.6(supports-color@7.2.0): - dependencies: - agent-base: 7.1.4 - debug: 4.4.3(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - js-tokens@10.0.0: {} - - json-bigint@1.0.0: - dependencies: - bignumber.js: 9.3.1 - - json-with-bigint@3.5.8: {} - - jwa@2.0.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.1 - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - magicast@0.5.2: - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - source-map-js: 1.2.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.4 - - math-intrinsics@1.1.0: {} - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - node-domexception@1.0.0: {} - - node-fetch@3.3.2: - dependencies: - data-uri-to-buffer: 4.0.1 - fetch-blob: 3.2.0 - formdata-polyfill: 4.0.10 - - object-inspect@1.13.4: {} - - obug@2.1.1: {} - - oxfmt@0.65.0: - dependencies: - tinypool: 2.1.0 - optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.65.0 - '@oxfmt/binding-android-arm64': 0.65.0 - '@oxfmt/binding-darwin-arm64': 0.65.0 - '@oxfmt/binding-darwin-x64': 0.65.0 - '@oxfmt/binding-freebsd-x64': 0.65.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.65.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.65.0 - '@oxfmt/binding-linux-arm64-gnu': 0.65.0 - '@oxfmt/binding-linux-arm64-musl': 0.65.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.65.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.65.0 - '@oxfmt/binding-linux-riscv64-musl': 0.65.0 - '@oxfmt/binding-linux-s390x-gnu': 0.65.0 - '@oxfmt/binding-linux-x64-gnu': 0.65.0 - '@oxfmt/binding-linux-x64-musl': 0.65.0 - '@oxfmt/binding-openharmony-arm64': 0.65.0 - '@oxfmt/binding-win32-arm64-msvc': 0.65.0 - '@oxfmt/binding-win32-ia32-msvc': 0.65.0 - '@oxfmt/binding-win32-x64-msvc': 0.65.0 - - oxlint-tsgolint@0.25.0: - optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.25.0 - '@oxlint-tsgolint/darwin-x64': 0.25.0 - '@oxlint-tsgolint/linux-arm64': 0.25.0 - '@oxlint-tsgolint/linux-x64': 0.25.0 - '@oxlint-tsgolint/win32-arm64': 0.25.0 - '@oxlint-tsgolint/win32-x64': 0.25.0 - - oxlint@1.80.0(oxlint-tsgolint@0.25.0): - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.80.0 - '@oxlint/binding-android-arm64': 1.80.0 - '@oxlint/binding-darwin-arm64': 1.80.0 - '@oxlint/binding-darwin-x64': 1.80.0 - '@oxlint/binding-freebsd-x64': 1.80.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.80.0 - '@oxlint/binding-linux-arm-musleabihf': 1.80.0 - '@oxlint/binding-linux-arm64-gnu': 1.80.0 - '@oxlint/binding-linux-arm64-musl': 1.80.0 - '@oxlint/binding-linux-ppc64-gnu': 1.80.0 - '@oxlint/binding-linux-riscv64-gnu': 1.80.0 - '@oxlint/binding-linux-riscv64-musl': 1.80.0 - '@oxlint/binding-linux-s390x-gnu': 1.80.0 - '@oxlint/binding-linux-x64-gnu': 1.80.0 - '@oxlint/binding-linux-x64-musl': 1.80.0 - '@oxlint/binding-openharmony-arm64': 1.80.0 - '@oxlint/binding-win32-arm64-msvc': 1.80.0 - '@oxlint/binding-win32-ia32-msvc': 1.80.0 - '@oxlint/binding-win32-x64-msvc': 1.80.0 - oxlint-tsgolint: 0.25.0 - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - postcss@8.5.10: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - qs@6.15.1: - dependencies: - side-channel: 1.1.0 - - resolve-pkg-maps@1.0.0: {} - - rolldown@1.0.0-rc.16: - dependencies: - '@oxc-project/types': 0.126.0 - '@rolldown/pluginutils': 1.0.0-rc.16 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.16 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.16 - '@rolldown/binding-darwin-x64': 1.0.0-rc.16 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.16 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.16 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.16 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.16 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.16 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.16 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.16 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.16 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.16 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.16 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.16 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.16 - - safe-buffer@5.2.1: {} - - semver@7.7.4: {} - - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - siginfo@2.0.0: {} - - source-map-js@1.2.1: {} - - stackback@0.0.2: {} - - std-env@4.1.0: {} - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - tinybench@2.9.0: {} - - tinyexec@1.1.1: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tinypool@2.1.0: {} - - tinyrainbow@3.1.0: {} - - toad-cache@3.7.0: {} - - tslib@2.8.1: - optional: true - - tsx@4.21.0: - dependencies: - esbuild: 0.27.7 - get-tsconfig: 4.14.0 - optionalDependencies: - fsevents: 2.3.3 - - typescript@6.0.3: {} - - undici-types@7.18.2: {} - - universal-github-app-jwt@2.2.2: {} - - universal-user-agent@7.0.3: {} - - url-template@2.0.8: {} - - vite@8.0.9(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.21.0): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.10 - rolldown: 1.0.0-rc.16 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 24.13.3 - esbuild: 0.27.7 - fsevents: 2.3.3 - tsx: 4.21.0 - - vitest@4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.0.9(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.21.0)): - dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.0.9(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.21.0)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 8.0.9(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.21.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.13.3 - '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) - transitivePeerDependencies: - - msw - - web-streams-polyfill@3.3.3: {} - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 3984565d3..000000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,3 +0,0 @@ -allowBuilds: - esbuild: false - unrs-resolver: false diff --git a/renovate.json b/renovate.json index 77bc183fe..bbff386bc 100644 --- a/renovate.json +++ b/renovate.json @@ -3,31 +3,20 @@ "extends": [ "config:recommended", "group:allNonMajor", - "security:minimumReleaseAgeNpm", "customManagers:dockerfileVersions" ], "configMigration": true, - "npm": { - "rangeStrategy": "bump" - }, "packageRules": [ { - "description": "Update Docker base images and deps", + "description": "Update Docker base images", "matchDatasources": ["docker"], "groupName": "dockerfile dependencies" }, { - "matchDatasources": ["npm"], - "matchDepTypes": ["devDependencies"], - "matchUpdateTypes": ["patch", "minor"], - "groupName": "all non-major npm devDependencies" - }, - { - "matchDatasources": ["npm"], - "minimumReleaseAge": "7 days", - "matchDepTypes": ["dependencies"], + "description": "Group non-major Go module updates", + "matchManagers": ["gomod"], "matchUpdateTypes": ["patch", "minor"], - "groupName": "all non-major npm dependencies" + "groupName": "all non-major go dependencies" } ], "pre-commit": { diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index d2cdc097b..000000000 --- a/src/config.ts +++ /dev/null @@ -1,57 +0,0 @@ -export const config = { - get addUsers(): boolean { - return process.env.ADD_USERS?.toLowerCase() === 'true' - }, - get exitCodeOnMissmatch(): number { - return Number.parseInt(process.env.EXIT_CODE_ON_MISMATCH ?? '0') || 0 - }, - get githubAppID(): number { - return Number.parseInt(process.env.GITHUB_APP_ID) - }, - get githubInstallationID(): number { - return Number.parseInt(process.env.GITHUB_INSTALLATION_ID) - }, - get githubOrg(): string { - return process.env.GITHUB_ORG ?? '' - }, - get githubPrivateKey(): string { - return Buffer.from(process.env.GITHUB_PRIVATE_KEY, 'base64').toString('utf8') - }, - get githubTeamSlug(): string { - return process.env.GITHUB_TEAM_SLUG ?? '' - }, - get syncTeam(): boolean { - return process.env.SYNC_TEAM?.toLowerCase() === 'true' - }, - get googleCredentials(): googleCredentials { - return JSON.parse(Buffer.from(process.env.GOOGLE_CREDENTIALS, 'base64').toString('utf8')) - }, - get googleEmailAddress(): string { - return process.env.GOOGLE_EMAIL_ADDRESS ?? '' - }, - get ignoredUsers(): string[] { - return process.env.IGNORED_USERS?.toLowerCase().split(',') ?? [] - }, - get maxRemoveUsers(): number { - return Number.parseInt(process.env.MAX_REMOVE_USERS ?? '0') || 100 - }, - get removeSuspendedUsers(): boolean { - return process.env.REMOVE_SUSPENDED_USERS?.toLowerCase() === 'true' - }, - get removeUsers(): boolean { - return process.env.REMOVE_USERS?.toLowerCase() === 'true' - }, -} - -export interface googleCredentials { - type: string - project_id: string - private_key_id: string - private_key: string - client_email: string - client_id: string - auth_uri: string - token_uri: string - auth_provider_x509_cert_url: string - client_x509_cert_url: string -} diff --git a/src/github.ts b/src/github.ts deleted file mode 100644 index faab2cb51..000000000 --- a/src/github.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createAppAuth } from '@octokit/auth-app' -import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest' - -import * as mod from './github.js' -import { config } from './config.js' - -export function getAuthenticatedOctokit(): Octokit { - return new Octokit({ - auth: { - appId: config.githubAppID, - installationId: config.githubInstallationID, - privateKey: config.githubPrivateKey, - }, - authStrategy: createAppAuth, - }) -} - -export function formatUserList(users: { login?: string | null }[]): Set { - return new Set(users.flatMap((user) => user.login?.toLowerCase()).filter((x): x is string => Boolean(x))) -} - -export async function getGithubUsersFromGithub(): Promise> { - const octokit = mod.getAuthenticatedOctokit() - const members = await octokit.paginate(octokit.orgs.listMembers, { - org: config.githubOrg, - }) - - const pendingInvites = await octokit.paginate(octokit.orgs.listPendingInvitations, { - org: config.githubOrg, - }) - const pendingGithubAccounts = formatUserList(pendingInvites) - - const githubAccounts = formatUserList(members) - - if (pendingGithubAccounts.size > 0) { - console.log(`Outstanding GitHub invites for ${[...pendingGithubAccounts].join(', ')}`) - } - - return new Set([...githubAccounts, ...pendingGithubAccounts]) -} - -export async function getUserIdFromUsername(username: string): Promise { - const octokit = mod.getAuthenticatedOctokit() - console.log(`Looking up user ${username}`) - let user - try { - user = await octokit.users.getByUsername({ username }) - } catch { - throw `Unable to find user id for ${username}` - } - console.log(`User ${username} userid: ${user.data.id}`) - return user.data.id -} - -export async function addUsersToGitHubOrg(users: Set): Promise { - for (const user of users) { - await mod.addUserToGitHubOrg(user) - } -} - -export async function addUserToGitHubOrg( - user: string, -): Promise { - const octokit = mod.getAuthenticatedOctokit() - if (config.ignoredUsers.includes(user.toLowerCase())) { - console.log(`Ignoring add for ${user}`) - return false - } - const userId = await mod.getUserIdFromUsername(user) - console.log(`Inviting ${user} (${userId} to ${config.githubOrg})`) - return await octokit.orgs.createInvitation({ - invitee_id: userId, - org: config.githubOrg, - }) -} - -export async function getTeamMembers(): Promise> { - const octokit = mod.getAuthenticatedOctokit() - const members = await octokit.paginate(octokit.teams.listMembersInOrg, { - org: config.githubOrg, - team_slug: config.githubTeamSlug, - }) - return formatUserList(members) -} - -export async function addUsersToTeam(users: Set): Promise { - for (const user of users) { - try { - await mod.addUserToTeam(user) - } catch (error) { - console.error(`Skipping team add for ${user}: ${error instanceof Error ? error.message : String(error)}`) - } - } -} - -export async function addUserToTeam( - user: string, -): Promise { - if (config.ignoredUsers.includes(user.toLowerCase())) { - console.log(`Ignoring team add for ${user}`) - return false - } - const octokit = mod.getAuthenticatedOctokit() - console.log(`Adding ${user} to team ${config.githubTeamSlug}`) - return octokit.teams.addOrUpdateMembershipForUserInOrg({ - org: config.githubOrg, - team_slug: config.githubTeamSlug, - username: user, - }) -} - -export async function syncTeamMembers(): Promise { - const octokit = mod.getAuthenticatedOctokit() - - const members = await octokit.paginate(octokit.orgs.listMembers, { - org: config.githubOrg, - }) - const orgMembers = formatUserList(members) - - const teamMembers = await mod.getTeamMembers() - - const usersNotInTeam = new Set([...orgMembers].filter((x) => !teamMembers.has(x))) - if (usersNotInTeam.size > 0) { - console.log(`Users not in team ${config.githubTeamSlug}: ${[...usersNotInTeam].join(', ')}`) - await mod.addUsersToTeam(usersNotInTeam) - } else { - console.log(`All org members already in team ${config.githubTeamSlug}`) - } -} - -export async function removeUsersFromGitHubOrg(users: Set): Promise { - for (const user of users) { - await mod.removeUserFromGitHubOrg(user) - } -} - -export async function removeUserFromGitHubOrg( - user: string, -): Promise { - const octokit = mod.getAuthenticatedOctokit() - if (config.ignoredUsers.includes(user.toLowerCase())) { - console.log(`Ignoring remove for ${user}`) - return false - } - console.log(`Removing user/invitation ${user} from ${config.githubOrg}`) - return octokit.orgs.removeMembershipForUser({ - org: config.githubOrg, - username: user, - }) -} diff --git a/src/google.ts b/src/google.ts deleted file mode 100644 index 302bd7577..000000000 --- a/src/google.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { google, type Auth, type admin_directory_v1 } from 'googleapis' - -import * as mod from './google.js' -import { config } from './config.js' - -export async function googleAuth(): Promise { - const { googleCredentials, googleEmailAddress } = config - const jwtClient = new google.auth.JWT({ - email: googleCredentials.client_email, - key: googleCredentials.private_key, - scopes: ['https://www.googleapis.com/auth/admin.directory.user.readonly'], - subject: googleEmailAddress, - }) - await jwtClient.authorize() - return jwtClient -} - -export async function getAdminService(): Promise { - return google.admin({ - auth: await googleAuth(), - version: 'directory_v1', - }) -} - -// oxlint-disable-next-line no-explicit-any -export function formatUserList(users: any[]): Set { - return new Set( - users - .flatMap((user) => - user.customSchemas?.Accounts?.github?.map((account: { value: string }) => account.value?.toLowerCase().trim()), - ) - .filter(Boolean), - ) -} - -export async function getGithubUsersFromGoogle(): Promise> { - const service = await mod.getAdminService() - let githubAccounts = new Set() - // oxlint-disable-next-line no-undefined - let pageToken: string | undefined = undefined - - do { - const userList: { data: admin_directory_v1.Schema$Users } = await service.users.list({ - customFieldMask: 'Accounts', - customer: 'my_customer', - fields: 'users(customSchemas/Accounts/github(value)),nextPageToken', - maxResults: 250, - pageToken, - projection: 'custom', - query: config.removeSuspendedUsers ? 'isSuspended=false' : '', - }) - // oxlint-disable-next-line no-undefined - pageToken = userList.data.nextPageToken ?? undefined - githubAccounts = new Set([...githubAccounts, ...formatUserList(userList.data.users ?? [])]) - } while (pageToken != null) - return githubAccounts -} diff --git a/tests/__snapshots__/config.spec.ts.snap b/tests/__snapshots__/config.spec.ts.snap deleted file mode 100644 index 90d37d8f9..000000000 --- a/tests/__snapshots__/config.spec.ts.snap +++ /dev/null @@ -1,18 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`ignoredUsers > getIgnoredUsers empty 1`] = `[]`; - -exports[`ignoredUsers > getIgnoredUsers many 1`] = ` -[ - "user1", - "user2", - "user3", - "user4", -] -`; - -exports[`ignoredUsers > getIgnoredUsers single 1`] = ` -[ - "user1", -] -`; diff --git a/tests/__snapshots__/github.spec.ts.snap b/tests/__snapshots__/github.spec.ts.snap deleted file mode 100644 index afca1f161..000000000 --- a/tests/__snapshots__/github.spec.ts.snap +++ /dev/null @@ -1,167 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`github integration > addUserToGitHubOrg 1`] = ` -[MockFunction] { - "calls": [ - [ - { - "invitee_id": 123, - "org": "myorg", - }, - ], - ], - "results": [ - { - "type": "return", - "value": Promise {}, - }, - ], -} -`; - -exports[`github integration > addUserToTeam 1`] = ` -[MockFunction] { - "calls": [ - [ - { - "org": "myorg", - "team_slug": "remarkable", - "username": "foo", - }, - ], - ], - "results": [ - { - "type": "return", - "value": Promise {}, - }, - ], -} -`; - -exports[`github integration > addUsersToGitHubOrg 1`] = ` -[MockFunction addUserToGitHubOrg] { - "calls": [ - [ - "foo", - ], - [ - "bar", - ], - ], - "results": [ - { - "type": "return", - "value": Promise {}, - }, - { - "type": "return", - "value": Promise {}, - }, - ], -} -`; - -exports[`github integration > addUsersToTeam 1`] = ` -[MockFunction addUserToTeam] { - "calls": [ - [ - "foo", - ], - [ - "bar", - ], - ], - "results": [ - { - "type": "return", - "value": Promise {}, - }, - { - "type": "return", - "value": Promise {}, - }, - ], -} -`; - -exports[`github integration > getAuthenticatedOctokit 1`] = ` -[MockFunction] { - "calls": [ - [ - { - "auth": { - "appId": 123, - "installationId": 123, - "privateKey": "helloworld", - }, - "authStrategy": [Function], - }, - ], - ], - "results": [ - { - "type": "return", - "value": Mock { - "constructor": [MockFunction], - }, - }, - ], -} -`; - -exports[`github integration > getGithubUsersFromGithub 1`] = ` -Set { - "chrisns", - "bar", - "foo", - "pending", - "anotherpending", -} -`; - -exports[`github integration > getUserIdFromUsername found 1`] = `123`; - -exports[`github integration > getUserIdFromUsername notfound 1`] = `"Unable to find user id for foo"`; - -exports[`github integration > removeUserFromGitHubOrg 1`] = ` -[MockFunction] { - "calls": [ - [ - { - "org": "myorg", - "username": "foo", - }, - ], - ], - "results": [ - { - "type": "return", - "value": Promise {}, - }, - ], -} -`; - -exports[`github integration > removeUsersFromGitHubOrg 1`] = ` -[MockFunction removeUserFromGitHubOrg] { - "calls": [ - [ - "foo", - ], - [ - "bar", - ], - ], - "results": [ - { - "type": "return", - "value": Promise {}, - }, - { - "type": "return", - "value": Promise {}, - }, - ], -} -`; diff --git a/tests/__snapshots__/google.spec.ts.snap b/tests/__snapshots__/google.spec.ts.snap deleted file mode 100644 index 94304280e..000000000 --- a/tests/__snapshots__/google.spec.ts.snap +++ /dev/null @@ -1,75 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`google integration > formatUserList 1`] = ` -Set { - "chrisns", - "foo", - "tar", - "bar", -} -`; - -exports[`google integration > formatUserList bad 1`] = ` -Set { - "chrisns", - "userspace", -} -`; - -exports[`google integration > getGithubUsersFromGoogle 1`] = ` -Set { - "chrisns", - "foo", - "tar", - "bar", -} -`; - -exports[`google integration > googleAuth 1`] = ` -[MockFunction] { - "calls": [ - [ - { - "email": "foo", - "key": "bar", - "scopes": [ - "https://www.googleapis.com/auth/admin.directory.user.readonly", - ], - "subject": "hello@example.com", - }, - ], - ], - "results": [ - { - "type": "return", - "value": Mock { - "authorize": [MockFunction] { - "calls": [ - [], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - ], - }, - "authorizeAsync": [MockFunction], - "constructor": [MockFunction], - "createGToken": [MockFunction], - "createScoped": [MockFunction], - "fetchIdToken": [MockFunction], - "fromAPIKey": [MockFunction], - "fromJSON": [MockFunction], - "fromStream": [MockFunction], - "fromStreamAsync": [MockFunction], - "getCredentials": [MockFunction], - "getRequestMetadataAsync": [MockFunction], - "hasAnyScopes": [MockFunction], - "hasUserScopes": [MockFunction], - "refreshTokenNoCache": [MockFunction], - }, - }, - ], -} -`; diff --git a/tests/__snapshots__/index.spec.ts.snap b/tests/__snapshots__/index.spec.ts.snap deleted file mode 100644 index 764d8fcd3..000000000 --- a/tests/__snapshots__/index.spec.ts.snap +++ /dev/null @@ -1,135 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`match > should have consistent console output 1`] = ` -[MockFunction log] { - "calls": [ - [ - "Users from google: a, b, c, d", - ], - [ - "Users from github: a, b, c, d", - ], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - ], -} -`; - -exports[`missmatch > should add users if set to 1`] = ` -[MockFunction] { - "calls": [ - [ - Set { - "d", - }, - ], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - ], -} -`; - -exports[`missmatch > should have consistent console output 1`] = ` -[MockFunction log] { - "calls": [ - [ - "Users from google: a, d", - ], - [ - "Users from github: b, c, a", - ], - [ - "Users not in google: b, c", - ], - [ - "Users not in github: d", - ], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - ], -} -`; - -exports[`missmatch > should have consistent console output with full destructive mode on 1`] = ` -[MockFunction log] { - "calls": [ - [ - "Users from google: a, d", - ], - [ - "Users from github: b, c, a", - ], - [ - "Users not in google: b, c", - ], - [ - "Users not in github: d", - ], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - ], -} -`; - -exports[`missmatch > should remove users if set to 1`] = ` -[MockFunction] { - "calls": [ - [ - Set { - "b", - "c", - }, - ], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - ], -} -`; diff --git a/tests/config.spec.ts b/tests/config.spec.ts deleted file mode 100644 index 2de83d33d..000000000 --- a/tests/config.spec.ts +++ /dev/null @@ -1,183 +0,0 @@ -import * as mod from '../src/config' - -describe('addUsers', () => { - beforeEach(() => { - delete process.env.ADD_USERS - }) - it('no value', () => { - expect(mod.config.addUsers).toStrictEqual(false) - }) - it('invalid value', () => { - process.env.ADD_USERS = 'foobar' - expect(mod.config.addUsers).toStrictEqual(false) - }) - it('false value', () => { - process.env.ADD_USERS = 'false' - expect(mod.config.addUsers).toStrictEqual(false) - }) - it('true value', () => { - process.env.ADD_USERS = 'true' - expect(mod.config.addUsers).toStrictEqual(true) - }) - it('true value mixed case', () => { - process.env.ADD_USERS = 'tRuE' - expect(mod.config.addUsers).toStrictEqual(true) - }) -}) - -describe('removeUsers', () => { - beforeEach(() => { - delete process.env.REMOVE_USERS - }) - it('no value', () => { - expect(mod.config.removeUsers).toStrictEqual(false) - }) - it('invalid value', () => { - process.env.REMOVE_USERS = 'foobar' - expect(mod.config.removeUsers).toStrictEqual(false) - }) - it('false value', () => { - process.env.REMOVE_USERS = 'false' - expect(mod.config.removeUsers).toStrictEqual(false) - }) - it('true value', () => { - process.env.REMOVE_USERS = 'true' - expect(mod.config.removeUsers).toStrictEqual(true) - }) - it('true value mixed case', () => { - process.env.REMOVE_USERS = 'tRuE' - expect(mod.config.removeUsers).toStrictEqual(true) - }) -}) - -describe('ignoredUsers', () => { - it('getIgnoredUsers empty', () => { - delete process.env.IGNORED_USERS - expect(mod.config.ignoredUsers).toMatchSnapshot() - }) - - it('getIgnoredUsers single', () => { - process.env.IGNORED_USERS = 'user1' - expect(mod.config.ignoredUsers).toMatchSnapshot() - }) - it('getIgnoredUsers many', () => { - process.env.IGNORED_USERS = 'user1,user2,user3,USER4' - expect(mod.config.ignoredUsers).toMatchSnapshot() - }) -}) - -describe('githubPrivateKey', () => { - it('should base 64 decode', () => { - process.env.GITHUB_PRIVATE_KEY = Buffer.from('helloworld').toString('base64') - expect(mod.config.githubPrivateKey).toStrictEqual('helloworld') - }) -}) - -describe('googleCredentials', () => { - it('should base 64 decode and json parse', () => { - process.env.GOOGLE_CREDENTIALS = Buffer.from(JSON.stringify({ hello: 'world' })).toString('base64') - expect(mod.config.googleCredentials).toStrictEqual({ hello: 'world' }) - }) -}) - -describe('exitCodeOnMissmatch', () => { - beforeEach(() => { - delete process.env.EXIT_CODE_ON_MISMATCH - }) - it('no value', () => { - expect(mod.config.exitCodeOnMissmatch).toStrictEqual(0) - }) - it('invalid value', () => { - process.env.EXIT_CODE_ON_MISMATCH = 'hello' - expect(mod.config.exitCodeOnMissmatch).toStrictEqual(0) - }) - it('valid value', () => { - process.env.EXIT_CODE_ON_MISMATCH = '123' - expect(mod.config.exitCodeOnMissmatch).toStrictEqual(123) - }) -}) - -describe('githubAppID', () => { - beforeEach(() => { - delete process.env.GITHUB_APP_ID - }) - it('no value', () => { - expect(mod.config.githubAppID).toStrictEqual(Number.NaN) - }) - it('invalid value', () => { - process.env.GITHUB_APP_ID = 'hello' - expect(mod.config.githubAppID).toStrictEqual(Number.NaN) - }) - it('valid value', () => { - process.env.GITHUB_APP_ID = '123' - expect(mod.config.githubAppID).toStrictEqual(123) - }) -}) - -describe('githubInstallationID', () => { - beforeEach(() => { - delete process.env.GITHUB_INSTALLATION_ID - }) - it('no value', () => { - expect(mod.config.githubInstallationID).toStrictEqual(Number.NaN) - }) - it('invalid value', () => { - process.env.GITHUB_INSTALLATION_ID = 'hello' - expect(mod.config.githubInstallationID).toStrictEqual(Number.NaN) - }) - it('valid value', () => { - process.env.GITHUB_INSTALLATION_ID = '123' - expect(mod.config.githubInstallationID).toStrictEqual(123) - }) -}) - -describe('githubOrg', () => { - beforeEach(() => { - delete process.env.GITHUB_ORG - }) - it('no value', () => { - expect(mod.config.githubOrg).toStrictEqual('') - }) - it('valid value', () => { - process.env.GITHUB_ORG = 'hello' - expect(mod.config.githubOrg).toStrictEqual('hello') - }) -}) - -describe('googleEmailAddress', () => { - beforeEach(() => { - delete process.env.GOOGLE_EMAIL_ADDRESS - }) - it('no value', () => { - expect(mod.config.googleEmailAddress).toStrictEqual('') - }) - it('valid value', () => { - process.env.GOOGLE_EMAIL_ADDRESS = 'hello' - expect(mod.config.googleEmailAddress).toStrictEqual('hello') - }) -}) - -describe('removeSuspendedUsers', () => { - beforeEach(() => { - delete process.env.REMOVE_SUSPENDED_USERS - }) - it('no value', () => { - expect(mod.config.removeSuspendedUsers).toStrictEqual(false) - }) - it('invalid value', () => { - process.env.REMOVE_SUSPENDED_USERS = 'foobar' - expect(mod.config.removeSuspendedUsers).toStrictEqual(false) - }) - it('false value', () => { - process.env.REMOVE_SUSPENDED_USERS = 'false' - expect(mod.config.removeSuspendedUsers).toStrictEqual(false) - }) - it('true value', () => { - process.env.REMOVE_SUSPENDED_USERS = 'true' - expect(mod.config.removeSuspendedUsers).toStrictEqual(true) - }) - it('true value mixed case', () => { - process.env.REMOVE_SUSPENDED_USERS = 'tRuE' - expect(mod.config.removeSuspendedUsers).toStrictEqual(true) - }) -}) diff --git a/tests/github.spec.ts b/tests/github.spec.ts deleted file mode 100644 index 48805081e..000000000 --- a/tests/github.spec.ts +++ /dev/null @@ -1,187 +0,0 @@ - -vi.mock('@octokit/rest') -import { Octokit } from '@octokit/rest' -import { config } from '../src/config' -import * as mod from '../src/github' - -describe('github integration', () => { - beforeEach(() => { - vi.spyOn(config, 'githubPrivateKey', 'get').mockReturnValue('helloworld') - vi.spyOn(config, 'githubAppID', 'get').mockReturnValue(123) - vi.spyOn(config, 'githubInstallationID', 'get').mockReturnValue(123) - vi.spyOn(global.console, 'log').mockImplementation(() => {}) - }) - - it('getAuthenticatedOctokit', () => { - mod.getAuthenticatedOctokit() - return expect(Octokit).toMatchSnapshot() - }) - it('getGithubUsersFromGithub', async () => { - const fakeOctokit = { - orgs: { listMembers: vi.fn(), listPendingInvitations: vi.fn() }, - paginate: vi - .fn<() => Promise<{ login: string }[]>>() - .mockResolvedValueOnce([{ login: 'chrisns' }, { login: 'bar' }, { login: 'foo' }]) - .mockResolvedValueOnce([{ login: 'pending' }, { login: 'chrisns' }, { login: 'anotherpending' }]), - } - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - await expect(mod.getGithubUsersFromGithub()).resolves.toMatchSnapshot() - }) - it('getUserIdFromUsername found', () => { - const fakeOctokit = { - users: { - getByUsername: vi.fn<() => Promise<{ data: { id: number } }>>().mockResolvedValue({ data: { id: 123 } }), - }, - } - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - return expect(mod.getUserIdFromUsername('foo')).resolves.toMatchSnapshot() - }) - - it('getUserIdFromUsername notfound', () => { - const fakeOctokit = { - users: { - getByUsername: vi.fn<() => Promise>().mockRejectedValue(new Error('not found')), - }, - } - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - return expect(mod.getUserIdFromUsername('foo')).rejects.toMatchSnapshot() - }) - - it('addUsersToGitHubOrg', async () => { - const users = new Set(['foo', 'bar']) - vi.spyOn(mod, 'addUserToGitHubOrg').mockResolvedValue(false) - await mod.addUsersToGitHubOrg(users) - return expect(mod.addUserToGitHubOrg).toMatchSnapshot() - }) - - it('removeUsersFromGitHubOrg', async () => { - const users = new Set(['foo', 'bar']) - vi.spyOn(mod, 'removeUserFromGitHubOrg').mockResolvedValue(false) - await mod.removeUsersFromGitHubOrg(users) - return expect(mod.removeUserFromGitHubOrg).toMatchSnapshot() - }) - - it('removeUserFromGitHubOrg skip ignore', async () => { - vi.spyOn(config, 'ignoredUsers', 'get').mockReturnValue(['foo']) - await expect(mod.removeUserFromGitHubOrg('foo')).resolves.toBe(false) - }) - - it('addUserToGitHubOrg skip ignore', async () => { - vi.spyOn(config, 'ignoredUsers', 'get').mockReturnValue(['foo']) - await expect(mod.addUserToGitHubOrg('foo')).resolves.toBe(false) - }) - - it('addUserToGitHubOrg', async () => { - const fakeOctokit = { - orgs: { - createInvitation: vi.fn<() => Promise>().mockResolvedValue(true), - }, - } - vi.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') - vi.spyOn(mod, 'getUserIdFromUsername').mockResolvedValue(123) - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - await mod.addUserToGitHubOrg('foo') - return expect(fakeOctokit.orgs.createInvitation).toMatchSnapshot() - }) - - it('removeUserFromGitHubOrg', async () => { - const fakeOctokit = { - orgs: { - removeMembershipForUser: vi.fn<() => Promise>().mockResolvedValue(true), - }, - } - vi.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') - vi.spyOn(mod, 'getUserIdFromUsername').mockResolvedValue(123) - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - await mod.removeUserFromGitHubOrg('foo') - return expect(fakeOctokit.orgs.removeMembershipForUser).toMatchSnapshot() - }) - - it('formatUserList', () => { - const response = [{ login: 'chrisns' }, { login: 'chrisns' }, { login: 'foo' }, {}] - return expect(mod.formatUserList(response)).toEqual(new Set(['chrisns', 'foo'])) - }) - - it('addUserToTeam skip ignore', async () => { - vi.spyOn(config, 'ignoredUsers', 'get').mockReturnValue(['foo']) - await expect(mod.addUserToTeam('foo')).resolves.toBe(false) - }) - - it('addUserToTeam', async () => { - const fakeOctokit = { - teams: { - addOrUpdateMembershipForUserInOrg: vi.fn<() => Promise>().mockResolvedValue(true), - }, - } - vi.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') - vi.spyOn(config, 'githubTeamSlug', 'get').mockReturnValue('remarkable') - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - await mod.addUserToTeam('foo') - return expect(fakeOctokit.teams.addOrUpdateMembershipForUserInOrg).toMatchSnapshot() - }) - - it('addUsersToTeam', async () => { - const users = new Set(['foo', 'bar']) - vi.spyOn(mod, 'addUserToTeam').mockResolvedValue(false) - await mod.addUsersToTeam(users) - return expect(mod.addUserToTeam).toMatchSnapshot() - }) - - it('addUsersToTeam skips users that error', async () => { - const users = new Set(['foo', 'bar']) - vi.spyOn(mod, 'addUserToTeam') - .mockRejectedValueOnce(new Error('Validation Failed: no_saml_sso')) - .mockResolvedValueOnce(false) - await expect(mod.addUsersToTeam(users)).resolves.toBeUndefined() - expect(mod.addUserToTeam).toHaveBeenCalledTimes(2) - }) - - it('getTeamMembers', async () => { - const fakeOctokit = { - teams: { listMembersInOrg: vi.fn() }, - paginate: vi - .fn<() => Promise<{ login: string }[]>>() - .mockResolvedValue([{ login: 'chrisns' }, { login: 'foo' }]), - } - vi.spyOn(config, 'githubTeamSlug', 'get').mockReturnValue('remarkable') - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - await expect(mod.getTeamMembers()).resolves.toEqual(new Set(['chrisns', 'foo'])) - }) - - it('syncTeamMembers adds org members missing from team', async () => { - const fakeOctokit = { - orgs: { listMembers: vi.fn() }, - paginate: vi - .fn<() => Promise<{ login: string }[]>>() - .mockResolvedValue([{ login: 'chrisns' }, { login: 'foo' }, { login: 'bar' }]), - } - vi.spyOn(config, 'githubTeamSlug', 'get').mockReturnValue('remarkable') - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - vi.spyOn(mod, 'getTeamMembers').mockResolvedValue(new Set(['chrisns'])) - const addSpy = vi.spyOn(mod, 'addUsersToTeam').mockResolvedValue() - await mod.syncTeamMembers() - expect(addSpy).toHaveBeenCalledWith(new Set(['foo', 'bar'])) - }) - - it('syncTeamMembers no-op when all present', async () => { - const fakeOctokit = { - orgs: { listMembers: vi.fn() }, - paginate: vi.fn<() => Promise<{ login: string }[]>>().mockResolvedValue([{ login: 'chrisns' }]), - } - vi.spyOn(config, 'githubTeamSlug', 'get').mockReturnValue('remarkable') - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - vi.spyOn(mod, 'getTeamMembers').mockResolvedValue(new Set(['chrisns'])) - const addSpy = vi.spyOn(mod, 'addUsersToTeam').mockResolvedValue() - await mod.syncTeamMembers() - expect(addSpy).not.toHaveBeenCalled() - }) -}) diff --git a/tests/google.spec.ts b/tests/google.spec.ts deleted file mode 100644 index 5d0e561ad..000000000 --- a/tests/google.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -vi.mock('googleapis') - -import { google } from 'googleapis' -import * as mod from '../src/google' - -const fakeUsersResponse = [ - { customSchemas: { Accounts: { github: [{ value: 'chrisns' }] } } }, - { - customSchemas: { - Accounts: { github: [{ value: 'Foo' }, { value: 'tar' }] }, - }, - }, - { - customSchemas: { - Accounts: { github: [{ value: 'foo' }, { value: 'bar' }] }, - }, - }, -] - -describe('google integration', () => { - beforeEach(() => { - process.env.GOOGLE_EMAIL_ADDRESS = 'hello@example.com' - process.env.GOOGLE_CREDENTIALS = Buffer.from(JSON.stringify({ client_email: 'foo', private_key: 'bar' })).toString( - 'base64', - ) - vi.spyOn(global.console, 'log').mockImplementation(() => {}) - }) - it('googleAuth', () => { - mod.googleAuth() - return expect(google.auth.JWT).toMatchSnapshot() - }) - it('getAdminService', () => { - // FIXME: TypeError: google.admin.mockReturnValue is not a function - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - google.admin.mockReturnValue('adminservice') - const result = mod.getAdminService() - return expect(result).resolves.toBe('adminservice') - }) - - it('getGithubUsersFromGoogle', async () => { - const service = { - users: { - list: vi - .fn<() => Promise<{ data: { users: typeof fakeUsersResponse } }>>() - .mockResolvedValue({ data: { users: fakeUsersResponse } }), - }, - } - // FIXME: TypeError: Cannot assign to read only property 'getAdminService' of object '[object Module]' - // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum - vi.spyOn(mod, 'getAdminService').mockResolvedValue(service) - - const result = mod.getGithubUsersFromGoogle() - await expect(result).resolves.toMatchSnapshot() - }) - - it('formatUserList', () => expect(mod.formatUserList(fakeUsersResponse)).toMatchSnapshot()) - - it('formatUserList bad', () => - expect( - mod.formatUserList([ - {}, - { customSchemas: {} }, - { customSchemas: { Accounts: {} } }, - { customSchemas: { Accounts: { github: [] } } }, - { customSchemas: { Accounts: { github: [{}] } } }, - { customSchemas: { Accounts: { github: [{ value: 'chrisns' }] } } }, - { customSchemas: { Accounts: { github: [{ value: 'userspace ' }] } } }, - ]), - ).toMatchSnapshot()) -}) diff --git a/tests/index.spec.ts b/tests/index.spec.ts deleted file mode 100644 index 0551f7d43..000000000 --- a/tests/index.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -vi.mock('../src/google.js') -vi.mock('../src/github.js') - -import * as google from '../src/google.js' -import * as github from '../src/github.js' -import * as mod from '../index.js' - -let processExitSpy -let consoleSpy - -beforeEach(() => { - processExitSpy = vi.spyOn(global.process, 'exit').mockImplementation(() => undefined as never) - consoleSpy = vi.spyOn(global.console, 'log').mockImplementation(() => {}) -}) - -describe('missmatch', () => { - beforeEach(() => { - // @ts-expect-error mockResolved unexpected - google.getGithubUsersFromGoogle.mockResolvedValue(new Set(['a', 'd'])) - // @ts-expect-error mockResolved unexpected - github.getGithubUsersFromGithub.mockResolvedValue(new Set(['b', 'c', 'a'])) - }) - it('should have consistent console output', async () => { - await mod.run() - return expect(consoleSpy).toMatchSnapshot() - }) - - it('should exit with 0 by default as there is a missmatch', async () => { - await mod.run() - return expect(processExitSpy).toHaveBeenCalledWith(0) - }) - it('should exit with 122 if defined when there is a missmatch', async () => { - process.env.EXIT_CODE_ON_MISMATCH = '122' - await mod.run() - return expect(processExitSpy).toHaveBeenCalledWith(122) - }) - it('should not add users if not set to', async () => { - delete process.env.ADD_USERS - await mod.run() - return expect(github.addUsersToGitHubOrg).not.toHaveBeenCalled() - }) - it('should not remove users if not set to', async () => { - delete process.env.REMOVE_USERS - await mod.run() - return expect(github.removeUsersFromGitHubOrg).not.toHaveBeenCalled() - }) - it('should add users if set to', async () => { - process.env.ADD_USERS = 'true' - await mod.run() - return expect(github.addUsersToGitHubOrg).toMatchSnapshot() - }) - it('should remove users if set to', async () => { - process.env.REMOVE_USERS = 'true' - await mod.run() - return expect(github.removeUsersFromGitHubOrg).toMatchSnapshot() - }) - it('should have consistent console output with full destructive mode on', async () => { - process.env.REMOVE_USERS = 'true' - process.env.ADD_USERS = 'true' - await mod.run() - return expect(consoleSpy).toMatchSnapshot() - }) -}) - -describe('match', () => { - beforeEach(() => { - // @ts-expect-error mockResolved unexpected - google.getGithubUsersFromGoogle.mockResolvedValue(new Set(['a', 'b', 'c', 'd'])) - // @ts-expect-error mockResolved unexpected - github.getGithubUsersFromGithub.mockResolvedValue(new Set(['a', 'b', 'c', 'd'])) - }) - it('should have consistent console output', async () => { - await mod.run() - return expect(consoleSpy).toMatchSnapshot() - }) - it('should exit with 0 by default', async () => { - await mod.run() - return expect(processExitSpy).toHaveBeenCalledWith(0) - }) - it('should not exit with 122 if defined', async () => { - process.env.EXIT_CODE_ON_MISMATCH = '122' - await mod.run() - return expect(processExitSpy).not.toHaveBeenCalledWith(122) - }) - it('should not add users', async () => { - process.env.ADD_USERS = 'true' - await mod.run() - return expect(github.addUsersToGitHubOrg).not.toHaveBeenCalled() - }) - it('should not remove users', async () => { - process.env.REMOVE_USERS = 'true' - await mod.run() - return expect(github.removeUsersFromGitHubOrg).not.toHaveBeenCalled() - }) -}) diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index f5bd88cbc..000000000 --- a/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "@tsconfig/node24/tsconfig.json", - "compilerOptions": { - "esModuleInterop": true, - "declaration": true, - "noEmit": true, - "types": ["node"] - }, - "exclude": ["node_modules", "tests/**"] -} diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index ab42278ce..000000000 --- a/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - mockReset: true, - restoreMocks: true, - } -}); From 962cc55f07e5b56f0fa8d16e4fc13084950c0f1d Mon Sep 17 00:00:00 2001 From: Marcus Ramberg Date: Fri, 23 Oct 2026 11:54:22 +0200 Subject: [PATCH 2/3] refactor: replace ghinstallation with local app transport --- go.mod | 2 - go.sum | 4 - internal/githuborg/apptransport.go | 173 +++++++++++++++++++++++++++++ internal/githuborg/github.go | 3 +- 4 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 internal/githuborg/apptransport.go diff --git a/go.mod b/go.mod index b38d4d94c..4e6a53e61 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/reMarkable/githubUserManager go 1.27.3 require ( - github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 github.com/google/go-github/v88 v88.0.0 golang.org/x/oauth2 v0.37.0 google.golang.org/api v0.307.0 @@ -17,7 +16,6 @@ require ( github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/s2a-go v0.1.11 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index 6e8e3c5fd..2e70ff8ec 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQ cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk= cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= -github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 h1:KQfD+43pRw9NUJhGycGrFr9vF1MubZacksKol1gomFI= -github.com/bradleyfalzon/ghinstallation/v2 v2.19.0/go.mod h1:fe5ECIhCdEnxwLiBlNTxx9CP455wt42BELnlDVMvaAA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -17,8 +15,6 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/internal/githuborg/apptransport.go b/internal/githuborg/apptransport.go new file mode 100644 index 000000000..c33f25f34 --- /dev/null +++ b/internal/githuborg/apptransport.go @@ -0,0 +1,173 @@ +package githuborg + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "sync" + "time" +) + +// appTransport is an http.RoundTripper that authenticates requests as a GitHub +// App installation. It mints a short-lived JWT signed with the app's private +// key, exchanges it for an installation access token, and caches that token +// until shortly before it expires. +// +// It replaces github.com/bradleyfalzon/ghinstallation with a stdlib-only +// implementation covering just what this service needs. +type appTransport struct { + base http.RoundTripper + appID int64 + installationID int64 + key *rsa.PrivateKey + + mu sync.Mutex + token string + tokenExpiry time.Time +} + +// newAppTransport parses the PEM-encoded RSA private key and returns a +// RoundTripper authenticating as the given app installation. +func newAppTransport(base http.RoundTripper, appID, installationID int64, pemKey []byte) (*appTransport, error) { + if base == nil { + base = http.DefaultTransport + } + key, err := parsePrivateKey(pemKey) + if err != nil { + return nil, err + } + return &appTransport{ + base: base, + appID: appID, + installationID: installationID, + key: key, + }, nil +} + +func parsePrivateKey(pemKey []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemKey) + if block == nil { + return nil, fmt.Errorf("parsing private key: no PEM block found") + } + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing private key: %w", err) + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("parsing private key: not an RSA key") + } + return key, nil +} + +func (t *appTransport) RoundTrip(req *http.Request) (*http.Response, error) { + token, err := t.installationToken(req.Context()) + if err != nil { + return nil, err + } + // Clone so we don't mutate the caller's request. + clone := req.Clone(req.Context()) + clone.Header.Set("Authorization", "Bearer "+token) + clone.Header.Set("Accept", "application/vnd.github+json") + return t.base.RoundTrip(clone) +} + +// installationToken returns a cached installation token, refreshing it when it +// is missing or within a minute of expiry. +func (t *appTransport) installationToken(ctx context.Context) (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if t.token != "" && time.Until(t.tokenExpiry) > time.Minute { + return t.token, nil + } + + jwt, err := t.appJWT() + if err != nil { + return "", err + } + + url := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", t.installationID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return "", err + } + httpReq.Header.Set("Authorization", "Bearer "+jwt) + httpReq.Header.Set("Accept", "application/vnd.github+json") + + resp, err := t.base.RoundTrip(httpReq) + if err != nil { + return "", fmt.Errorf("requesting installation token: %w", err) + } + defer func() { + err := resp.Body.Close() + if err != nil { + slog.Warn("Failed to close response body", "err", err) + } + }() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("requesting installation token: unexpected status %s: %s", resp.Status, body) + } + + var out struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + if err := json.Unmarshal(body, &out); err != nil { + return "", fmt.Errorf("decoding installation token: %w", err) + } + + t.token = out.Token + t.tokenExpiry = out.ExpiresAt + return t.token, nil +} + +// appJWT builds and signs the short-lived RS256 JWT used to authenticate as the +// app itself when requesting installation tokens. +func (t *appTransport) appJWT() (string, error) { + now := time.Now() + header := map[string]string{"alg": "RS256", "typ": "JWT"} + // Backdate iat by 60s to tolerate clock drift; GitHub caps lifetime at 10m. + claims := map[string]any{ + "iat": now.Add(-60 * time.Second).Unix(), + "exp": now.Add(9 * time.Minute).Unix(), + "iss": strconv.FormatInt(t.appID, 10), + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + + signingInput := encodeSegment(headerJSON) + "." + encodeSegment(claimsJSON) + hashed := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, t.key, crypto.SHA256, hashed[:]) + if err != nil { + return "", fmt.Errorf("signing jwt: %w", err) + } + return signingInput + "." + encodeSegment(sig), nil +} + +func encodeSegment(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/internal/githuborg/github.go b/internal/githuborg/github.go index 329969767..ae075f77d 100644 --- a/internal/githuborg/github.go +++ b/internal/githuborg/github.go @@ -9,7 +9,6 @@ import ( "net/http" "strings" - "github.com/bradleyfalzon/ghinstallation/v2" "github.com/google/go-github/v88/github" "github.com/reMarkable/githubUserManager/internal/config" @@ -36,7 +35,7 @@ type Service struct { // New builds a Service authenticated as the configured GitHub App installation. func New(cfg *config.Config) (*Service, error) { - transport, err := ghinstallation.New( + transport, err := newAppTransport( http.DefaultTransport, cfg.GitHubAppID, cfg.GitHubInstallationID, From 74b66bc092f3cbdc168be72bef445d8b2db1a08b Mon Sep 17 00:00:00 2001 From: Marcus Ramberg Date: Fri, 23 Oct 2026 12:16:19 +0200 Subject: [PATCH 3/3] refactor: make usermanager a container action so we can call it directly and have immutable version tags --- .env.example | 27 +++++----- .github/workflows/ci.yml | 31 +++++------ .github/workflows/release.yml | 4 +- README.md | 69 ++++++++++++++---------- action.yml | 42 +++------------ go.mod | 1 + go.sum | 2 + internal/config/config.go | 68 ++++++++++++----------- internal/config/config_test.go | 98 ++++++++++++++-------------------- main.go | 6 ++- 10 files changed, 165 insertions(+), 183 deletions(-) diff --git a/.env.example b/.env.example index 7dc0406ad..532a222eb 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,15 @@ -GOOGLE_EMAIL_ADDRESS=hello@example.com -GOOGLE_CREDENTIALS=ewogICJ0eXBlIjogInNlcnZpY2VfYWNjb3VudCIsCiAgInByb2plY3RfaWQiOiAibXlwcm9qZWN0IiwKICAicHJpdmF0ZV9rZXlfaWQiOiAicHJpdmF0ZWtleWlkZ29lc2hlcmUiLAogICJwcml2YXRlX2tleSI6ICItLS0tLUJFR0lOIFBSSVZBVEUgS0VZLS0tLS1cbnByaXZhdGVrZXlnb2VzaGVyZVxuLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLVxuIiwKICAiY2xpZW50X2VtYWlsIjogImdpdGh1YnVzZXJtYW5hZ2VyQGdpdGh1YnVzZXJtYW5hZ2VyLmlhbS5nc2VydmljZWFjY291bnQuY29tIiwKICAiY2xpZW50X2lkIjogImNsaWVudGlkIiwKICAiYXV0aF91cmkiOiAiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tL28vb2F1dGgyL2F1dGgiLAogICJ0b2tlbl91cmkiOiAiaHR0cHM6Ly9vYXV0aDIuZ29vZ2xlYXBpcy5jb20vdG9rZW4iLAogICJhdXRoX3Byb3ZpZGVyX3g1MDlfY2VydF91cmwiOiAiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vb2F1dGgyL3YxL2NlcnRzIiwKICAiY2xpZW50X3g1MDlfY2VydF91cmwiOiAiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vcm9ib3QvdjEvbWV0YWRhdGEveDUwOS9naXRodWJ1c2VybWFuYWdlciU0MGdpdGh1YnVzZXJtYW5hZ2VyLmlhbS5nc2VydmljZWFjY291bnQuY29tIgp9Cg== -ADD_USERS=TRUE -REMOVE_USERS=TRUE -EXIT_CODE_ON_MISMATCH=0 -GITHUB_ORG=myorg -GITHUB_INSTALLATION_ID=123 -GITHUB_APP_ID=123 -GITHUB_PRIVATE_KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tXG5wcml2YXRla2V5Z29lc2hlcmVcbi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS1cbg== -IGNORED_USERS=user1,user2 -SYNC_TEAM=TRUE -GITHUB_TEAM_SLUG=remarkable +# Inputs are read as GitHub Action inputs, exposed to the container as INPUT_* +# environment variables (upper-cased input name). Set these when running the +# container directly, e.g. `docker run --env-file .env githubusermanager`. +INPUT_GOOGLE-EMAIL-ADDRESS=hello@example.com +INPUT_GOOGLE-CREDENTIALS=ewogICJ0eXBlIjogInNlcnZpY2VfYWNjb3VudCIsCiAgInByb2plY3RfaWQiOiAibXlwcm9qZWN0IiwKICAicHJpdmF0ZV9rZXlfaWQiOiAicHJpdmF0ZWtleWlkZ29lc2hlcmUiLAogICJwcml2YXRlX2tleSI6ICItLS0tLUJFR0lOIFBSSVZBVEUgS0VZLS0tLS1cbnByaXZhdGVrZXlnb2VzaGVyZVxuLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLVxuIiwKICAiY2xpZW50X2VtYWlsIjogImdpdGh1YnVzZXJtYW5hZ2VyQGdpdGh1YnVzZXJtYW5hZ2VyLmlhbS5nc2VydmljZWFjY291bnQuY29tIiwKICAiY2xpZW50X2lkIjogImNsaWVudGlkIiwKICAiYXV0aF91cmkiOiAiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tL28vb2F1dGgyL2F1dGgiLAogICJ0b2tlbl91cmkiOiAiaHR0cHM6Ly9vYXV0aDIuZ29vZ2xlYXBpcy5jb20vdG9rZW4iLAogICJhdXRoX3Byb3ZpZGVyX3g1MDlfY2VydF91cmwiOiAiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vb2F1dGgyL3YxL2NlcnRzIiwKICAiY2xpZW50X3g1MDlfY2VydF91cmwiOiAiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vcm9ib3QvdjEvbWV0YWRhdGEveDUwOS9naXRodWJ1c2VybWFuYWdlciU0MGdpdGh1YnVzZXJtYW5hZ2VyLmlhbS5nc2VydmljZWFjY291bnQuY29tIgp9Cg== +INPUT_ADD-USERS=TRUE +INPUT_REMOVE-USERS=TRUE +INPUT_EXIT-CODE-ON-MISMATCH=0 +INPUT_GITHUB-ORG=myorg +INPUT_GITHUB-INSTALLATION-ID=123 +INPUT_GITHUB-APP-ID=123 +INPUT_GITHUB-PRIVATE-KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tXG5wcml2YXRla2V5Z29lc2hlcmVcbi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS1cbg== +INPUT_IGNORED-USERS=user1,user2 +INPUT_SYNC-TEAM=TRUE +INPUT_TEAM-SLUG=remarkable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 272b69a6b..2dd71438e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,4 @@ name: ci - on: push: branches: @@ -8,37 +7,35 @@ on: release: types: - created - jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write - + id-token: write + attestations: write steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: go.mod cache: true - - run: | go vet ./... go test -race -coverprofile=coverage.out ./... go build ./... - - id: docker_meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/remarkable/githubusermanager - # Tag the image to match the refs people pass to `uses:` so the - # composite action can pull `...:${{ github.action_ref }}` without - # any hardcoded version to bump before a release: - # - push to main -> `main` - # - release vX.Y.Z -> `vX.Y.Z`, `vX.Y`, `vX` (moving tags) - # - any build -> `sha-` (for debugging/pinning) + # Tag by event so each image is traceable to what produced it: + # - pull request -> `pr-` (so a PR build can be tested) + # - push to main -> `main` + # - release vX.Y.Z -> `vX.Y.Z`, `vX.Y`, `vX` (moving tags) + # - any build -> `sha-` (for debugging/pinning) tags: | + type=ref,event=pr type=ref,event=branch type=semver,pattern=v{{version}} type=semver,pattern=v{{major}}.{{minor}} @@ -49,18 +46,22 @@ jobs: org.opencontainers.image.documentation=https://github.com/reMarkable/githubUserManager org.opencontainers.image.authors=Marcus Ramberg maintainer=reMarkable - - name: Login to GitHub Container Registry - if: ${{ github.event_name != 'pull_request' }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - + # Push on every internal build, including same-repo PRs (tagged + # `pr-`), so a PR image can be pulled and tested before merge. + # Fork PRs only build (no push) because they get a read-only token. The + # action itself still runs as a Docker container action built from the + # Dockerfile at the referenced commit, so consumers can keep pinning an + # immutable SHA. - name: Build and push to GitHub Container Registry uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: - push: ${{ github.event_name != 'pull_request' }} + push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} tags: ${{ steps.docker_meta.outputs.tags }} labels: ${{ steps.docker_meta.outputs.labels }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9587d90d..0587966f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,9 @@ name: release # When a release is published for a semver tag (vX.Y.Z), move the matching # major (vX) and major.minor (vX.Y) git tags to the released commit. This keeps # `uses: reMarkable/githubUserManager@v1` (and @v1.2) pointing at the latest -# compatible release, matching the moving image tags published by ci.yml. +# compatible release. The action is a Docker container action built from the +# Dockerfile at the referenced commit, so these git tags are the only thing +# consumers pin against. on: release: types: diff --git a/README.md b/README.md index a20f55790..e1dce3d12 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ jobs: google-credentials: ${{ secrets.GOOGLE_CREDENTIALS }} add-users: 'false' remove-users: 'false' - exit-code-on-missmatch: '1' + exit-code-on-mismatch: '1' github-org: 'myorg' github-app-id: 1234 github-installation-id: 12345 @@ -100,8 +100,6 @@ jobs: ignored-users: user1,user2 sync-team: 'true' team-slug: 'remarkable' - github-token: ${{ secrets.GITHUB_TOKEN }} - github-actor: ${{ github.actor }} ``` Add a dependabot configuration to always get updates! @@ -118,37 +116,50 @@ updates: #### Docker -1. Make an [env file](https://www.digitalocean.com/community/tutorials/how-to-read-and-set-environmental-and-shell-variables-on-linux) with the [below table](#Setup-environment-variables) -1. `docker run --env-file .env ghcr.io/remarkable/githubusermanager:main` - -Images are published to `ghcr.io/remarkable/githubusermanager` and tagged to -match the refs you can reference the action with: `main`, released versions -(`v1.2.3`), the moving `v1.2`/`v1` tags, and `sha-` for a specific -build. The GitHub Action always pulls the image tag matching the ref it was -called with (`${{ github.action_ref }}`), so there is no image version to bump -by hand before a release. +This action runs as a [Docker container action](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action): +GitHub builds the image from the `Dockerfile` at the exact ref you pin the +action to, so you can reference an immutable commit SHA and there is no +separately published image tag to bump before a release. + +Inputs are read directly from the GitHub Action inputs (exposed by the runner +as `INPUT_*` environment variables). To run the container by hand, set the same +inputs with the `INPUT_` prefix and the input name upper-cased, e.g. +`INPUT_GITHUB-ORG`, `INPUT_ADD-USERS`: + +```sh +docker build -t githubusermanager . +docker run --rm \ + -e INPUT_GOOGLE-EMAIL-ADDRESS=foo@example.com \ + -e INPUT_GITHUB-ORG=myorg \ + # ...the remaining inputs from the table below... \ + githubusermanager +``` #### Go / lambda / cloud run / something else 1. Clone this repo 1. `go build -o githubusermanager .` -1. `./githubusermanager` (with the [below environment variables table](#Setup-environment-variables) set) +1. `./githubusermanager` (with the `INPUT_*` variables from the [table below](#action-inputs) set) + +### Action inputs -### Setup environment variables +Inputs use the action input names below; when running the container directly set +them as `INPUT_` environment variables (upper-cased input name). -| Environment Variable | Description | Example | Default | +| Input | Description | Example | Default | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ------- | -| `GOOGLE_EMAIL_ADDRESS` | Email address to assume to, needs to be an workspace admin | `foo@example.com` | `null` | -| `GOOGLE_CREDENTIALS` | Base64'd json as downloaded from the google service account creation step | `Zm9vCg==` | `null` | -| `ADD_USERS` | Set to TRUE to add users to the github organisation | `TRUE` | `false` | -| `REMOVE_USERS` | Set to TRUE to remove users from the github organisation | `TRUE` | `false` | -| `REMOVE_SUSPENDED_USERS` | Set to TRUE to remove users from the github organisation that are suspended in Google | `TRUE` | `false` | -| `EXIT_CODE_ON_MISMATCH` | Exit code to use when there's a mismatch, useful when combined with `ADD_USERS` and `REMOVE_USERS` to be used in a dry-run mode | `1` | `0` | -| `GITHUB_ORG` | GitHub Organization | `chrisnstest` | `null` | -| `GITHUB_APP_ID` | GitHub App ID | `106341` | `null` | -| `GITHUB_INSTALLATION_ID` | Github App Installation ID | `15627551` | `null` | -| `GITHUB_PRIVATE_KEY` | Base64'd private key as downloaded from github application registration step | `Zm9vCg==` | `null` | -| `IGNORED_USERS` | Comma separated list of user ids to totally ignore always, useful for owners of an org you don't want accidentally removed | `owner1,owner2` | `null` | -| `SYNC_TEAM` | Set to TRUE to ensure every org member is added to the team named in `GITHUB_TEAM_SLUG` | `TRUE` | `false` | -| `GITHUB_TEAM_SLUG` | Slug of the team every org member should be added to, e.g. to grant PR rights to shared repos | `remarkable` | `null` | -| `DEBUG` | Set to TRUE to enable debug level logging | `TRUE` | `false` | +| `google-email-address` | Email address to assume to, needs to be an workspace admin | `foo@example.com` | `null` | +| `google-credentials` | Base64'd json as downloaded from the google service account creation step | `Zm9vCg==` | `null` | +| `add-users` | Set to TRUE to add users to the github organisation | `TRUE` | `false` | +| `remove-users` | Set to TRUE to remove users from the github organisation | `TRUE` | `false` | +| `remove-suspended_users` | Set to TRUE to remove users from the github organisation that are suspended in Google | `TRUE` | `false` | +| `exit-code-on-mismatch` | Exit code to use when there's a mismatch, useful when combined with `add-users` and `remove-users` to be used in a dry-run mode | `1` | `0` | +| `max-remove-users` | Safety limit on how many users may be removed in a single run | `100` | `100` | +| `github-org` | GitHub Organization | `chrisnstest` | `null` | +| `github-app-id` | GitHub App ID | `106341` | `null` | +| `github-installation-id` | Github App Installation ID | `15627551` | `null` | +| `github-private-key` | Base64'd private key as downloaded from github application registration step | `Zm9vCg==` | `null` | +| `ignored-users` | Comma separated list of user ids to totally ignore always, useful for owners of an org you don't want accidentally removed | `owner1,owner2` | `null` | +| `sync-team` | Set to TRUE to ensure every org member is added to the team named in `team-slug` | `TRUE` | `false` | +| `team-slug` | Slug of the team every org member should be added to, e.g. to grant PR rights to shared repos | `remarkable` | `null` | +| `debug` | Set to TRUE to enable debug level logging | `TRUE` | `false` | diff --git a/action.yml b/action.yml index e22475f49..5b0adf9d2 100644 --- a/action.yml +++ b/action.yml @@ -22,6 +22,10 @@ inputs: exit-code-on-mismatch: description: 'Exit code to use when there is a mismatch, useful when combined with `ADD_USERS` and `REMOVE_USERS` to be used in a dry-run mode' required: false + max-remove-users: + description: 'Safety limit on how many users may be removed in a single run' + required: false + default: '100' github-org: description: 'GitHub Organization ' required: true @@ -47,39 +51,5 @@ inputs: description: 'Set to TRUE to enable debug level logging' required: false runs: - using: 'composite' - steps: - - name: Run - run: | - docker run --rm \ - -e GOOGLE_EMAIL_ADDRESS="$GOOGLE_EMAIL_ADDRESS" \ - -e GOOGLE_CREDENTIALS="$GOOGLE_CREDENTIALS" \ - -e ADD_USERS="$ADD_USERS" \ - -e REMOVE_USERS="$REMOVE_USERS" \ - -e REMOVE_SUSPENDED_USERS="$REMOVE_SUSPENDED_USERS" \ - -e EXIT_CODE_ON_MISMATCH="$EXIT_CODE_ON_MISMATCH" \ - -e GITHUB_ORG="$GITHUB_ORG" \ - -e GITHUB_APP_ID="$GITHUB_APP_ID" \ - -e GITHUB_INSTALLATION_ID="$GITHUB_INSTALLATION_ID" \ - -e GITHUB_PRIVATE_KEY="$GITHUB_PRIVATE_KEY" \ - -e IGNORED_USERS="$IGNORED_USERS" \ - -e SYNC_TEAM="$SYNC_TEAM" \ - -e GITHUB_TEAM_SLUG="$GITHUB_TEAM_SLUG" \ - -e DEBUG="$DEBUG" \ - ghcr.io/remarkable/githubusermanager:${{ github.action_ref }} - shell: bash - env: - GOOGLE_EMAIL_ADDRESS: ${{ inputs.google-email-address }} - GOOGLE_CREDENTIALS: ${{ inputs.google-credentials }} - ADD_USERS: ${{ inputs.add-users }} - REMOVE_USERS: ${{ inputs.remove-users }} - REMOVE_SUSPENDED_USERS: ${{ inputs.remove-suspended_users }} - EXIT_CODE_ON_MISMATCH: ${{ inputs.exit-code-on-mismatch }} - GITHUB_ORG: ${{ inputs.github-org }} - GITHUB_APP_ID: ${{ inputs.github-app-id }} - GITHUB_INSTALLATION_ID: ${{ inputs.github-installation-id }} - GITHUB_PRIVATE_KEY: ${{ inputs.github-private-key }} - IGNORED_USERS: ${{ inputs.ignored-users }} - SYNC_TEAM: ${{ inputs.sync-team }} - GITHUB_TEAM_SLUG: ${{ inputs.team-slug }} - DEBUG: ${{ inputs.debug }} + using: 'docker' + image: 'Dockerfile' diff --git a/go.mod b/go.mod index 4e6a53e61..e367c6728 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.27.3 require ( github.com/google/go-github/v88 v88.0.0 + github.com/sethvargo/go-githubactions v1.4.0 golang.org/x/oauth2 v0.37.0 google.golang.org/api v0.307.0 ) diff --git a/go.sum b/go.sum index 2e70ff8ec..a98f81295 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/googleapis/gax-go/v2 v2.26.2 h1:ydkmNXxj7bEmmeK5AihkKnWxyOyBR9TDebvp5 github.com/googleapis/gax-go/v2 v2.26.2/go.mod h1:sMKqnMesnKH+3wiRJROcttA+cJoZoGbZl1vDQ8XYtGk= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sethvargo/go-githubactions v1.4.0 h1:dwscFydQh+NmavGQjUvMe/vCl9HgLqwHsxxPIoWmy5o= +github.com/sethvargo/go-githubactions v1.4.0/go.mod h1:108h1bJHNUwPbvZBZbn+ieToC/JMClYZ7tBy3vNORxk= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/internal/config/config.go b/internal/config/config.go index 1eec427c8..67f11448e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,17 +1,21 @@ -// Package config loads the manager configuration from environment variables. -// The variable names and semantics match the original Node implementation so -// the container contract is unchanged. +// Package config loads the manager configuration from GitHub Action inputs +// (exposed as INPUT_* environment variables) via go-githubactions. package config import ( "encoding/base64" "encoding/json" "fmt" - "os" "strconv" "strings" ) +// InputSource resolves a GitHub Action input by its kebab-case name. It is +// satisfied by *github.com/sethvargo/go-githubactions.Action. +type InputSource interface { + GetInput(name string) string +} + // GoogleCredentials mirrors the service-account JSON downloaded from GCP. type GoogleCredentials struct { Type string `json:"type"` @@ -50,60 +54,60 @@ type Config struct { GoogleCredentialsJSON []byte } -// Load reads and validates the configuration from the environment. -func Load() (*Config, error) { +// Load reads and validates the configuration from the action inputs. +func Load(src InputSource) (*Config, error) { c := &Config{ - AddUsers: boolEnv("ADD_USERS"), - RemoveUsers: boolEnv("REMOVE_USERS"), - RemoveSuspendedUsers: boolEnv("REMOVE_SUSPENDED_USERS"), - ExitCodeOnMismatch: intEnvOr("EXIT_CODE_ON_MISMATCH", 0), - MaxRemoveUsers: intEnvOr("MAX_REMOVE_USERS", 100), - Debug: boolEnv("DEBUG"), - GitHubOrg: os.Getenv("GITHUB_ORG"), - GitHubTeamSlug: os.Getenv("GITHUB_TEAM_SLUG"), - SyncTeam: boolEnv("SYNC_TEAM"), - IgnoredUsers: ignoredUsers(os.Getenv("IGNORED_USERS")), - GoogleEmailAddress: os.Getenv("GOOGLE_EMAIL_ADDRESS"), + AddUsers: boolVal(src.GetInput("add-users")), + RemoveUsers: boolVal(src.GetInput("remove-users")), + RemoveSuspendedUsers: boolVal(src.GetInput("remove-suspended_users")), + ExitCodeOnMismatch: intOr(src.GetInput("exit-code-on-mismatch"), 0), + MaxRemoveUsers: intOr(src.GetInput("max-remove-users"), 100), + Debug: boolVal(src.GetInput("debug")), + GitHubOrg: src.GetInput("github-org"), + GitHubTeamSlug: src.GetInput("team-slug"), + SyncTeam: boolVal(src.GetInput("sync-team")), + IgnoredUsers: ignoredUsers(src.GetInput("ignored-users")), + GoogleEmailAddress: src.GetInput("google-email-address"), } - appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64) + appID, err := strconv.ParseInt(src.GetInput("github-app-id"), 10, 64) if err != nil { - return nil, fmt.Errorf("GITHUB_APP_ID must be a number: %w", err) + return nil, fmt.Errorf("github-app-id must be a number: %w", err) } c.GitHubAppID = appID - installationID, err := strconv.ParseInt(os.Getenv("GITHUB_INSTALLATION_ID"), 10, 64) + installationID, err := strconv.ParseInt(src.GetInput("github-installation-id"), 10, 64) if err != nil { - return nil, fmt.Errorf("GITHUB_INSTALLATION_ID must be a number: %w", err) + return nil, fmt.Errorf("github-installation-id must be a number: %w", err) } c.GitHubInstallationID = installationID - key, err := decodeBase64("GITHUB_PRIVATE_KEY") + key, err := decodeBase64("github-private-key", src.GetInput("github-private-key")) if err != nil { return nil, err } c.GitHubPrivateKey = string(key) - creds, err := decodeBase64("GOOGLE_CREDENTIALS") + creds, err := decodeBase64("google-credentials", src.GetInput("google-credentials")) if err != nil { return nil, err } c.GoogleCredentialsJSON = creds if err := json.Unmarshal(creds, &c.GoogleCredentials); err != nil { - return nil, fmt.Errorf("GOOGLE_CREDENTIALS is not valid JSON: %w", err) + return nil, fmt.Errorf("google-credentials is not valid JSON: %w", err) } return c, nil } -func boolEnv(name string) bool { - return strings.EqualFold(os.Getenv(name), "true") +func boolVal(s string) bool { + return strings.EqualFold(strings.TrimSpace(s), "true") } -// intEnvOr matches the Node `parseInt(...) || fallback` behaviour: an unset, +// intOr matches the Node `parseInt(...) || fallback` behaviour: an unset, // invalid, or zero value falls back to the default. -func intEnvOr(name string, fallback int) int { - v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(name))) +func intOr(s string, fallback int) int { + v, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil || v == 0 { return fallback } @@ -111,6 +115,7 @@ func intEnvOr(name string, fallback int) int { } func ignoredUsers(raw string) []string { + raw = strings.TrimSpace(raw) if raw == "" { return nil } @@ -121,9 +126,8 @@ func ignoredUsers(raw string) []string { return parts } -func decodeBase64(name string) ([]byte, error) { - raw := strings.TrimSpace(os.Getenv(name)) - decoded, err := base64.StdEncoding.DecodeString(raw) +func decodeBase64(name, raw string) ([]byte, error) { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw)) if err != nil { return nil, fmt.Errorf("%s must be base64 encoded: %w", name, err) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ad6b3170e..767c518c0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,46 +2,38 @@ package config import ( "encoding/base64" - "os" "reflect" "testing" ) -func clearEnv(t *testing.T) { - t.Helper() - for _, k := range []string{ - "ADD_USERS", "REMOVE_USERS", "REMOVE_SUSPENDED_USERS", "EXIT_CODE_ON_MISMATCH", - "MAX_REMOVE_USERS", "GITHUB_ORG", "GITHUB_APP_ID", "GITHUB_INSTALLATION_ID", - "GITHUB_PRIVATE_KEY", "GITHUB_TEAM_SLUG", "SYNC_TEAM", "IGNORED_USERS", - "GOOGLE_EMAIL_ADDRESS", "GOOGLE_CREDENTIALS", - } { - _ = os.Unsetenv(k) - } -} +// fakeInputs implements InputSource backed by a map of input name to value. +type fakeInputs map[string]string -// validEnv sets the minimum required variables for Load to succeed. -func validEnv(t *testing.T) { - t.Helper() - _ = os.Setenv("GITHUB_APP_ID", "123") - _ = os.Setenv("GITHUB_INSTALLATION_ID", "456") - _ = os.Setenv("GITHUB_PRIVATE_KEY", base64.StdEncoding.EncodeToString([]byte("helloworld"))) - _ = os.Setenv("GOOGLE_CREDENTIALS", base64.StdEncoding.EncodeToString([]byte(`{"client_email":"foo","private_key":"bar"}`))) +func (f fakeInputs) GetInput(name string) string { return f[name] } + +// validInputs returns the minimum required inputs for Load to succeed. +func validInputs() fakeInputs { + return fakeInputs{ + "github-app-id": "123", + "github-installation-id": "456", + "github-private-key": base64.StdEncoding.EncodeToString([]byte("helloworld")), + "google-credentials": base64.StdEncoding.EncodeToString([]byte(`{"client_email":"foo","private_key":"bar"}`)), + } } -func TestBoolEnv(t *testing.T) { +func TestBoolVal(t *testing.T) { cases := map[string]bool{"": false, "foobar": false, "false": false, "true": true, "tRuE": true} for in, want := range cases { - clearEnv(t) - validEnv(t) + inputs := validInputs() if in != "" { - _ = os.Setenv("ADD_USERS", in) + inputs["add-users"] = in } - cfg, err := Load() + cfg, err := Load(inputs) if err != nil { t.Fatalf("Load: %v", err) } if cfg.AddUsers != want { - t.Errorf("ADD_USERS=%q => %v, want %v", in, cfg.AddUsers, want) + t.Errorf("add-users=%q => %v, want %v", in, cfg.AddUsers, want) } } } @@ -49,27 +41,25 @@ func TestBoolEnv(t *testing.T) { func TestExitCodeOnMismatch(t *testing.T) { cases := map[string]int{"": 0, "hello": 0, "123": 123} for in, want := range cases { - clearEnv(t) - validEnv(t) + inputs := validInputs() if in != "" { - _ = os.Setenv("EXIT_CODE_ON_MISMATCH", in) + inputs["exit-code-on-mismatch"] = in } - cfg, _ := Load() + cfg, _ := Load(inputs) if cfg.ExitCodeOnMismatch != want { - t.Errorf("EXIT_CODE_ON_MISMATCH=%q => %d, want %d", in, cfg.ExitCodeOnMismatch, want) + t.Errorf("exit-code-on-mismatch=%q => %d, want %d", in, cfg.ExitCodeOnMismatch, want) } } } func TestMaxRemoveUsersDefault(t *testing.T) { - clearEnv(t) - validEnv(t) - cfg, _ := Load() + inputs := validInputs() + cfg, _ := Load(inputs) if cfg.MaxRemoveUsers != 100 { t.Errorf("default MaxRemoveUsers = %d, want 100", cfg.MaxRemoveUsers) } - _ = os.Setenv("MAX_REMOVE_USERS", "5") - cfg, _ = Load() + inputs["max-remove-users"] = "5" + cfg, _ = Load(inputs) if cfg.MaxRemoveUsers != 5 { t.Errorf("MaxRemoveUsers = %d, want 5", cfg.MaxRemoveUsers) } @@ -83,22 +73,19 @@ func TestIgnoredUsers(t *testing.T) { "user1, user2 ,USER3": {"user1", "user2", "user3"}, } for in, want := range cases { - clearEnv(t) - validEnv(t) + inputs := validInputs() if in != "" { - _ = os.Setenv("IGNORED_USERS", in) + inputs["ignored-users"] = in } - cfg, _ := Load() + cfg, _ := Load(inputs) if !reflect.DeepEqual(cfg.IgnoredUsers, want) { - t.Errorf("IGNORED_USERS=%q => %v, want %v", in, cfg.IgnoredUsers, want) + t.Errorf("ignored-users=%q => %v, want %v", in, cfg.IgnoredUsers, want) } } } func TestPrivateKeyBase64Decode(t *testing.T) { - clearEnv(t) - validEnv(t) - cfg, err := Load() + cfg, err := Load(validInputs()) if err != nil { t.Fatal(err) } @@ -108,10 +95,9 @@ func TestPrivateKeyBase64Decode(t *testing.T) { } func TestGoogleCredentialsDecode(t *testing.T) { - clearEnv(t) - validEnv(t) - _ = os.Setenv("GOOGLE_CREDENTIALS", base64.StdEncoding.EncodeToString([]byte(`{"client_email":"hello","private_key":"world"}`))) - cfg, err := Load() + inputs := validInputs() + inputs["google-credentials"] = base64.StdEncoding.EncodeToString([]byte(`{"client_email":"hello","private_key":"world"}`)) + cfg, err := Load(inputs) if err != nil { t.Fatal(err) } @@ -121,19 +107,17 @@ func TestGoogleCredentialsDecode(t *testing.T) { } func TestLoadRejectsBadAppID(t *testing.T) { - clearEnv(t) - validEnv(t) - _ = os.Setenv("GITHUB_APP_ID", "notanumber") - if _, err := Load(); err == nil { - t.Error("expected error for non-numeric GITHUB_APP_ID") + inputs := validInputs() + inputs["github-app-id"] = "notanumber" + if _, err := Load(inputs); err == nil { + t.Error("expected error for non-numeric github-app-id") } } func TestLoadRejectsBadBase64(t *testing.T) { - clearEnv(t) - validEnv(t) - _ = os.Setenv("GITHUB_PRIVATE_KEY", "!!!notbase64!!!") - if _, err := Load(); err == nil { - t.Error("expected error for invalid base64 GITHUB_PRIVATE_KEY") + inputs := validInputs() + inputs["github-private-key"] = "!!!notbase64!!!" + if _, err := Load(inputs); err == nil { + t.Error("expected error for invalid base64 github-private-key") } } diff --git a/main.go b/main.go index 7b9af3f46..f08e97a60 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,8 @@ import ( "log/slog" "os" + gh "github.com/sethvargo/go-githubactions" + "github.com/reMarkable/githubUserManager/internal/app" "github.com/reMarkable/githubUserManager/internal/config" "github.com/reMarkable/githubUserManager/internal/githuborg" @@ -17,7 +19,9 @@ import ( func main() { ctx := context.Background() - cfg, err := config.Load() + action := gh.New() + + cfg, err := config.Load(action) if err != nil { fmt.Fprintln(os.Stderr, "configuration error:", err) os.Exit(1)