From e0d6f81f3edf87a259ff8951de010bbe00e34364 Mon Sep 17 00:00:00 2001 From: Matt Norris Date: Sun, 1 Nov 2026 21:52:34 -0500 Subject: [PATCH 01/20] feat(skills): add ess- skills and instructions - Add ess-django-docs, ess-env-credentials, ess-gh-repo-mirror, ess-git-commits, ess-postgres-docs, and ess-shell-flags-literal. - Replace summarize-change-log with ess-summarize-change-log. - Replace pr-address-comments and pr-address-comments-all with ess-pr-address-comments, which handles one PR or many. - Add portable rules under instructions/ess/. --- ...ss-dockerfile-runtime-copy.instructions.md | 29 ++ .../ess/ess-npmrc-env.instructions.md | 24 ++ instructions/ess/ess-okf-docs.instructions.md | 19 ++ .../ess/ess-shell-file-naming.instructions.md | 52 ++++ skills/README.md | 12 +- .../LICENSE | 0 skills/ess/ess-django-docs/SKILL.md | 16 ++ .../LICENSE | 0 skills/ess/ess-env-credentials/SKILL.md | 39 +++ .../LICENSE | 0 skills/ess/ess-gh-repo-mirror/SKILL.md | 170 +++++++++++ skills/ess/ess-git-commits/LICENSE | 202 +++++++++++++ skills/ess/ess-git-commits/SKILL.md | 69 +++++ .../ess-git-commits/references/pre-commit.md | 42 +++ .../scripts/strip-cursor-coauthor.sh | 33 +++ skills/ess/ess-postgres-docs/LICENSE | 202 +++++++++++++ skills/ess/ess-postgres-docs/SKILL.md | 18 ++ skills/ess/ess-pr-address-comments/LICENSE | 202 +++++++++++++ .../SKILL.md | 103 ++++--- .../references/fix-procedure.md | 93 ++++++ .../references/subagent-prompts.md | 45 +-- .../references/worktree-mechanics.md | 16 +- .../scripts/create-pr-worktree.sh} | 27 +- .../scripts/fetch-pr.sh} | 128 +++++++-- .../scripts/find-my-prs.sh} | 13 +- .../scripts/reply-and-resolve.sh} | 6 +- .../scripts/safe-push.sh} | 4 +- skills/ess/ess-shell-flags-literal/LICENSE | 202 +++++++++++++ skills/ess/ess-shell-flags-literal/SKILL.md | 46 +++ skills/ess/ess-summarize-change-log/LICENSE | 202 +++++++++++++ .../SKILL.md | 24 +- .../examples-good.md | 0 .../examples.md | 0 .../scripts/test_validate_summary.py | 31 +- .../scripts/validate-summary.sh | 13 + .../scripts/validate_summary.py | 68 ++--- skills/ess/pr-address-comments/SKILL.md | 269 ------------------ .../scripts/validate-summary.sh | 11 - 38 files changed, 1989 insertions(+), 441 deletions(-) create mode 100644 instructions/ess/ess-dockerfile-runtime-copy.instructions.md create mode 100644 instructions/ess/ess-npmrc-env.instructions.md create mode 100644 instructions/ess/ess-okf-docs.instructions.md create mode 100644 instructions/ess/ess-shell-file-naming.instructions.md rename skills/ess/{pr-address-comments-all => ess-django-docs}/LICENSE (100%) create mode 100644 skills/ess/ess-django-docs/SKILL.md rename skills/ess/{pr-address-comments => ess-env-credentials}/LICENSE (100%) create mode 100644 skills/ess/ess-env-credentials/SKILL.md rename skills/ess/{summarize-change-log => ess-gh-repo-mirror}/LICENSE (100%) create mode 100644 skills/ess/ess-gh-repo-mirror/SKILL.md create mode 100644 skills/ess/ess-git-commits/LICENSE create mode 100644 skills/ess/ess-git-commits/SKILL.md create mode 100644 skills/ess/ess-git-commits/references/pre-commit.md create mode 100755 skills/ess/ess-git-commits/scripts/strip-cursor-coauthor.sh create mode 100644 skills/ess/ess-postgres-docs/LICENSE create mode 100644 skills/ess/ess-postgres-docs/SKILL.md create mode 100644 skills/ess/ess-pr-address-comments/LICENSE rename skills/ess/{pr-address-comments-all => ess-pr-address-comments}/SKILL.md (66%) create mode 100644 skills/ess/ess-pr-address-comments/references/fix-procedure.md rename skills/ess/{pr-address-comments-all => ess-pr-address-comments}/references/subagent-prompts.md (61%) rename skills/ess/{pr-address-comments-all => ess-pr-address-comments}/references/worktree-mechanics.md (81%) rename skills/ess/{pr-address-comments-all/scripts/create_pr_worktree.sh => ess-pr-address-comments/scripts/create-pr-worktree.sh} (83%) rename skills/ess/{pr-address-comments-all/scripts/fetch_pr.sh => ess-pr-address-comments/scripts/fetch-pr.sh} (58%) rename skills/ess/{pr-address-comments-all/scripts/find_my_prs.sh => ess-pr-address-comments/scripts/find-my-prs.sh} (90%) rename skills/ess/{pr-address-comments-all/scripts/reply_and_resolve.sh => ess-pr-address-comments/scripts/reply-and-resolve.sh} (93%) rename skills/ess/{pr-address-comments-all/scripts/safe_push.sh => ess-pr-address-comments/scripts/safe-push.sh} (98%) create mode 100644 skills/ess/ess-shell-flags-literal/LICENSE create mode 100644 skills/ess/ess-shell-flags-literal/SKILL.md create mode 100644 skills/ess/ess-summarize-change-log/LICENSE rename skills/ess/{summarize-change-log => ess-summarize-change-log}/SKILL.md (74%) rename skills/ess/{summarize-change-log => ess-summarize-change-log}/examples-good.md (100%) rename skills/ess/{summarize-change-log => ess-summarize-change-log}/examples.md (100%) rename skills/ess/{summarize-change-log => ess-summarize-change-log}/scripts/test_validate_summary.py (74%) create mode 100755 skills/ess/ess-summarize-change-log/scripts/validate-summary.sh rename skills/ess/{summarize-change-log => ess-summarize-change-log}/scripts/validate_summary.py (75%) delete mode 100644 skills/ess/pr-address-comments/SKILL.md delete mode 100755 skills/ess/summarize-change-log/scripts/validate-summary.sh diff --git a/instructions/ess/ess-dockerfile-runtime-copy.instructions.md b/instructions/ess/ess-dockerfile-runtime-copy.instructions.md new file mode 100644 index 0000000..976cda9 --- /dev/null +++ b/instructions/ess/ess-dockerfile-runtime-copy.instructions.md @@ -0,0 +1,29 @@ +--- +description: Copy runtime image files as root-owned and not writable, so the process cannot modify its own code (SonarQube docker:S6504) +applyTo: "**/Dockerfile,**/Dockerfile.*,**/*.dockerfile" +--- + +# Runtime image copies stay root-owned + +A container should not be able to rewrite its own application code. If the runtime user owns the files it runs, a compromised process can change them, which breaks least privilege and container immutability. SonarQube reports this as **docker:S6504**, on any `COPY` or `ADD` whose `--chown` user is not `root`. + +In the final stage, copy application files as root and drop the write bit. The runtime user can read and traverse them and cannot modify them. + +```dockerfile +# Bad — node owns the app and can rewrite it +COPY --from=build --chown=node:node /app/dist ./dist +USER node + +# Good +COPY --from=build --chown=root:root --chmod=555 /app/dist ./dist +USER node +``` + +`--chmod=555` is read and execute for everyone, so directories stay traversable and files stay readable. The same flags apply whether the runtime user is `node`, `nextjs`, or any other non-root account. + +## Rules + +- On every runtime-stage `COPY` or `ADD` of application files, set `--chown=root:root --chmod=555`. +- Do not `--chown` to a non-root user or a uid other than `0`. +- A path the process must write (cache, uploads, temp) is a separate empty directory. Do not loosen the application copy to make that path writable. +- Build-stage copies that the builder then writes, such as `package.json` before `npm ci`, stay default root and omit `--chmod=555`. Still do not `--chown` them to a non-root user. diff --git a/instructions/ess/ess-npmrc-env.instructions.md b/instructions/ess/ess-npmrc-env.instructions.md new file mode 100644 index 0000000..b177c6e --- /dev/null +++ b/instructions/ess/ess-npmrc-env.instructions.md @@ -0,0 +1,24 @@ +--- +description: In .npmrc, reference environment variables as ${VAR}. A trailing ? is not portable. +applyTo: "**/.npmrc,**/.npmrc.*,**/npmrc" +--- + +# npmrc environment variables + +npm replaces `${VARIABLE_NAME}` in `.npmrc` values. The text inside the braces is the environment variable name, exactly. + +```ini +//registry.example.com/:_authToken=${NPM_TOKEN} +``` + +Do not append `?`. + +npm 10, bundled with Node 20 and Node 22, has no optional form. It looks up a variable named `NPM_TOKEN?`, so exporting `NPM_TOKEN` leaves `${NPM_TOKEN?}` unexpanded and authenticated installs fail. + +```ini +//registry.example.com/:_authToken=${NPM_TOKEN?} +``` + +npm 11 and later document `?` as "substitute an empty string when unset." That is still the wrong form for a required value such as an auth token: an unset variable becomes an empty token instead of a visible unreplaced placeholder. Write `${VARIABLE_NAME}` so the same file works on npm 10 and later. + +Keep the `${...}` reference. Never paste a token into a committed `.npmrc`. Export the variable before `npm ci`. diff --git a/instructions/ess/ess-okf-docs.instructions.md b/instructions/ess/ess-okf-docs.instructions.md new file mode 100644 index 0000000..04ca4c6 --- /dev/null +++ b/instructions/ess/ess-okf-docs.instructions.md @@ -0,0 +1,19 @@ +--- +description: >- + When editing or creating markdown with OKF YAML frontmatter (type, generated, + tags, etc.), consult Context7 for the OKF v0.2 spec before guessing. +applyTo: "**/*.md" +--- + +Use the Context7 MCP for the Open Knowledge Format spec. Context7 is a +configured docs server in this repo. Do not call an `okf-spec` server; none +is defined. + +1. Call `resolve-library-id` with library name `Open Knowledge Format` unless + you already have the id `/googlecloudplatform/open-knowledge-format` + (OKF v0.2, GoogleCloudPlatform/open-knowledge-format). +2. Call `query-docs` with that library id. Keep each query to one frontmatter + concept (`type`, `title`, `description`, `tags`, `generated`). + +`type` is the only required key. Consumers must tolerate unknown types and +preserve unknown keys. diff --git a/instructions/ess/ess-shell-file-naming.instructions.md b/instructions/ess/ess-shell-file-naming.instructions.md new file mode 100644 index 0000000..dadecbe --- /dev/null +++ b/instructions/ess/ess-shell-file-naming.instructions.md @@ -0,0 +1,52 @@ +--- +description: Name shell scripts in kebab-case after what they do, and keep each self-test beside its script as test_.sh +applyTo: "**/*.sh" +--- + +# Shell File Naming + +Shell has no standard file layout, so follow one convention everywhere. + +| File | Case | Example | +| ---- | ---- | ------- | +| Script a person or tool runs | kebab-case, verb first | `find-review-requests.sh`, `safe-push.sh` | +| Its self-test | `test_` + the script name in snake_case | `test_find_review_requests.sh` | + +- **Kebab-case for scripts.** A script is a command, and commands read as kebab-case + (`git-subtree`, `apt-get`). Do not add snake_case scripts beside kebab-case ones. +- **Descriptive names, not `run.sh` or `main.sh`.** `$0`, error messages, `ps`, and + editor tabs show only the file name, so it must say what the script does. +- **`test_` snake_case for tests.** Tests are never typed as commands, and the prefix + matches pytest's `test_*.py`, so one search finds every test in the repo. + +## Co-locate the test with its script + +When a script has a test, give the pair its own directory, named after the script: + +```text +scripts/ +├── npx-skills-add/ +│ ├── npx-skills-add.sh +│ └── test_npx_skills_add.sh +└── apm-lock-check-ignored/ + ├── apm-lock-check-ignored.sh + └── test_apm_lock_check_ignored.sh +``` + +Several scripts that work together and share one directory, such as a skill's +`scripts/`, keep each test next to its script in that directory instead. + +The test finds its script relative to itself, never through the working directory: + +```bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"${SCRIPT_DIR}/find-review-requests.sh" --repo acme/widgets +``` + +## Renaming + +Rename with `git mv` and rename the test to match. Then search for the old name +(`rg -n 'old-name\.sh'`) and update every caller, doc, usage comment, and Makefile +or hook entry. When rewriting references in bulk, match whole file names, for example +`(?- + Answer Django questions from current documentation through the Context7 MCP + instead of training data. Use when the user asks about Django APIs, settings, + the ORM, migrations, management commands, or Django REST Framework details. +compatibility: Requires the Context7 MCP server. +--- + +When answering Django questions, use the Context7 MCP. Do not rely on +training data for Django API details. + +1. Call `resolve-library-id` with library name `Django` unless you already + have a Django library id. +2. Call `query-docs` with that library id. Keep each query to one Django + API or setting. diff --git a/skills/ess/pr-address-comments/LICENSE b/skills/ess/ess-env-credentials/LICENSE similarity index 100% rename from skills/ess/pr-address-comments/LICENSE rename to skills/ess/ess-env-credentials/LICENSE diff --git a/skills/ess/ess-env-credentials/SKILL.md b/skills/ess/ess-env-credentials/SKILL.md new file mode 100644 index 0000000..4ebd062 --- /dev/null +++ b/skills/ess/ess-env-credentials/SKILL.md @@ -0,0 +1,39 @@ +--- +name: ess-env-credentials +description: >- + Recover from a command that fails for missing environment variables or + credentials by asking to source the project's .env file, never reading or + printing it. Use when a command fails with errors such as "Missing required + environment variable", "API key not found", "Authentication failed", + "Credentials not configured", or "passphrase must be set with + PULUMI_CONFIG_PASSPHRASE". +compatibility: Requires a POSIX shell (bash or zsh). +--- + +# Environment Variables and Credentials + +When a command fails due to missing environment variables or credentials, such as: + +- `Missing required environment variable` +- `API key not found` +- `Authentication failed` +- `Credentials not configured` +- `passphrase must be set with PULUMI_CONFIG_PASSPHRASE` + +Do NOT tell the user to run commands in their own terminal. + +## Resolution Steps + +1. Check if a `.env` file exists in the current project directory +2. Ask the user: "I encountered a credentials/environment variable error. Can I source the `.env` file to get the required values?" +3. If approved, run commands by sourcing the .env first: + +```bash +set -a && source .env && set +a && +``` + +## Security Notes + +- Never read or display the contents of `.env` files (they contain secrets) +- Always verify `.env` is gitignored before proceeding +- The `.env` file should contain the required environment variables diff --git a/skills/ess/summarize-change-log/LICENSE b/skills/ess/ess-gh-repo-mirror/LICENSE similarity index 100% rename from skills/ess/summarize-change-log/LICENSE rename to skills/ess/ess-gh-repo-mirror/LICENSE diff --git a/skills/ess/ess-gh-repo-mirror/SKILL.md b/skills/ess/ess-gh-repo-mirror/SKILL.md new file mode 100644 index 0000000..63711eb --- /dev/null +++ b/skills/ess/ess-gh-repo-mirror/SKILL.md @@ -0,0 +1,170 @@ +--- +name: ess-gh-repo-mirror +description: >- + Mirror a GitHub repo or GitHub Action, including its tags, into another GitHub + owner or org, or sync an existing mirror. Use when asked to mirror, fork, copy, + or sync a repo or action into another org, or when a workflow cannot use an + Action from outside its own org. +compatibility: >- + Requires git and an authenticated gh CLI with permission to create repos in the + target owner. GitHub only. +--- + +# Mirror a GitHub Repo + +Copy a GitHub repo's default branch and all of its tags into a repo under another owner or +org, and keep that copy in sync with the source. + +This is most useful for GitHub Actions. Orgs often restrict workflows to Actions hosted in +the org itself, so a workflow cannot reference `some-org/some-action@v4` directly. Mirroring +copies the versions you want into your own org, and workflows reference the mirror instead, +for example `your-org/some-action@v4`. Because tags come along, existing version pins keep +working after you swap the owner. + +## Inputs + +| Input | Format | Default | +| ----- | ------ | ------- | +| Source | `https://github.com//` or `/` | Required | +| Target | `` or `/` | Required; ask if not given. Name defaults to the source repo name. | +| Visibility | `private`, `internal`, or `public` | `private` | +| Remote | Git remote name for the target | The target owner | + +`private` is the tightest visibility: only people explicitly granted access can read the repo. +`internal` makes it readable by every member of the enterprise and exists only for orgs on +GitHub Enterprise. Use a looser visibility only when the user asks for it. + +## Workflow + +### 1. Clone the source (skip if already present) + +```bash +git clone https://github.com//.git +cd +``` + +Clone next to the current repo unless the user names another location. + +### 2. Confirm before writing anything remote + +Check whether the target exists: + +```bash +gh repo view / --json visibility 2>/dev/null +``` + +Then show the user a summary and wait for an explicit yes: + +- Source `/` and its default branch +- Target `/`, and whether it will be created or already exists +- Visibility (for an existing repo, its current visibility) +- Remote name +- For an Action: the repos whose workflows will use it, and the access level that lets them + (work these out as described in step 7) + +Stop on anything but yes. + +### 3. Create the target repo (skip if it exists) + +```bash +gh repo create / -- +``` + +### 4. Add the target remote + +Reuse a remote that already points at the target (`git remote -v`); otherwise add one: + +```bash +git remote add https://github.com//.git +``` + +### 5. Push the default branch and the source's tags + +Push the upstream default branch and the source's tags. A reused clone may be on a feature +branch, carry local-only commits or tags, or be detached. Do not push the current checkout, +and do not push `refs/tags/*`. + +```bash +branch=$(git remote show origin | sed -n 's/.*HEAD branch: //p') +git fetch origin --prune --no-tags \ + "+refs/heads/${branch}:refs/remotes/origin/${branch}" \ + "+refs/tags/*:refs/mirror/origin/tags/*" +git push "refs/remotes/origin/${branch}:refs/heads/${branch}" +git push "refs/mirror/origin/tags/*:refs/tags/*" +``` + +Source tags land in `refs/mirror/origin/tags/`, and only that namespace is pushed. `--prune` +drops a tag from the namespace when the source deletes it. `--no-tags` keeps the fetch from +writing `refs/tags/*`, so a local-only tag stays local and is not published. The leading `+` +replaces a tag in the namespace when upstream moves it, such as `v7`. Brace `${branch}`: in +zsh, `$branch:refs` is a modifier and corrupts the refspec. If the source has no tags, the +tag push matches nothing and exits with an error; skip it. + +### 6. Handle rejected tags + +A tag push is rejected when upstream moved a mutable tag (like `v7`) after the mirror last +copied it. Force-pushing overwrites the mirror's copy, so list the rejected tags and ask +before forcing them. Then force-push each one from the same mirror namespace, not from +`refs/tags/`: + +```bash +git push "refs/mirror/origin/tags/:refs/tags/" --force +``` + +### 7. Let other repos use the Action (Actions only) + +Skip this step for repos that are not Actions, and for public mirrors. Workflows in other repos +cannot use an Action from a private or internal repo until its Actions access setting allows +them, so changing `uses:` to the mirror still fails after both pushes succeed. + +Ask which repos will reference the mirror. A public repo cannot use a private or internal Action +at all. If any consumer is public, stop and ask the user how to resolve the mismatch; do not +widen the mirror's visibility on your own. + +Otherwise pick the narrowest access level that covers every consumer: + +| Consumers | Access level | +| --------- | ------------ | +| Repos owned by the same user account | `user` | +| Repos in the same org | `organization` | + +Set the level the user approved in step 2: + +```bash +gh api -X PUT repos///actions/permissions/access -f access_level= +``` + +This API covers private repos only. For an `internal` mirror used by repos in other orgs of the +enterprise, choose the enterprise option under Settings → Actions → General → Access instead. + +## Updating an existing mirror + +From the clone, find the remote that points at the target with `git remote -v`, and look up the +source's default branch rather than assuming `main`: + +```bash +branch=$(git remote show origin | sed -n 's/.*HEAD branch: //p') +``` + +Fetch that branch and every source tag into the same isolated namespace as step 5. `--prune` +drops a tag from the namespace when the source deletes it, and the leading `+` replaces a tag +there when upstream moves it, such as `v7`. Local tags under `refs/tags/` are left alone: + +```bash +git fetch origin --prune --no-tags \ + "+refs/heads/${branch}:refs/remotes/origin/${branch}" \ + "+refs/tags/*:refs/mirror/origin/tags/*" +``` + +Confirm as in step 2 before pushing anything, and stop on anything but yes. Then push the +fetched upstream ref straight to the matching target branch, and push only the source tags, +so the result does not depend on which branch the clone has checked out or which local tags +it carries: + +```bash +git push "refs/remotes/origin/${branch}:refs/heads/${branch}" +git push "refs/mirror/origin/tags/*:refs/tags/*" +``` + +If the source has no tags, skip the tag push. Handle rejected tags as in step 6, including +the confirmation. diff --git a/skills/ess/ess-git-commits/LICENSE b/skills/ess/ess-git-commits/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/skills/ess/ess-git-commits/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/ess-git-commits/SKILL.md b/skills/ess/ess-git-commits/SKILL.md new file mode 100644 index 0000000..e322aed --- /dev/null +++ b/skills/ess/ess-git-commits/SKILL.md @@ -0,0 +1,69 @@ +--- +name: ess-git-commits +description: >- + Commit and open pull requests as an agent without agent attribution: no + Co-authored-by trailer, no "Made with Cursor" footer. Commit with plain git + commit and let a commit-msg hook strip the trailer Cursor injects. Use when + committing, amending, creating or editing a pull request, or when a commit + shows a "Co-authored-by: Cursor" trailer. +compatibility: >- + Targets Cursor agents, which add the trailer. Requires git; the hook needs + pre-commit with the commit-msg stage installed. +--- + +# Git Commits + +Commit messages and pull requests contain only what the user asked for. Never add +`Co-authored-by:` trailers or any other agent attribution. + +Cursor appends a `Co-authored-by: Cursor` trailer to every agent `git commit`, whatever the +settings or rules say. The `strip-cursor-coauthor` commit-msg hook, bundled as +[scripts/strip-cursor-coauthor.sh](scripts/strip-cursor-coauthor.sh), removes it before +the commit is created, so commit normally and let the hooks run. To add the hook to a +repo, follow [references/pre-commit.md](references/pre-commit.md). + +The commands below call `pre-commit` directly. If the repo runs it through a tool +runner, such as `uv run pre-commit`, use that form instead. + +## Committing + +1. Once per checkout, make sure the commit-msg hook is installed. A plain install covers + every stage listed in `default_install_hook_types`: + + ```bash + test -x "$(git rev-parse --git-path hooks)/commit-msg" || pre-commit install + ``` + +2. Commit with `git commit`. Never pass `--no-verify`: it skips the hook that removes the + trailer, along with the message checks. +3. Verify with `git log -1 --format=full`. If the trailer is still there, the hook did not + run: install it (step 1), then `git commit --amend --no-edit` while the commit is unpushed. + +## Fallback: no hooks + +If installing the hooks is not an option, build the commit with `git commit-tree`, which +Cursor leaves alone. It skips every hook too, so write the message to a file and run them +yourself first: + +```bash +pre-commit run --files $(git diff --cached --name-only) +cat > /tmp/commit-msg <<'EOF' +(): + + +EOF +pre-commit run --hook-stage commit-msg --commit-msg-filename /tmp/commit-msg +old=$(git rev-parse HEAD) +new=$(git commit-tree "$(git write-tree)" -p "$old" < /tmp/commit-msg) +git update-ref "refs/heads/$(git branch --show-current)" "$new" "$old" +``` + +Passing the old tip makes `update-ref` fail if the branch moved in the meantime. + +## Pull requests + +- Never append `Made with Cursor`, `Made with [Cursor](https://cursor.com)`, or similar + attribution to PR titles or bodies. +- PR descriptions contain only **Summary** and **Test plan**, or what the user asked for. +- After `gh pr create` or `gh pr edit`, check `gh pr view --json body` and remove + any injected footer. diff --git a/skills/ess/ess-git-commits/references/pre-commit.md b/skills/ess/ess-git-commits/references/pre-commit.md new file mode 100644 index 0000000..6627a54 --- /dev/null +++ b/skills/ess/ess-git-commits/references/pre-commit.md @@ -0,0 +1,42 @@ +# Install the strip-cursor-coauthor hook + +The hook runs [`scripts/strip-cursor-coauthor.sh`](../scripts/strip-cursor-coauthor.sh) +at the commit-msg stage and removes the `Co-authored-by: Cursor` trailer. The script is +stand-alone bash; it needs only `grep` and `mktemp`. + +1. Install this skill into the repo, for example with APM, and commit the installed copy. + The hook runs that copy, so a fresh clone without it has no hook to run. +2. Add the hook to `.pre-commit-config.yaml`: + + ```yaml + repos: + - repo: local + hooks: + - id: strip-cursor-coauthor + name: strip Cursor co-author trailer + entry: bash .agents/skills/ess-git-commits/scripts/strip-cursor-coauthor.sh + language: system + stages: [commit-msg] + pass_filenames: true + ``` + + `entry` runs the script through `bash`, so the hook works even if the installer drops + the executable bit. Adjust the path if your installer writes skills somewhere other + than `.agents/skills/`. + +3. Make `pre-commit install` set up the commit-msg stage. Without this line it installs + only the pre-commit stage, and the hook never runs: + + ```yaml + default_install_hook_types: [pre-commit, commit-msg] + ``` + +4. Install the hooks once per checkout, then confirm on a test commit: + + ```bash + pre-commit install + git log -1 --format=full + ``` + +If commit-message checks such as commitizen also run at commit-msg, list this hook first, +so a rejected message is never the trailer's fault. diff --git a/skills/ess/ess-git-commits/scripts/strip-cursor-coauthor.sh b/skills/ess/ess-git-commits/scripts/strip-cursor-coauthor.sh new file mode 100755 index 0000000..a0297f8 --- /dev/null +++ b/skills/ess/ess-git-commits/scripts/strip-cursor-coauthor.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Remove the `Co-authored-by: Cursor` trailer from a commit message. +# +# Cursor appends the trailer while composing the message, before git runs any +# hook. `--no-verify` does not suppress it and `git commit --amend` re-adds it, +# so a commit-msg hook is the only place that reliably removes it without +# rewriting the commit afterwards. The IDE toggle (Cursor Settings -> Git & +# Pull Requests -> Attribution) covers the local case; this hook also covers +# contributors who have not set it and surfaces that Cloud Agents ignore it. +# +# Matches the agent's mailbox rather than its display name: a human +# contributor could be called "Cursor", but only the bot commits from this +# address. Human `Co-authored-by:` lines are left untouched. + +set -euo pipefail + +readonly CURSOR_TRAILER='^Co-authored-by:.*' + +msg_file="${1:?usage: strip-cursor-coauthor.sh }" + +if ! grep -qiE "$CURSOR_TRAILER" "$msg_file"; then + exit 0 +fi + +stripped="$(mktemp)" +trap 'rm -f "$stripped"' EXIT + +# `|| true`: grep exits 1 when it filters out every line, which `set -e` +# would otherwise treat as a failure. +grep -viE "$CURSOR_TRAILER" "$msg_file" >"$stripped" || true + +# The command substitution drops the blank line the trailer left behind. +printf '%s\n' "$(cat "$stripped")" >"$msg_file" diff --git a/skills/ess/ess-postgres-docs/LICENSE b/skills/ess/ess-postgres-docs/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/skills/ess/ess-postgres-docs/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/ess-postgres-docs/SKILL.md b/skills/ess/ess-postgres-docs/SKILL.md new file mode 100644 index 0000000..fdf58dd --- /dev/null +++ b/skills/ess/ess-postgres-docs/SKILL.md @@ -0,0 +1,18 @@ +--- +name: ess-postgres-docs +description: >- + Answer PostgreSQL and psql questions from current documentation through the + Context7 MCP instead of training data. Use when the user asks about SQL + syntax, the psql CLI, PostgreSQL administration, or configuration settings. +compatibility: Requires the Context7 MCP server. +--- + +When answering PostgreSQL questions (SQL syntax, psql CLI, administration, or +configuration), use the Context7 MCP. Do not rely on training data for +PostgreSQL command or API details. + +1. Call `resolve-library-id` with library name `PostgreSQL` unless you already + have the id `/websites/postgresql_current`. +2. Call `query-docs` with that library id. Prefer `/websites/postgresql_current` + unless the user specifies a version. Keep each query to one command or + setting. diff --git a/skills/ess/ess-pr-address-comments/LICENSE b/skills/ess/ess-pr-address-comments/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/skills/ess/ess-pr-address-comments/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/pr-address-comments-all/SKILL.md b/skills/ess/ess-pr-address-comments/SKILL.md similarity index 66% rename from skills/ess/pr-address-comments-all/SKILL.md rename to skills/ess/ess-pr-address-comments/SKILL.md index 6533b1e..29ca07a 100644 --- a/skills/ess/pr-address-comments-all/SKILL.md +++ b/skills/ess/ess-pr-address-comments/SKILL.md @@ -1,20 +1,21 @@ --- -name: pr-address-comments-all -description: Address GitHub PR review comments on one or more PRs in parallel, each in its own git worktree on the PR's branch, reusing the pr-address-comments procedure. Auto-starts (look up PRs, create worktrees, fetch comments), then pauses to show a per-PR addressing plan for approval; after approval each PR is handled by a background subagent that implements, commits, pushes to the PR branch, and resolves threads. With no PR given, it discovers your own open PRs on the current repo's remote that have changes-requested or unresolved review comments and runs the same workflow. Use when the user gives one or more PR URLs/numbers, or asks to address/fix review comments on their own open PRs on this repo without naming them, especially in parallel or batch. +name: ess-pr-address-comments +description: Address GitHub PR review comments, implement fixes on the PR branch, push, then reply to and resolve the addressed threads. Handles one PR or many in parallel, each in its own git worktree on the PR's branch. Auto-starts (look up PRs, create worktrees, fetch comments), then pauses to show a per-PR addressing plan for approval; after approval each PR is handled by a background subagent that implements, commits, pushes to the PR branch, and resolves threads. With no PR given, it discovers your own open PRs on the current repo's remote that have changes-requested or unresolved review comments and runs the same workflow. Use for /ess-pr-address-comments, when the user gives one or more PR URLs or numbers, or asks to address/fix review comments on their own open PRs on this repo without naming them. --- -# PR Address Comments — All (parallel, one worktree per PR) +# PR Address Comments -Address review comments across **one or more** GitHub PRs at the same time. Each PR is -isolated in its own git worktree checked out **on the PR's branch**, so work commits -locally and pushes straight back to the PR. This skill orchestrates the existing single-PR -procedure in [pr-address-comments/SKILL.md](../pr-address-comments/SKILL.md) — it does -not re-document its `gh`/GraphQL/commit steps; helper scripts and reference docs hold the -mechanics. +Address review comments on **one or more** GitHub PRs, in parallel when there are several. +Each PR is isolated in its own git worktree checked out **on the PR's branch**, so work +commits locally and pushes straight back to the PR. Helper scripts and reference docs hold +the `gh`/GraphQL/commit mechanics. ## Quick start ``` +/ess-pr-address-comments # discover my open PRs that need attention +/ess-pr-address-comments 103 +/ess-pr-address-comments https://github.com///pull/103 You: address the review comments on my open PRs You: fix my PRs that need changes You: address the review comments on PRs 103 and 110 @@ -58,15 +59,18 @@ subagent may not understand the whole-repo context. ## Scripts -Invoke by workspace-relative path. Each takes `--help`. +Every phase runs these from `SKILL_DIR`: the absolute path of the directory that +contains this `SKILL.md`. Resolve it once, before Phase 0, and reuse it. Do not use a +repo-relative path; the skill may be installed outside the repo you are working in. +Each script takes `--help`. | Script | Purpose | |--------|---------| -| [`scripts/find_my_prs.sh`](scripts/find_my_prs.sh) | Discover my open PRs on the current repo with changes-requested or unresolved comments | -| [`scripts/fetch_pr.sh`](scripts/fetch_pr.sh) | Look up ONE PR ref -> `meta.env` + `comments.json` + `threads.json` | -| [`scripts/create_pr_worktree.sh`](scripts/create_pr_worktree.sh) | Create a worktree on the PR branch (`-B`) + run repo setup | -| [`scripts/safe_push.sh`](scripts/safe_push.sh) | Push HEAD to the PR branch without hanging — auto-rebases if the branch moved, fails cleanly on conflict | -| [`scripts/reply_and_resolve.sh`](scripts/reply_and_resolve.sh) | Reply to a comment (REST `in_reply_to`) + resolve its thread (GraphQL) | +| [`scripts/find-my-prs.sh`](scripts/find-my-prs.sh) | Discover my open PRs on the current repo with changes-requested or unresolved comments | +| [`scripts/fetch-pr.sh`](scripts/fetch-pr.sh) | Look up ONE PR ref -> `meta.env` + `comments.json` + `threads.json` | +| [`scripts/create-pr-worktree.sh`](scripts/create-pr-worktree.sh) | Create a worktree on the PR branch (`-B`) + run repo setup | +| [`scripts/safe-push.sh`](scripts/safe-push.sh) | Push HEAD to the PR branch without hanging — auto-rebases if the branch moved, fails cleanly on conflict | +| [`scripts/reply-and-resolve.sh`](scripts/reply-and-resolve.sh) | Reply to a comment (REST `in_reply_to`) + resolve its thread (GraphQL) | --- @@ -76,11 +80,21 @@ If the user named no PR, find their own open PRs on the **current repo's remote* attention (review decision `CHANGES_REQUESTED`, or >=1 unresolved review thread): ```bash -SKILL=skills/ess/pr-address-comments-all -mapfile -t PRS < <("$SKILL/scripts/find_my_prs.sh") # current repo, my open PRs +SKILL_DIR= +# bash and zsh. Check the exit status yourself: a process substitution would hide a +# failing script and look like an empty list, and mapfile is not available in zsh. +PR_OUTPUT="$("$SKILL_DIR/scripts/find-my-prs.sh")" || { + echo "Discovery failed; stopping." >&2 + exit 1 +} +PRS=() +while IFS= read -r pr; do + [ -n "$pr" ] && PRS+=("$pr") +done <<< "$PR_OUTPUT" ``` -The script prints a table to stderr and the matching PR numbers to stdout. If `PRS` is +The script prints a table to stderr and the matching PR numbers to stdout. If it exits +non-zero, stop and report the failure. Do not treat that as an empty result. If `PRS` is empty, report "no open PRs need attention" and stop. Otherwise **auto-proceed** to Phase 1 for each number in `PRS` (in parallel) — no extra confirmation; the per-PR plan GATE before any code still applies. Discovery is scoped to the current repo only; for other repos, pass @@ -96,13 +110,11 @@ Pre-flight once: `gh auth status` and `git rev-parse --show-toplevel` must both Then, **for each PR in parallel**: ```bash -SKILL=skills/ess/pr-address-comments-all - # 1) Look up the PR -> writes meta.env + comments.json + threads.json -"$SKILL/scripts/fetch_pr.sh" # add --repo owner/repo for a bare number +"$SKILL_DIR/scripts/fetch-pr.sh" # add --repo owner/repo for a bare number # 2) Create a worktree checked out on the PR's branch + run worktrees.json setup -"$SKILL/scripts/create_pr_worktree.sh" --pr --repo / +"$SKILL_DIR/scripts/create-pr-worktree.sh" --pr --repo / ``` Record each PR's `meta.env` values plus the printed `WORKTREE_ID` / `WORKTREE_PATH`. @@ -134,28 +146,33 @@ Compile every PR's returned plan into one view and **stop for the user**: ``` The `File:Line` column comes straight from each thread's `path` and `line` in -`threads.json` (see [`scripts/fetch_pr.sh`](scripts/fetch_pr.sh)). +`threads.json` (see [`scripts/fetch-pr.sh`](scripts/fetch-pr.sh)). Wait for explicit approval. Allow the user to edit, drop, or skip items per PR. Do not start Phase 3 until approved; if changes are requested, revise and re-present. ## Phase 3 — Implement, commit, push, resolve (parallel) +Pass each subagent the absolute `SKILL_DIR` resolved before Phase 0. The PR worktree +may be an older checkout or another repository, so it may not contain +`fix-procedure.md` or the helper scripts. The subagent reads procedures and runs helpers +from `$SKILL_DIR`. Code edits stay in the PR worktree. + For each approved PR, launch one background `generalPurpose` subagent using the implementation template in [references/subagent-prompts.md](references/subagent-prompts.md). Each subagent works entirely inside its `WORKTREE_PATH`, follows -[pr-address-comments/SKILL.md](../pr-address-comments/SKILL.md) Steps 6–8 +`"$SKILL_DIR/references/fix-procedure.md"` (implement -> verify -> **non-interactive** conventional commit), pushes with -`"$SKILL/scripts/safe_push.sh" --branch ` (with `SKILL=skills/ess/pr-address-comments-all`), then calls -`"$SKILL/scripts/reply_and_resolve.sh"` for each ADDRESSED thread (never for "skip" threads). +`"$SKILL_DIR/scripts/safe-push.sh" --branch `, then calls +`"$SKILL_DIR/scripts/reply-and-resolve.sh"` for each ADDRESSED thread (never for "skip" threads). Run all implementation subagents concurrently; each pushes to its own branch. > **Background subagents have no TTY.** Any command that waits for input (interactive > `cz c`, `git pull --rebase` that stops on a conflict, a pager, a credential prompt) > hangs that subagent forever. Subagents MUST use non-interactive git and push via -> `safe_push.sh` — see [Resilience](#resilience--moving-branches--stuck-subagents). +> `safe-push.sh` — see [Resilience](#resilience--moving-branches--stuck-subagents). ## Resilience — moving branches & stuck subagents @@ -166,7 +183,7 @@ prompt it can never answer, so it hangs indefinitely. Guard against this on both **Subagent side (built into the template):** -- Push only via `"$SKILL/scripts/safe_push.sh" --branch `. It forces non-interactive git, +- Push only via `"$SKILL_DIR/scripts/safe-push.sh" --branch `. It forces non-interactive git, retries the push, auto-rebases when the remote moved with non-conflicting changes, and on a real conflict **aborts the rebase** (leaving a clean tree) and exits `3` instead of blocking. Never run a bare `git push`/`git pull --rebase` in a subagent. @@ -175,15 +192,17 @@ prompt it can never answer, so it hangs indefinitely. Guard against this on both leave a rebase/merge in progress. **Orchestrator side (watchdog):** after dispatching the background subagents, do not block -on them indefinitely. If a subagent runs far past its expected time with no result, or -returns a `SAFE_PUSH_CONFLICT` / non-zero push, **finish that PR inline** instead of -waiting or re-dispatching: in its worktree, `git fetch origin -- ` then rebase the -subagent's fix commit onto `origin/` (or `git reset --hard origin/` and -re-apply the approved fixes), `"$SKILL/scripts/safe_push.sh" --branch ` (with -`SKILL=skills/ess/pr-address-comments-all`), and `"$SKILL/scripts/reply_and_resolve.sh"`. -Never run two agents -against the same branch at once — if a tick/retry finds a PR already has an in-flight fix, -skip it. +on them indefinitely. Never run two agents against the same branch at once — if a tick or +retry finds a PR already has an in-flight fix, skip it. + +- If a subagent **returns** a `SAFE_PUSH_CONFLICT` or other non-zero push, it has stopped. + Finish that PR inline: in its worktree, `git fetch origin -- `, then rebase the + subagent's fix commit onto `origin/` (or `git reset --hard origin/` and + re-apply the approved fixes), `"$SKILL_DIR/scripts/safe-push.sh" --branch `, and + `"$SKILL_DIR/scripts/reply-and-resolve.sh"`. +- If a subagent **times out** with no result, cancel it and confirm it has terminated + before touching its worktree. A timeout does not mean it has stopped. If termination + cannot be confirmed, report the stall and do not fetch, rebase, or reset that worktree. ## Aggregate summary @@ -215,8 +234,8 @@ removing any worktree whose push failed. - Phase 2 / Phase 3 subagent prompt templates -> [references/subagent-prompts.md](references/subagent-prompts.md). - Script flags -> run any script with `--help`. -- The per-PR commit / reply / resolve specifics -> - [pr-address-comments/SKILL.md](../pr-address-comments/SKILL.md) Steps 6–9. +- The per-PR implement / verify / commit / reply / resolve specifics and API error + handling -> [references/fix-procedure.md](references/fix-procedure.md). ## Anti-patterns @@ -225,14 +244,14 @@ removing any worktree whose push failed. - Fabricating comments/threads when `gh` fails — surface the failure instead. - Detached worktrees for this flow — use `-B` so commits land on the PR branch (see worktree-mechanics.md for the one exception). -- Duplicating the command's Steps 6–9 in subagent prompts — reference them, don't copy. +- Duplicating `fix-procedure.md` in subagent prompts — reference it, don't copy. - Stacking multiple PRs in one worktree — one worktree per PR, always. - Running discovery against a repo you didn't intend — Phase 0 always uses the current remote; pass explicit PR URLs for any other repo. - Interactive commands in a background subagent (`cz c`, `git pull --rebase` on a conflict, a pager) — they have no TTY and will hang forever. Commit with `git commit -m` and push - via `safe_push.sh`. -- A bare `git push` in a subagent with no recovery — use `safe_push.sh` so a moved branch + via `safe-push.sh`. +- A bare `git push` in a subagent with no recovery — use `safe-push.sh` so a moved branch rebases or fails cleanly instead of stalling. - Leaving a rebase/merge in progress when a subagent gives up — always abort to a clean tree and report, so the orchestrator can finish inline. diff --git a/skills/ess/ess-pr-address-comments/references/fix-procedure.md b/skills/ess/ess-pr-address-comments/references/fix-procedure.md new file mode 100644 index 0000000..cda4273 --- /dev/null +++ b/skills/ess/ess-pr-address-comments/references/fix-procedure.md @@ -0,0 +1,93 @@ +# Per-PR fix procedure + +How one PR's approved plan becomes code, a commit, and resolved threads. The Phase 3 +subagent (or the orchestrator, when it runs a single PR inline) follows these steps inside +that PR's worktree. Inputs come from [`scripts/fetch-pr.sh`](../scripts/fetch-pr.sh): +`meta.env` for owner, repo, number, and branch, and `threads.json` for the full +conversation. `threads.json` has one object per comment (`threadId`, `commentId`, +`path`, `line`, `root`, `body`). Reply to the comment whose `root` is true. + +## 1. Implement + +For each "fix" item in the approved plan: + +1. **Read** the file and the surrounding context. +2. **Implement** the fix the reviewer asked for. +3. **Check** consistency: grep for stale references to anything you renamed or moved. + +Group related fixes, such as several comments on the same file, to avoid redundant reads. +Track which `commentId`s received a code fix; only those are replied to and resolved in +step 4. Do nothing for "skip" items. + +## 2. Verify + +Run checks that match the nature of the fixes, and keep them cheap: + +```bash +rg "" / # no stale references left +ls / # file layout matches the docs +``` + +Run the repo's linters or tests for the files you touched when they apply. + +## 3. Commit + +Commit with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#specification), +non-interactively. Never use `cz c` here: it prompts, and a background subagent has no +TTY to answer it. + +```bash +git add +git commit -m "fix(): address PR # review comments" \ + -m "" +``` + +| Type | When to use | +|------|-------------| +| `fix` | Addressing review comments, bug fixes | +| `refactor` | Code restructuring without behavior change | +| `docs` | Documentation-only changes | +| `style` | Formatting, whitespace | +| `chore` | Tooling, config, non-production changes | + +Push with `"$SKILL_DIR/scripts/safe-push.sh" --branch `, never a bare `git push`. +`SKILL_DIR` is the absolute path of this skill, resolved before entering the PR worktree. +See SKILL.md "Resilience" for why. + +## 4. Reply and resolve addressed threads + +For each thread addressed in step 1, run +[`scripts/reply-and-resolve.sh`](../scripts/reply-and-resolve.sh): + +```bash +"$SKILL_DIR/scripts/reply-and-resolve.sh" \ + --repo / --pr \ + --comment-id --thread-id \ + --body "Fixed — " +``` + +Reply format: + +- Be concise: "Fixed — ." +- Name the specific change when it helps: "Changed `grep hello-world` to `grep hello-agent-auth`." +- Do not repeat the reviewer's comment back to them. + +Only resolve threads whose comments were addressed in code. Leave threads that need +discussion, or that were skipped or deferred, open. + +### API gotchas + +The helper already does this correctly; these matter if you ever call `gh` by hand. + +- **Reply** with `POST /repos/{owner}/{repo}/pulls/{number}/comments` and an `in_reply_to` + field. The older `/replies` sub-resource returns 404. +- **Resolve** with the GraphQL `resolveReviewThread` mutation. REST has no resolve + endpoint, and `minimizeComment` only hides a comment; it does not resolve the thread. + +## Error handling + +| Error | Cause | Fix | +|-------|-------|-----| +| 404 on reply | Used the `/replies` sub-resource | Use `POST /pulls/{number}/comments` with `in_reply_to` | +| 422 on resolve | Thread already resolved | Safe to ignore; the thread is already in the desired state | +| 403 on GraphQL | Token lacks `write:discussion` scope | `gh auth refresh -s write:discussion` | diff --git a/skills/ess/pr-address-comments-all/references/subagent-prompts.md b/skills/ess/ess-pr-address-comments/references/subagent-prompts.md similarity index 61% rename from skills/ess/pr-address-comments-all/references/subagent-prompts.md rename to skills/ess/ess-pr-address-comments/references/subagent-prompts.md index 03e68d8..3619215 100644 --- a/skills/ess/pr-address-comments-all/references/subagent-prompts.md +++ b/skills/ess/ess-pr-address-comments/references/subagent-prompts.md @@ -2,8 +2,8 @@ Reusable prompts the orchestrator passes to the per-PR subagents. Fill in the `<...>` placeholders from each PR's `meta.env` / `threads.json` (produced by -[`scripts/fetch_pr.sh`](../scripts/fetch_pr.sh)) and the worktree path (from -[`scripts/create_pr_worktree.sh`](../scripts/create_pr_worktree.sh)). +[`scripts/fetch-pr.sh`](../scripts/fetch-pr.sh)) and the worktree path (from +[`scripts/create-pr-worktree.sh`](../scripts/create-pr-worktree.sh)). ## Phase 2 — analysis (read-only) @@ -18,8 +18,9 @@ For each review thread below, read the referenced file and surrounding code in this worktree and propose a concrete, minimal fix. If a thread needs discussion rather than a code change, mark it "skip" and say why. Do NOT edit anything. -Threads (from threads.json — path | line | comment | threadId | commentId): -/threads.json> +Threads (from threads.json — one JSON object per comment, including replies. +`root: true` is the comment to reply to; its `commentId` is the root comment id. +Paste every row from /threads.json): Return a markdown table with columns: # | path:line | reviewer ask | proposed fix (files + approach) | action (fix/skip) @@ -35,29 +36,34 @@ verbatim. Work entirely inside this worktree: (cd there first). It is already checked out on branch for PR # in /. +SKILL_DIR= +Read procedures and run helpers from $SKILL_DIR. Do not look for them inside +the PR worktree: that checkout may predate this skill or belong to another +repository. Code edits stay in the PR worktree. + You are a BACKGROUND subagent with NO terminal. Never run a command that waits for input — it will hang you forever. So: no `cz c`, no bare `git pull --rebase`, no pager. Up front, force non-interactive git: export GIT_EDITOR=true GIT_SEQUENCE_EDITOR=true GIT_PAGER=cat PAGER=cat GIT_TERMINAL_PROMPT=0 -Read skills/ess/pr-address-comments/SKILL.md and follow Steps 6–8 for this -PR, using the APPROVED PLAN below as the exact list of fixes to make: +Read "$SKILL_DIR/references/fix-procedure.md" and follow it +for this PR, using the APPROVED PLAN below as the exact list of fixes to make: Specifics: -- Step 6: implement each "fix" item. Skip items marked "skip". -- Step 7: verify (grep for stale refs; run cheap, relevant linters/tests). -- Step 8: conventional-commit non-interactively (NOT `cz c`): +- Implement each "fix" item. Skip items marked "skip". +- Verify (grep for stale refs; run cheap, relevant linters/tests). +- Conventional-commit non-interactively (NOT `cz c`): git commit -m "fix(): address PR # review comments" - Push with the safe helper (handles a branch that moved under you; never hangs): - skills/ess/pr-address-comments-all/scripts/safe_push.sh --branch + "$SKILL_DIR/scripts/safe-push.sh" --branch If it exits 3 (SAFE_PUSH_CONFLICT) or otherwise non-zero, STOP — do NOT leave a rebase in progress — and report the exact state so the orchestrator finishes inline. Do not retry endlessly or improvise an interactive rebase. - For each ADDRESSED thread, reply + resolve via the helper (do NOT resolve - "skip" threads): - skills/ess/pr-address-comments-all/scripts/reply_and_resolve.sh \ + "skip" threads). Use the root comment id (`root: true` in threads.json): + "$SKILL_DIR/scripts/reply-and-resolve.sh" \ --repo / --pr \ --comment-id --thread-id \ --body "Fixed — " @@ -68,14 +74,13 @@ and the push result (including any SAFE_PUSH_CONFLICT/non-zero exit). ## Notes -- Steps 6–8 (implement / verify / commit) are NOT duplicated here — the subagent - follows them from `skills/ess/pr-address-comments/SKILL.md`, with two background - overrides: commit with `git commit -m` (Step 8's `cz c` is interactive and would - hang a TTY-less subagent), and push via `safe_push.sh` instead of a bare - `git push` (so a branch that moved mid-run rebases or fails cleanly). -- `reply_and_resolve.sh` and `safe_push.sh` live in the repo, so they are present - inside the worktree (a checkout of the same repo) and can be called by their - workspace-relative paths. +- Implement / verify / commit / reply are NOT duplicated here — the subagent + follows them from [`fix-procedure.md`](fix-procedure.md), which already commits + with `git commit -m` (interactive `cz c` would hang a TTY-less subagent) and + pushes via `safe-push.sh` instead of a bare `git push` (so a branch that moved + mid-run rebases or fails cleanly). +- `reply-and-resolve.sh` and `safe-push.sh` are invoked from `$SKILL_DIR`, the + absolute path of the loaded skill. The PR worktree is not assumed to contain them. - A `SAFE_PUSH_CONFLICT` (exit 3) is the orchestrator's cue to finish that PR inline — see SKILL.md "Resilience". The subagent must report it, not retry. - For a single PR, the orchestrator may run Phases 2–3 inline instead of spawning diff --git a/skills/ess/pr-address-comments-all/references/worktree-mechanics.md b/skills/ess/ess-pr-address-comments/references/worktree-mechanics.md similarity index 81% rename from skills/ess/pr-address-comments-all/references/worktree-mechanics.md rename to skills/ess/ess-pr-address-comments/references/worktree-mechanics.md index 5f754b3..c606e57 100644 --- a/skills/ess/pr-address-comments-all/references/worktree-mechanics.md +++ b/skills/ess/ess-pr-address-comments/references/worktree-mechanics.md @@ -1,8 +1,8 @@ # Worktree mechanics -How `pr-address-comments-all` isolates each PR in its own git worktree and why +How `ess-pr-address-comments` isolates each PR in its own git worktree and why it differs from the stock `/worktree` command. The script -[`scripts/create_pr_worktree.sh`](../scripts/create_pr_worktree.sh) implements +[`scripts/create-pr-worktree.sh`](../scripts/create-pr-worktree.sh) implements all of this; read here when you need to understand or debug it. ## `-B ` instead of `--detach` @@ -18,7 +18,9 @@ git worktree add -B "$BRANCH" "$WORKTREE_DIR" "origin/$BRANCH" ``` `-B` creates (or resets) a real local branch `$BRANCH` pointing at -`origin/$BRANCH` and checks it out in the new worktree. As a result: +`origin/$BRANCH` and checks it out in the new worktree. The script refuses that +reset when the local branch contains commits that are not on `origin/$BRANCH`, +so Phase 1 cannot drop unpushed work. As a result: - Commits land directly on the PR branch. - `git push origin "$BRANCH"` updates the PR — no `push HEAD:` dance. @@ -72,19 +74,19 @@ another worktree (git forbids the same branch in two worktrees). Options: - Create the worktree detached and push explicitly: ```bash - SKILL=skills/ess/pr-address-comments-all + # SKILL_DIR is the absolute path of this skill, resolved before entering the worktree. git worktree add --detach "$WORKTREE_DIR" "origin/$BRANCH" # ... commit ... - "$SKILL/scripts/safe_push.sh" --branch "$BRANCH" # pushes HEAD:, rebases if moved + "$SKILL_DIR/scripts/safe-push.sh" --branch "$BRANCH" # pushes HEAD:, rebases if moved ``` - `safe_push.sh` already pushes `HEAD:`, so it works the same whether the + `safe-push.sh` already pushes `HEAD:`, so it works the same whether the worktree is attached (`-B`) or detached — and it will not hang if the branch moved while you worked. ### Cross-repo PRs When PRs span multiple repositories (full URLs in different repos), run -`create_pr_worktree.sh` from **each PR's own repo root** so `REPO_ROOT` and the +`create-pr-worktree.sh` from **each PR's own repo root** so `REPO_ROOT` and the remote resolve correctly. Always pass `--repo owner/repo` so a bare-number assumption from one repo never leaks into another. diff --git a/skills/ess/pr-address-comments-all/scripts/create_pr_worktree.sh b/skills/ess/ess-pr-address-comments/scripts/create-pr-worktree.sh similarity index 83% rename from skills/ess/pr-address-comments-all/scripts/create_pr_worktree.sh rename to skills/ess/ess-pr-address-comments/scripts/create-pr-worktree.sh index 73c5cc4..807c5b7 100755 --- a/skills/ess/pr-address-comments-all/scripts/create_pr_worktree.sh +++ b/skills/ess/ess-pr-address-comments/scripts/create-pr-worktree.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# create_pr_worktree.sh +# create-pr-worktree.sh # Create a git worktree checked out ON a PR's branch (so work commits locally # and pushes straight back to the PR), then run the repo's worktree setup. # @@ -8,7 +8,7 @@ # (~/.cursor/worktrees//) so /delete-worktree still # works, but uses `git worktree add -B ` instead of --detach. # -# Usage: create_pr_worktree.sh --pr N --repo owner/repo [--branch B] +# Usage: create-pr-worktree.sh --pr N --repo owner/repo [--branch B] # --pr N - PR number (used to name the worktree) # --repo owner/repo - Repo the PR lives in # --branch B - PR head branch (default: looked up via gh) @@ -61,16 +61,31 @@ else fi REPO_KEY="${REPO_BASENAME}-${REPO_HASH}" +info "Fetching origin/${BRANCH}" +# `--` so a branch name starting with `-` is treated as a refspec, not an option. +git fetch origin -- "$BRANCH" + +# -B moves an existing local branch to origin/ even when that branch is +# not checked out. Refuse when the local branch has commits the remote does not, +# so Phase 1 cannot drop unpushed work before the user approves a plan. Check +# this before creating any worktree directory. +if git show-ref --verify --quiet "refs/heads/${BRANCH}"; then + if ! git show-ref --verify --quiet "refs/remotes/origin/${BRANCH}"; then + error "origin/${BRANCH} is missing after fetch. Refusing to reset local branch ${BRANCH}." + fi + # Fully qualified refs. A tag with the same name as the branch would + # otherwise win, and an ancestral tag would hide unpushed branch commits. + if ! git merge-base --is-ancestor "refs/heads/${BRANCH}" "refs/remotes/origin/${BRANCH}"; then + error "Local branch ${BRANCH} has commits that are not on origin/${BRANCH}. Refusing to reset it." + fi +fi + WORKTREE_ID="pr-${PR_NUMBER}-$(openssl rand -hex 4)" WORKTREE_DIR="${HOME}/.cursor/worktrees/${WORKTREE_ID}/${REPO_KEY}" [[ -d "$WORKTREE_DIR" ]] && error "Worktree directory already exists: ${WORKTREE_DIR}" mkdir -p "$(dirname "$WORKTREE_DIR")" -info "Fetching origin/${BRANCH}" -# `--` so a branch name starting with `-` is treated as a refspec, not an option. -git fetch origin -- "$BRANCH" - info "Creating worktree on branch ${BRANCH}" # -B resets/creates a real local branch tracking origin/, checked out # in the new worktree. Commits land on the branch; `git push origin ` diff --git a/skills/ess/pr-address-comments-all/scripts/fetch_pr.sh b/skills/ess/ess-pr-address-comments/scripts/fetch-pr.sh similarity index 58% rename from skills/ess/pr-address-comments-all/scripts/fetch_pr.sh rename to skills/ess/ess-pr-address-comments/scripts/fetch-pr.sh index 21f4772..4744f57 100755 --- a/skills/ess/pr-address-comments-all/scripts/fetch_pr.sh +++ b/skills/ess/ess-pr-address-comments/scripts/fetch-pr.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # -# fetch_pr.sh +# fetch-pr.sh # Look up ONE PR reference and dump everything the skill needs to plan and # address its review comments. # -# Usage: fetch_pr.sh [--repo owner/repo] [--output DIR] +# Usage: fetch-pr.sh [--repo owner/repo] [--output DIR] # - Full PR URL (https://github.com///pull/) or a bare number # --repo owner/repo - Repo to use for a bare number (default: current remote) # --output DIR - Output directory (default: /tmp/pr-address---) @@ -12,7 +12,7 @@ # Writes into DIR: # meta.env - OWNER=, REPO=, NUMBER=, BRANCH=, TITLE=, STATE= (shell-escaped) # comments.json - raw inline review comments (gh api .../pulls//comments) -# threads.json - unresolved review threads as NDJSON {threadId,commentId,path,line,body} +# threads.json - one NDJSON object per comment {threadId,commentId,path,line,root,body}; root=true is the reply target # # Also prints the meta block to stdout. Exits non-zero if gh fails -- never # fabricates data. @@ -96,10 +96,11 @@ STATE="$(gh pr view "$NUMBER" --repo "${OWNER}/${REPO}" --json state --jq '.stat gh api "repos/${OWNER}/${REPO}/pulls/${NUMBER}/comments" > "${OUTPUT_DIR}/comments.json" \ || error "gh api comments failed for ${OWNER}/${REPO}#${NUMBER}" -# Unresolved review threads (thread id + first comment's databaseId/path/line/body), -# paginated through ALL pages so a PR with >100 threads is never truncated. One -# gh call per page; each page emits its unresolved threads as NDJSON plus a final -# "@@PAGEhasNextPageendCursor" marker, so no external jq is needed. +# Unresolved review threads, including every comment (not only the first). +# One NDJSON object per comment: {threadId,commentId,path,line,root,body}. +# root=true is the first comment; reply to that commentId. Thread pages and +# comment pages are both paginated. Each gh --jq page emits compact JSON via +# @json plus @@COMMENTS / @@PAGE markers, so no external jq is needed. THREADS_QUERY=' query($owner:String!, $repo:String!, $number:Int!, $cursor:String) { repository(owner: $owner, name: $repo) { @@ -108,35 +109,126 @@ query($owner:String!, $repo:String!, $number:Int!, $cursor:String) { nodes { id isResolved - comments(first: 1) { nodes { databaseId path line originalLine body } } + comments(first: 100) { + nodes { databaseId path line originalLine body } + pageInfo { hasNextPage endCursor } + } } pageInfo { hasNextPage endCursor } } } } }' +THREAD_JQ=' +(.data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved == false) + | . as $t + | ( + $t.comments.nodes + | to_entries[] + | { + threadId: $t.id, + commentId: .value.databaseId, + path: .value.path, + line: (.value.line // .value.originalLine), + root: (.key == 0), + body: .value.body + } + | @json + ), + ( + select($t.comments.pageInfo.hasNextPage) + | "@@COMMENTS\t" + $t.id + "\t" + ($t.comments.pageInfo.endCursor // "") + ) +), +("@@PAGE\t" + (.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage|tostring) + "\t" + (.data.repository.pullRequest.reviewThreads.pageInfo.endCursor // "")) +' +COMMENTS_QUERY=' +query($id:ID!, $cursor:String) { + node(id: $id) { + ... on PullRequestReviewThread { + id + comments(first: 100, after: $cursor) { + nodes { databaseId path line originalLine body } + pageInfo { hasNextPage endCursor } + } + } + } +}' +COMMENT_JQ=' +.data.node as $t +| ( + $t.comments.nodes[] + | { + threadId: $t.id, + commentId: .databaseId, + path: .path, + line: (.line // .originalLine), + root: false, + body: .body + } + | @json + ), + ( + select($t.comments.pageInfo.hasNextPage) + | "@@COMMENTS\t" + $t.id + "\t" + ($t.comments.pageInfo.endCursor // "") + ) +' + : > "${OUTPUT_DIR}/threads.json" -CURSOR="" -while :; do - if [[ -z "$CURSOR" ]]; then PAGE_ARG=(-F cursor=null); else PAGE_ARG=(-f cursor="$CURSOR"); fi - PAGE_OUT="$(gh api graphql -f query="$THREADS_QUERY" \ - -F owner="$OWNER" -F repo="$REPO" -F number="$NUMBER" "${PAGE_ARG[@]}" \ - --jq '(.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {threadId: .id, commentId: .comments.nodes[0].databaseId, path: .comments.nodes[0].path, line: (.comments.nodes[0].line // .comments.nodes[0].originalLine), body: .comments.nodes[0].body}), ("@@PAGE\t" + (.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage|tostring) + "\t" + (.data.repository.pullRequest.reviewThreads.pageInfo.endCursor // ""))')" \ - || error "gh api graphql (reviewThreads) failed for ${OWNER}/${REPO}#${NUMBER}" - HAS_NEXT="false"; NEXT_CURSOR="" +COMMENT_QUEUE="${OUTPUT_DIR}/.comment-queue" +: > "$COMMENT_QUEUE" + +# Split one gh --jq page into comment rows, comment-continuation markers, and +# the thread-page marker. Sets HAS_NEXT and NEXT_CURSOR when @@PAGE is present. +consume_page() { + local page_out="$1" + HAS_NEXT="false" + NEXT_CURSOR="" while IFS= read -r line; do [[ -z "$line" ]] && continue if [[ "$line" == "@@PAGE"$'\t'* ]]; then IFS=$'\t' read -r _ HAS_NEXT NEXT_CURSOR <<< "$line" + elif [[ "$line" == "@@COMMENTS"$'\t'* ]]; then + printf '%s\n' "$line" >> "$COMMENT_QUEUE" else printf '%s\n' "$line" >> "${OUTPUT_DIR}/threads.json" fi - done <<< "$PAGE_OUT" + done <<< "$page_out" +} + +CURSOR="" +while :; do + if [[ -z "$CURSOR" ]]; then PAGE_ARG=(-F cursor=null); else PAGE_ARG=(-f cursor="$CURSOR"); fi + PAGE_OUT="$(gh api graphql -f query="$THREADS_QUERY" \ + -F owner="$OWNER" -F repo="$REPO" -F number="$NUMBER" "${PAGE_ARG[@]}" \ + --jq "$THREAD_JQ")" \ + || error "gh api graphql (reviewThreads) failed for ${OWNER}/${REPO}#${NUMBER}" + consume_page "$PAGE_OUT" CURSOR="$NEXT_CURSOR" [[ "$HAS_NEXT" == "true" && -n "$CURSOR" ]] || break done -THREAD_COUNT="$(grep -c '"threadId"' "${OUTPUT_DIR}/threads.json" 2>/dev/null || echo 0)" +# A comment page can enqueue another @@COMMENTS marker. Drop the marker being +# handled before the request so a retry cannot process it twice. +while [[ -s "$COMMENT_QUEUE" ]]; do + IFS=$'\t' read -r _ THREAD_NODE COMMENT_CURSOR < "$COMMENT_QUEUE" + tail -n +2 "$COMMENT_QUEUE" > "${COMMENT_QUEUE}.next" + mv "${COMMENT_QUEUE}.next" "$COMMENT_QUEUE" + [[ -n "${THREAD_NODE}" && -n "${COMMENT_CURSOR}" ]] \ + || error "Malformed comment page marker for ${OWNER}/${REPO}#${NUMBER}" + PAGE_OUT="$(gh api graphql -f query="$COMMENTS_QUERY" \ + -F id="$THREAD_NODE" -f cursor="$COMMENT_CURSOR" \ + --jq "$COMMENT_JQ")" \ + || error "gh api graphql (reviewThread comments) failed for ${OWNER}/${REPO}#${NUMBER}" + consume_page "$PAGE_OUT" +done +rm -f "$COMMENT_QUEUE" + +# Count threads, not comments. grep -c exits 1 when the count is 0; do not +# append a second 0 with `|| echo`. +THREAD_COUNT="$(grep -c '"root":true' "${OUTPUT_DIR}/threads.json" 2>/dev/null || true)" +THREAD_COUNT="${THREAD_COUNT:-0}" info "Unresolved threads: ${THREAD_COUNT}" # Write + emit the meta block. Values are shell-escaped with printf %q so a diff --git a/skills/ess/pr-address-comments-all/scripts/find_my_prs.sh b/skills/ess/ess-pr-address-comments/scripts/find-my-prs.sh similarity index 90% rename from skills/ess/pr-address-comments-all/scripts/find_my_prs.sh rename to skills/ess/ess-pr-address-comments/scripts/find-my-prs.sh index 047a611..ab1c249 100755 --- a/skills/ess/pr-address-comments-all/scripts/find_my_prs.sh +++ b/skills/ess/ess-pr-address-comments/scripts/find-my-prs.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # -# find_my_prs.sh +# find-my-prs.sh # Discover the current repo's open PRs authored by you that need attention: # either the review decision is CHANGES_REQUESTED, or there is at least one # unresolved review thread. # -# Usage: find_my_prs.sh [--repo owner/repo] [--limit N] +# Usage: find-my-prs.sh [--repo owner/repo] [--limit N] # --repo owner/repo - Repo to query (default: current remote) # --limit N - Max PRs to scan (default: 50) # @@ -14,17 +14,18 @@ # stderr - a human-readable table (number, decision, unresolved, branch, title) # # Exits 0 with empty stdout when nothing matches. +# Exits non-zero if any review-thread lookup fails. A failed lookup is not +# reported as "no PRs need attention", and a PR is not skipped when its +# decision is already CHANGES_REQUESTED. set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' -YELLOW='\033[1;33m' NC='\033[0m' error() { echo -e "${RED}Error:${NC} $1" >&2; exit 1; } info() { echo -e "${GREEN}→${NC} $1" >&2; } -warn() { echo -e "${YELLOW}Warning:${NC} $1" >&2; } command -v gh >/dev/null 2>&1 || error "gh CLI not found on PATH" @@ -35,7 +36,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --repo) OWNER_REPO="${2:-}"; shift 2 ;; --limit) LIMIT="${2:-}"; shift 2 ;; - -h|--help) sed -n '2,18p' "$0"; exit 0 ;; + -h|--help) sed -n '2,19p' "$0"; exit 0 ;; *) error "Unknown argument: $1" ;; esac done @@ -109,7 +110,7 @@ MATCHES=() while IFS=$'\t' read -r NUMBER DECISION BRANCH TITLE; do [[ -z "$NUMBER" ]] && continue UNRESOLVED="$(count_unresolved_threads "$NUMBER")" \ - || { warn "Could not read threads for #${NUMBER}; skipping"; continue; } + || error "Could not read threads for #${NUMBER}. Discovery failed; refusing to return a partial result." if [[ "$DECISION" == "CHANGES_REQUESTED" || "$UNRESOLVED" -gt 0 ]]; then MATCHES+=("$NUMBER") diff --git a/skills/ess/pr-address-comments-all/scripts/reply_and_resolve.sh b/skills/ess/ess-pr-address-comments/scripts/reply-and-resolve.sh similarity index 93% rename from skills/ess/pr-address-comments-all/scripts/reply_and_resolve.sh rename to skills/ess/ess-pr-address-comments/scripts/reply-and-resolve.sh index 0feff3b..1af374c 100755 --- a/skills/ess/pr-address-comments-all/scripts/reply_and_resolve.sh +++ b/skills/ess/ess-pr-address-comments/scripts/reply-and-resolve.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash # -# reply_and_resolve.sh +# reply-and-resolve.sh # Reply to a PR review comment and resolve its thread. Encapsulates the -# error-prone Step 9 of pr-address-comments (SKILL.md): +# error-prone reply/resolve step of references/fix-procedure.md: # - REST reply via POST /repos///pulls//comments with in_reply_to # (NOT the /replies sub-resource, which 404s) # - GraphQL resolveReviewThread mutation # # Usage: -# reply_and_resolve.sh --repo owner/repo --pr N \ +# reply-and-resolve.sh --repo owner/repo --pr N \ # --comment-id ID --thread-id TID --body "Fixed — " # # Only call this for threads that were actually ADDRESSED in code. Skip diff --git a/skills/ess/pr-address-comments-all/scripts/safe_push.sh b/skills/ess/ess-pr-address-comments/scripts/safe-push.sh similarity index 98% rename from skills/ess/pr-address-comments-all/scripts/safe_push.sh rename to skills/ess/ess-pr-address-comments/scripts/safe-push.sh index b05d27a..25defe0 100755 --- a/skills/ess/pr-address-comments-all/scripts/safe_push.sh +++ b/skills/ess/ess-pr-address-comments/scripts/safe-push.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# safe_push.sh +# safe-push.sh # Push the current worktree HEAD to a PR branch WITHOUT ever hanging, even when # the remote branch moved while a subagent was working. This prevents the known # failure where a raw `git push` is rejected (non-fast-forward) and a follow-up @@ -15,7 +15,7 @@ # exit 3 so the caller re-applies on the fresh base or # escalates to the orchestrator. It never waits for input. # -# Usage: safe_push.sh --branch B [--remote origin] [--max-attempts 3] +# Usage: safe-push.sh --branch B [--remote origin] [--max-attempts 3] # # Exit codes: # 0 pushed successfully diff --git a/skills/ess/ess-shell-flags-literal/LICENSE b/skills/ess/ess-shell-flags-literal/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/skills/ess/ess-shell-flags-literal/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/ess-shell-flags-literal/SKILL.md b/skills/ess/ess-shell-flags-literal/SKILL.md new file mode 100644 index 0000000..fcdc08e --- /dev/null +++ b/skills/ess/ess-shell-flags-literal/SKILL.md @@ -0,0 +1,46 @@ +--- +name: ess-shell-flags-literal +description: >- + Write command-line flags out literally in every shell command; never pack + several flags or arguments into one shell variable. Use when running or + writing shell commands in zsh, especially repeated gh, aws, or pulumi calls + that share the same flags, or when a command fails with an unknown flag or a + bad repo name. +compatibility: >- + Written for zsh, which does not word-split unquoted variables. The rule also + avoids quoting bugs in bash. +--- + +# Write Flags Out Literally + +The shell is **zsh**. Unlike bash, zsh does not word-split an unquoted `$VAR`, so a +variable holding several flags reaches the command as **one** argument. The command +then fails locally (unknown flag, bad repo name) before it ever reaches GitHub, AWS, +or Pulumi. + +Every flag and its value goes in the command itself, every time, including the +fifth `gh` call in a row against the same repo. + +```bash +# Bad: gh receives the single argument "--repo my-org/my-repo --json body" +GH="--repo my-org/my-repo --json body" +gh pr view 123 $GH + +# Bad: same failure for any CLI +AWS_ARGS="--profile my-profile --region us-east-1" +aws ssm get-parameter --name /x $AWS_ARGS + +# Good +gh pr view 123 --repo my-org/my-repo --json body +aws ssm get-parameter --name /x --profile my-profile --region us-east-1 +``` + +## Rules + +- Do not store flags, flag/value pairs, or argument lists in a string variable. +- Do not work around it with `${=VAR}`, `eval`, or `$(echo $VAR)`. Write the flags out. +- A variable may hold a **single value** used as one argument, quoted: + `gh pr view "$PR" --repo my-org/my-repo`. +- If a long argument list genuinely must be reused within one script file, use an + array: `args=(--repo my-org/my-repo --json body); gh pr view 123 "${args[@]}"`. + Never do this for one-off agent shell calls. diff --git a/skills/ess/ess-summarize-change-log/LICENSE b/skills/ess/ess-summarize-change-log/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/skills/ess/ess-summarize-change-log/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/summarize-change-log/SKILL.md b/skills/ess/ess-summarize-change-log/SKILL.md similarity index 74% rename from skills/ess/summarize-change-log/SKILL.md rename to skills/ess/ess-summarize-change-log/SKILL.md index 7bc5d4d..ed60d0e 100644 --- a/skills/ess/summarize-change-log/SKILL.md +++ b/skills/ess/ess-summarize-change-log/SKILL.md @@ -1,10 +1,14 @@ --- -name: summarize-change-log +name: ess-summarize-change-log description: >- Condense long commit logs, squash messages, or change narratives into 1–5 GitHub-ready markdown bullets with Conventional Commit subjects (≤50 chars). - Use when the user asks to summarize a git log, boil down commits, write a - squash-merge message, or produce a five-bullet PR/merge summary. + Use for /ess-summarize-change-log, or when the user asks to summarize a git + log, boil down commits, write a squash-merge message, or produce a + five-bullet PR/merge summary. +compatibility: >- + Requires uv. The validator fetches a pinned Commitizen on first run, which + needs network access. --- # Summarize Change Log @@ -49,30 +53,32 @@ Every subject must state **what changed**. Ban vague/process subjects: ## Validation (required) -Before returning output, run from this skill's directory: +Before returning output, run the validator from `SKILL_DIR`: the absolute path of the +directory that contains this `SKILL.md`. Do not use a repo-relative path; the skill may be +installed outside the repo you are working in. ```bash -scripts/validate-summary.sh /tmp/draft-summary.md +"$SKILL_DIR/scripts/validate-summary.sh" /tmp/draft-summary.md ``` Or validate the checked-in fixture: ```bash -scripts/validate-summary.sh \ - examples-good.md +"$SKILL_DIR/scripts/validate-summary.sh" "$SKILL_DIR/examples-good.md" ``` Or pipe stdin: ```bash printf '%s\n' '- **fix: short subject** — Details.' \ - | scripts/validate-summary.sh + | "$SKILL_DIR/scripts/validate-summary.sh" ``` The script enforces: - 1–5 bullets in the expected format -- `uv run cz check -m … -l 50` (explicit 50-char limit; config alone is not applied without `-l`) +- `cz check -m … -l 50` from a pinned Commitizen, run with `uv tool run` in an empty + directory, so the current repo's Commitizen config and dependencies do not apply - Vague-subject blocklist Fix every failure and re-run until exit 0. Only then return the markdown. diff --git a/skills/ess/summarize-change-log/examples-good.md b/skills/ess/ess-summarize-change-log/examples-good.md similarity index 100% rename from skills/ess/summarize-change-log/examples-good.md rename to skills/ess/ess-summarize-change-log/examples-good.md diff --git a/skills/ess/summarize-change-log/examples.md b/skills/ess/ess-summarize-change-log/examples.md similarity index 100% rename from skills/ess/summarize-change-log/examples.md rename to skills/ess/ess-summarize-change-log/examples.md diff --git a/skills/ess/summarize-change-log/scripts/test_validate_summary.py b/skills/ess/ess-summarize-change-log/scripts/test_validate_summary.py similarity index 74% rename from skills/ess/summarize-change-log/scripts/test_validate_summary.py rename to skills/ess/ess-summarize-change-log/scripts/test_validate_summary.py index 50f519e..344615a 100644 --- a/skills/ess/summarize-change-log/scripts/test_validate_summary.py +++ b/skills/ess/ess-summarize-change-log/scripts/test_validate_summary.py @@ -1,4 +1,4 @@ -"""Tests for summarize-change-log summary validation.""" +"""Tests for ess-summarize-change-log summary validation.""" from __future__ import annotations @@ -11,14 +11,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from validate_summary import ( # noqa: E402 + COMMITIZEN_SPEC, ValidationError, is_vague_subject, parse_document, + run_commitizen_check, validate_summary_text, ) -_REPO_ROOT = Path(__file__).resolve().parents[4] - def test_parse_document_extracts_subjects() -> None: text = "- **fix(api): short subject** — Details.\n" @@ -83,7 +83,7 @@ def test_validate_summary_text_checks_each_subject(mock_cz: MagicMock) -> None: "- **docs(web): update guide** — More.\n" ) expected_count = len(parse_document(text).subjects) - count = validate_summary_text(text, repo_root=_REPO_ROOT) + count = validate_summary_text(text) assert count == expected_count assert mock_cz.call_count == expected_count @@ -92,5 +92,26 @@ def test_validate_summary_text_checks_each_subject(mock_cz: MagicMock) -> None: def test_validate_summary_text_rejects_vague_subject(mock_cz: MagicMock) -> None: text = "- **fix: address PR #123** — Process-only subject.\n" with pytest.raises(ValidationError, match="vague subject"): - validate_summary_text(text, repo_root=_REPO_ROOT) + validate_summary_text(text) mock_cz.assert_not_called() + + +@patch("validate_summary.subprocess.run") +def test_run_commitizen_check_uses_pinned_tool_outside_repo( + mock_run: MagicMock, +) -> None: + mock_run.return_value = MagicMock(returncode=0) + run_commitizen_check("fix(api): short subject") + command = mock_run.call_args.args[0] + assert command[:5] == ["uv", "tool", "run", "--from", COMMITIZEN_SPEC] + assert command[-4:] == ["-m", "fix(api): short subject", "-l", "50"] + cwd = Path(mock_run.call_args.kwargs["cwd"]) + assert cwd != Path.cwd() + assert not cwd.exists() + + +@patch("validate_summary.subprocess.run") +def test_run_commitizen_check_raises_on_failure(mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=1) + with pytest.raises(ValidationError, match="failed Conventional Commits"): + run_commitizen_check("Fixed stuff") diff --git a/skills/ess/ess-summarize-change-log/scripts/validate-summary.sh b/skills/ess/ess-summarize-change-log/scripts/validate-summary.sh new file mode 100755 index 0000000..974c04a --- /dev/null +++ b/skills/ess/ess-summarize-change-log/scripts/validate-summary.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Validate ess-summarize-change-log markdown bullets (wrapper for validate_summary.py). +set -euo pipefail + +if ! command -v uv >/dev/null 2>&1; then + echo "validate-summary: uv not found on PATH (required for validation)" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# --no-project: the validator needs only the standard library, so do not sync +# (or create a .venv for) whatever project the caller is in. +exec uv run --no-project python "$SCRIPT_DIR/validate_summary.py" "$@" diff --git a/skills/ess/summarize-change-log/scripts/validate_summary.py b/skills/ess/ess-summarize-change-log/scripts/validate_summary.py similarity index 75% rename from skills/ess/summarize-change-log/scripts/validate_summary.py rename to skills/ess/ess-summarize-change-log/scripts/validate_summary.py index 83751df..da14e49 100644 --- a/skills/ess/summarize-change-log/scripts/validate_summary.py +++ b/skills/ess/ess-summarize-change-log/scripts/validate_summary.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate 1–5 markdown summary bullets for the summarize-change-log skill.""" +"""Validate 1–5 markdown summary bullets for the ess-summarize-change-log skill.""" from __future__ import annotations @@ -7,11 +7,13 @@ import re import subprocess import sys +import tempfile from dataclasses import dataclass from pathlib import Path MESSAGE_LENGTH_LIMIT = 50 MAX_BULLETS = 5 +COMMITIZEN_SPEC = "commitizen==4.16.3" BULLET_PATTERN = re.compile(r"^\s*-\s+\*\*([^*]+)\*\*\s*(—|-)\s*") LIST_LINE_PATTERN = re.compile(r"^\s*-\s+") @@ -38,22 +40,6 @@ class SummaryDocument: footers: list[str] -def find_repo_root(start: Path) -> Path: - """Walk parents until a pyproject.toml with Commitizen config is found.""" - path = start.resolve() - while True: - pyproject = path / "pyproject.toml" - if pyproject.is_file() and "[tool.commitizen]" in pyproject.read_text( - encoding="utf-8" - ): - return path - if path.parent == path: - raise ValidationError( - "could not find repo root with [tool.commitizen] in pyproject.toml" - ) - path = path.parent - - def is_vague_subject(subject: str) -> bool: """Return True when the subject matches a banned process/meta phrase.""" return any(pattern.search(subject) for pattern in VAGUE_SUBJECT_PATTERNS) @@ -104,27 +90,33 @@ def parse_document(text: str) -> SummaryDocument: return SummaryDocument(subjects=subjects, footers=footers) -def run_commitizen_check(subject: str, repo_root: Path) -> None: - """Run ``uv run cz check`` for a single Conventional Commit subject.""" - result = subprocess.run( - [ - "uv", - "run", - "cz", - "check", - "-m", - subject, - "-l", - str(MESSAGE_LENGTH_LIMIT), - ], - cwd=repo_root, - check=False, - ) +def run_commitizen_check(subject: str) -> None: + """Run a pinned ``cz check`` for a single Conventional Commit subject.""" + # An empty working directory keeps the current repo's Commitizen config, + # which may use different rules, out of the check. + with tempfile.TemporaryDirectory() as empty_dir: + result = subprocess.run( + [ + "uv", + "tool", + "run", + "--from", + COMMITIZEN_SPEC, + "cz", + "check", + "-m", + subject, + "-l", + str(MESSAGE_LENGTH_LIMIT), + ], + cwd=empty_dir, + check=False, + ) if result.returncode != 0: raise ValidationError(f"failed Conventional Commits check for: {subject}") -def validate_summary_text(text: str, *, repo_root: Path) -> int: +def validate_summary_text(text: str) -> int: """Validate summary markdown. Returns bullet count on success.""" document = parse_document(text) bullet_count = len(document.subjects) @@ -143,7 +135,7 @@ def validate_summary_text(text: str, *, repo_root: Path) -> int: raise ValidationError( f"vague subject (describe what changed, not process): {subject}" ) - run_commitizen_check(subject, repo_root) + run_commitizen_check(subject) return bullet_count @@ -158,7 +150,7 @@ def _read_input(path: Path | None) -> str: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( - description="Validate summarize-change-log markdown bullets.", + description="Validate ess-summarize-change-log markdown bullets.", ) parser.add_argument( "path", @@ -170,9 +162,7 @@ def main(argv: list[str] | None = None) -> int: try: input_path = Path(args.path).resolve() if args.path else None text = _read_input(input_path) - script_dir = Path(__file__).resolve().parent - repo_root = find_repo_root(script_dir) - bullet_count = validate_summary_text(text, repo_root=repo_root) + bullet_count = validate_summary_text(text) except ValidationError as exc: print(f"validate-summary: {exc}", file=sys.stderr) return 1 diff --git a/skills/ess/pr-address-comments/SKILL.md b/skills/ess/pr-address-comments/SKILL.md deleted file mode 100644 index 0e07db5..0000000 --- a/skills/ess/pr-address-comments/SKILL.md +++ /dev/null @@ -1,269 +0,0 @@ ---- -name: pr-address-comments -description: >- - Read a remote GitHub PR's review comments, implement fixes on the PR branch, - then reply to and resolve addressed threads (REST reply + GraphQL - resolveReviewThread). Use for /pr-address-comments, a single PR URL or number, - when not using the parallel batch skill pr-address-comments-all. Requires gh - and a git checkout of the repo. -metadata: - version: "1.0" ---- - -# PR Address Comments - -Read a remote PR's review comments, implement fixes, then reply to and resolve the addressed threads. - -**Phase**: Fix - -## Quick start - -``` -/pr-address-comments https://github.com///pull/123 -/pr-address-comments 103 -``` - ---- - -## Prerequisites - -- **GitHub CLI** (`gh`) — authenticated and with access to the repository -- The current directory must be a git repo (`.git/` present) - ---- - -## Limitations - -- GitHub's REST API does not have a "resolve thread" endpoint. Threads must be resolved via the **GraphQL API** using `resolveReviewThread`. -- The `minimizeComment` mutation hides comments but does NOT resolve threads — do not use it for this purpose. -- Reply endpoint: `POST /repos/{owner}/{repo}/pulls/{number}/comments` with `in_reply_to` field. The older `/replies` sub-resource returns 404. - ---- - -## Instructions - -### Step 1: Resolve the PR - -Parse the user's input: - -- If a **full URL** is provided (e.g., `https://github.com/org/repo/pull/123`), extract the owner, repo, and PR number directly. -- If only a **number** is provided, determine the remote from `.git`: - -```bash -# Get the GitHub owner/repo from the git remote -gh repo view --json nameWithOwner --jq .nameWithOwner -``` - -### Step 2: Fetch PR Details and Review Comments - -```bash -# Get PR metadata -gh pr view --json title,body,state,headRefName,files - -# Get inline review comments (these are the ones to address) -gh api repos///pulls//comments -``` - -Parse each comment for: -- `id` — the comment ID (needed for responding later) -- `path` — the file path -- `line` / `original_line` — the line number -- `body` — the reviewer's feedback - -### Step 3: Get Unresolved Thread IDs (GraphQL) - -Fetch thread metadata upfront so you can resolve them later: - -```bash -gh api graphql -f query=' -query { - repository(owner: "", name: "") { - pullRequest(number: ) { - reviewThreads(first: 100) { - nodes { - id - isResolved - comments(first: 1) { - nodes { - databaseId - path - body - } - } - } - } - } - } -}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {threadId: .id, commentId: .comments.nodes[0].databaseId, path: .comments.nodes[0].path, body: .comments.nodes[0].body}' -``` - -This gives you both the **thread ID** (for resolving) and the **comment database ID** (for replying). - -### Step 4: Categorize and Present - -Show the user a summary: - -```markdown -## PR #: - -### Review Comments (<COUNT>) - -| # | File | Line | Comment | -|---|------|------|---------| -| 1 | path/to/file.py | 42 | "Add error handling..." | -| 2 | path/to/other.md | 17 | "Update reference..." | -``` - -### Step 5: Checkout the PR Branch - -```bash -git fetch origin <HEAD_REF_NAME> -git checkout <HEAD_REF_NAME> -``` - -### Step 6: Address All Comments - -For each comment: - -1. **Read** the file and surrounding context -2. **Implement** the fix suggested by the reviewer -3. **Verify** the fix (grep for stale references, check consistency) - -Work through all comments systematically. Group related fixes (e.g., multiple comments about the same file) to avoid redundant reads. - -**Track which comments were addressed** — maintain a list of comment IDs that received code fixes. Only these will be replied to and resolved in Step 9. - -### Step 7: Verify - -Run verification checks based on the nature of the fixes: - -```bash -# Example: check for stale references -grep -r "<old-term>" <relevant-dirs>/ - -# Example: confirm file structure matches docs -ls <dir>/ -``` - -### Step 8: Commit - -Commit using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#specification). - -**Check if commitizen is available:** - -```bash -command -v cz >/dev/null 2>&1 && echo "cz available" || echo "cz not found" -``` - -**If `cz` is installed**, use it interactively: - -```bash -git add <files> -cz c -``` - -Follow the commitizen prompts, selecting the appropriate type (usually `fix`) and scope. - -**If `cz` is NOT installed**, commit manually following the spec: - -```bash -git add <files> -git commit -m "fix(<scope>): address PR #<NUMBER> review comments - -<brief description of what was fixed>" -``` - -**Conventional Commits format:** - -``` -<type>(<scope>): <short description> - -[optional body — what and why] - -[optional footer(s)] -``` - -| Type | When to use | -|------|-------------| -| `fix` | Addressing review comments, bug fixes | -| `refactor` | Code restructuring without behavior change | -| `docs` | Documentation-only changes | -| `style` | Formatting, whitespace | -| `chore` | Tooling, config, non-production changes | - -### Step 9: Reply and Resolve Addressed Threads - -For each comment that was **addressed in Step 6**, reply and resolve its thread. - -**Reply to each comment (REST API):** - -```bash -gh api repos/<OWNER>/<REPO>/pulls/<NUMBER>/comments \ - -f body="<reply-message>" \ - -F in_reply_to=<COMMENT_DATABASE_ID> -``` - -Reply format: -- Be concise: "Fixed — <what was done>." -- Reference the specific change if helpful: "Changed `grep hello-world` to `grep hello-agent-auth`." -- Do NOT repeat the reviewer's comment back to them. - -**IMPORTANT:** The reply endpoint is: -``` -POST /repos/{owner}/{repo}/pulls/{pull_number}/comments -``` -with `in_reply_to` as a field in the body. Do NOT use `/replies` sub-resource (returns 404). - -**Resolve each thread (GraphQL):** - -```bash -gh api graphql -f query=' -mutation { - resolveReviewThread(input: {threadId: "<THREAD_ID>"}) { - thread { isResolved } - } -}' -``` - -Only resolve threads whose comments were addressed in code. Skip threads that: -- Require discussion rather than a code fix -- Were not implemented (skipped or deferred) - -### Step 10: Summary - -```markdown -## Done - -### Addressed & Resolved -| # | File | Comment | Reply | -|---|------|---------|-------| -| 1 | path/to/file.md | "Update reference..." | Fixed — updated to use new name. | -| 2 | path/to/other.py | "Remove auth..." | Fixed — removed auth prerequisites. | - -### Skipped (not addressed) -| # | File | Comment | Reason | -|---|------|---------|--------| -| 3 | path/to/config.py | "Consider refactoring..." | Requires discussion | - -**Resolved**: X / Y threads -**Skipped**: Z (not addressed in code) -``` - ---- - -## Error Handling - -| Error | Cause | Fix | -|-------|-------|-----| -| 404 on reply | Used wrong endpoint (`/replies`) | Use `POST /pulls/{number}/comments` with `in_reply_to` field | -| 422 on resolve | Thread already resolved | Safe to ignore — thread is already in desired state | -| 403 on GraphQL | Token lacks `write:discussion` scope | Re-authenticate: `gh auth refresh -s write:discussion` | - ---- - -## Related Commands - -- `/fix-pr-comments` — Interactive mode (asks before each fix) -- `pr-review` skill — Review someone else's PR -- `/create-pr` — Create a new PR -- `pr-address-comments-all` — Parallel batch with worktrees and approval gate diff --git a/skills/ess/summarize-change-log/scripts/validate-summary.sh b/skills/ess/summarize-change-log/scripts/validate-summary.sh deleted file mode 100755 index 148dd5e..0000000 --- a/skills/ess/summarize-change-log/scripts/validate-summary.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -# Validate summarize-change-log markdown bullets (wrapper for validate_summary.py). -set -euo pipefail - -if ! command -v uv >/dev/null 2>&1; then - echo "validate-summary: uv not found on PATH (required for validation)" >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec uv run python "$SCRIPT_DIR/validate_summary.py" "$@" From 2163bb580fdd63727114aa6b4e758b1247c4ee50 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 09:34:15 -0500 Subject: [PATCH 02/20] feat(essentials-sync): scan the files git would ship - Scanners and the verbatim copy share one file list from git ls-files, so ignore rules in parent directories count (a root-ignored .env is neither scanned nor copied). TruffleHog and secretlint get that list. - The PII check accepts subdomains of example.com, .net, and .org. - Add read-only `scan <path>...` and `status --source-repo --target-repo`. - Publish `scan` as the ess-scan pre-commit hook. --- .pre-commit-hooks.yaml | 10 ++ tools/typescript/essentials-sync/README.md | 29 +++- .../essentials-sync/scripts/ess-scan.sh | 18 ++ tools/typescript/essentials-sync/src/cli.ts | 95 ++++++----- tools/typescript/essentials-sync/src/copy.ts | 28 +--- .../essentials-sync/src/ess-items.ts | 32 ++++ .../essentials-sync/src/file-set.ts | 106 ++++++++++++ .../essentials-sync/src/jargon-overrides.ts | 25 +++ .../essentials-sync/src/scan-command.ts | 108 ++++++++++++ .../essentials-sync/src/scanners/pii.ts | 9 +- .../src/scanners/secretlint.ts | 23 ++- .../src/scanners/text-files.ts | 38 +---- .../src/scanners/trufflehog.ts | 19 ++- .../essentials-sync/src/status-command.ts | 105 ++++++++++++ .../essentials-sync/tests/file-set.test.ts | 83 ++++++++++ .../tests/read-only-commands.test.ts | 156 ++++++++++++++++++ .../essentials-sync/tests/scanners.test.ts | 33 ++++ 17 files changed, 817 insertions(+), 100 deletions(-) create mode 100644 .pre-commit-hooks.yaml create mode 100755 tools/typescript/essentials-sync/scripts/ess-scan.sh create mode 100644 tools/typescript/essentials-sync/src/ess-items.ts create mode 100644 tools/typescript/essentials-sync/src/file-set.ts create mode 100644 tools/typescript/essentials-sync/src/jargon-overrides.ts create mode 100644 tools/typescript/essentials-sync/src/scan-command.ts create mode 100644 tools/typescript/essentials-sync/src/status-command.ts create mode 100644 tools/typescript/essentials-sync/tests/file-set.test.ts create mode 100644 tools/typescript/essentials-sync/tests/read-only-commands.test.ts diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..d0fcd18 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,10 @@ +- id: ess-scan + name: ess-scan (secrets, jargon, PII in ess-* items) + description: >- + Runs the essentials-sync deterministic scanners on each changed ess-* item + and fails on critical findings. Needs Node.js 22 or later; trufflehog is + used when installed. + entry: tools/typescript/essentials-sync/scripts/ess-scan.sh + language: script + files: '(^|/)ess-[^/]+/|(^|/)ess/ess-[^/]+$' + require_serial: true diff --git a/tools/typescript/essentials-sync/README.md b/tools/typescript/essentials-sync/README.md index 9c37fe2..6375f8a 100644 --- a/tools/typescript/essentials-sync/README.md +++ b/tools/typescript/essentials-sync/README.md @@ -38,7 +38,7 @@ When `--no-extract` is set and the source pre-scan comes back with **zero** find This exists because a clean source gives the primary agent nothing useful to do, and an agent asked to generalize an already-generic tree will paraphrase working prose and drop valid cross-references instead. On a 16-file skill directory the fast path finished in 27s with zero content drift, against 11min and 187 rewritten lines for the agent path. -The copy honors `.gitignore` and skips the same cache and build directories the scanners skip. That is a safety property, not an optimization: the scanners never read gitignored paths, so copying them would ship content nothing vouched for (`.env`, local credentials). Anything copied that the text scanners could not read -- binaries, files over the 5 MB cap -- is listed explicitly at the end of the run. Files that exist only in the target (`LICENSE`, `NOTICE`) are left untouched, so re-syncing never clobbers them. +The copy and the scanners read one file list. Inside a git work tree it is `git ls-files --cached --others --exclude-standard`, so every ignore rule git applies counts, including `.gitignore` files in parent directories up to the repo root. Outside a work tree it falls back to the package's own `.gitignore` files. Cache and build directories and `tmp-*` notes are always skipped. That shared list is a safety property, not an optimization: the scanners never read ignored paths, so copying them would ship content nothing vouched for (`.env`, local credentials). Anything copied that the text scanners could not read -- binaries, files over the 5 MB cap -- is listed explicitly at the end of the run. Files that exist only in the target (`LICENSE`, `NOTICE`) are left untouched, so re-syncing never clobbers them. Pass `--no-fast-copy` to force the agent path regardless. @@ -138,6 +138,19 @@ Exceptions live in `.essentials-sync-jargon.json` at the repo root. `allow.paths The exception list applies to the hooks only; `essentials-sync` runs still scan synced packages against the full wordlist. +### Guarding `ess-*` items in another repo + +Repos that keep `ess-*` items to sync here can run the same deterministic scanners on every commit with the `ess-scan` hook from this repo's `.pre-commit-hooks.yaml`: + +```yaml +- repo: https://github.com/CiscoDevNet/essentials + rev: <tag or sha> + hooks: + - id: ess-scan +``` + +It runs `essentials-sync scan` on each changed item and fails on critical findings. Its first run installs and builds the tool inside pre-commit's clone, which needs Node.js 22 or later. + ## Usage ``` @@ -161,6 +174,18 @@ essentials-sync [options] | `--no-adversarial-review` | no | Skip the LLM reviewer; run deterministic scanners only. | | `--list-models` | no | Print the available model IDs for your `CURSOR_API_KEY` and exit. | +### Read-only commands + +These need no `CURSOR_API_KEY`, never prompt, and have no `--dry-run`, because they change nothing. + +``` +essentials-sync scan [--no-trufflehog] [--no-secretlint] <path>... +essentials-sync status --source-repo <path> --target-repo <path> +``` + +- **`scan <path>...`** runs the deterministic scanners (trufflehog, secretlint, jargon, PII) and exits `1` on any critical finding, else `0`. A path inside an `ess-*` item scans the whole item; any other path is scanned as given. Jargon terms from `.essentials-sync-jargon.json` at the repo root and in each scanned directory are added to the wordlist, and jargon findings are critical. Each finding prints as file, line, scanner, and rule; secret findings never print the matched value. +- **`status`** lists every `ess-*` item in either repo as `identical`, `differs` (with the files that changed or exist on one side only), `only in source`, or `only in target`. An item is a directory named `ess-*`, or a file named `ess-*` inside a directory named `ess` (`instructions/ess/`). Files are compared by content and executable bit from the same list the copy uses; each item's `LICENSE` is skipped, and dot-directories (`.agents/`, `.cursor/`) are ignored as installed copies. Always exits `0`. + The `opus` sentinel resolves to the highest-version Opus model on your Cursor account; `codex` to the highest-version Codex; etc. Fallback chains kick in if a family is unavailable (primary: `opus` -> any `claude` -> `auto`; reviewer: `codex` -> any `gpt` -> any non-primary -> `auto`). The resolver prefers the canonical version of each family (e.g. `gpt-5.3-codex` over `gpt-5.3-codex-spark`) and always logs the pair it chose so you can see what actually ran. ### Scan coverage @@ -182,7 +207,7 @@ Files whose basename starts with `tmp-` or `tmp.` are skipped by the determinist npm test ``` -Tests use [Vitest](https://vitest.dev) and cover the jargon and PII scanners against the `clean-package` and `dirty-package` fixtures under `tests/fixtures/`, the extract-plan name/path derivation logic, the verbatim copy (including that it refuses to copy gitignored files and preserves executable bits), and the fast-path eligibility gate. +Tests use [Vitest](https://vitest.dev) and cover the jargon and PII scanners against the `clean-package` and `dirty-package` fixtures under `tests/fixtures/`, the extract-plan name/path derivation logic, the shared file list (including ignore rules from the repo root), the verbatim copy (including that it refuses to copy gitignored files and preserves executable bits), the fast-path eligibility gate, and the `scan` and `status` commands. ## License diff --git a/tools/typescript/essentials-sync/scripts/ess-scan.sh b/tools/typescript/essentials-sync/scripts/ess-scan.sh new file mode 100755 index 0000000..c0e13f2 --- /dev/null +++ b/tools/typescript/essentials-sync/scripts/ess-scan.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Runs `essentials-sync scan` on the given paths, installing and building the +# tool first if this checkout has not been built yet. Used as the ess-scan +# pre-commit hook, whose clone of this repo starts with no node_modules or dist. +set -euo pipefail + +TOOL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ ! -f "${TOOL_DIR}/dist/cli.js" ]]; then + echo "ess-scan: building essentials-sync (first run only)" >&2 + ( + cd "${TOOL_DIR}" + npm ci --no-audit --no-fund --loglevel=error >&2 + npm run --silent build >&2 + ) +fi + +exec node "${TOOL_DIR}/dist/cli.js" scan "$@" diff --git a/tools/typescript/essentials-sync/src/cli.ts b/tools/typescript/essentials-sync/src/cli.ts index dc1df1e..5eed83d 100644 --- a/tools/typescript/essentials-sync/src/cli.ts +++ b/tools/typescript/essentials-sync/src/cli.ts @@ -8,43 +8,68 @@ import { runScanners, formatFindings } from "./scanners/index.js"; import { planSync, composeFullPlan } from "./sync.js"; import { planExtract } from "./extract-plan.js"; import { runSyncSession } from "./agent.js"; -import { parseJargonOverrides } from "./jargon-list.js"; +import { loadJargonOverrides } from "./jargon-overrides.js"; import { listAvailableModels, parseModelSpec, resolveModels, } from "./model-resolver.js"; - -loadEnvFiles(); +import { runScanCommand } from "./scan-command.js"; +import { runStatusCommand } from "./status-command.js"; const DEFAULT_PRIMARY = "opus"; const DEFAULT_REVIEW = "codex"; const DEFAULT_MAX_REVISIONS = 3; -const program = new Command() - .name("essentials-sync") - .description( - "Extract a jargon-free ess-* shared package out of a team-/account-specific tool" - + " in the source repo, then sync that shared package to the open-source essentials repo." - + " Default behavior runs both phases; pass --no-extract for sources that are already generic.", - ) - .option("-s, --source <path>", "Absolute path to the source tool directory (tools/python/<name>/)") - .option("-r, --target-repo <path>", "Absolute path to the target repo root (must be a git working tree)") - .option("-t, --target-path <path>", "Path relative to --target-repo where the package should land (default: packages/python/<package-name>)") - .option("--source-repo <path>", "Source repo root (default: git root of --source)") - .option("--package-name <name>", "Name of the extracted ess-* package (default: derived from --source basename)") - .option("-m, --model <spec>", "Primary agent model id, family sentinel, or 'auto'") - .option("--review-model <spec>", "Adversarial reviewer model id, family sentinel, or 'auto'") - .option("--max-revisions <n>", "Maximum scan-and-revise iterations per phase", parseInteger, DEFAULT_MAX_REVISIONS) - .option("--dry-run", "Write everything to a temp directory instead of --target-path", false) - .option("--no-source-scan", "Skip the informational source pre-scan") - .option("--no-adversarial-review", "Skip the LLM reviewer; deterministic scanners only") - .option("--no-extract", "Skip the in-source extract phase; assume --source is already a generic package and only run the sync to essentials") - .option("--no-fast-copy", "Always let the primary agent author the sync, even when the source scan is completely clean") - .option("--list-models", "Print the model catalog for the current API key and exit", false) - .parse(process.argv); +// The read-only subcommands need no API key and none of the sync options, so +// they are dispatched before the sync program is parsed. +const READ_ONLY_COMMANDS: Record<string, (argv: string[]) => Promise<number>> = { + scan: runScanCommand, + status: runStatusCommand, +}; + +const readOnlyCommand = READ_ONLY_COMMANDS[process.argv[2] ?? ""]; +if (readOnlyCommand) { + readOnlyCommand(process.argv.slice(3)).then( + (exitCode) => process.exit(exitCode), + (error: unknown) => fail(1, error instanceof Error ? error.message : String(error)), + ); +} else { + loadEnvFiles(); + void main(buildSyncProgram().parse(process.argv)); +} -void main(program); +function buildSyncProgram(): Command { + return new Command() + .name("essentials-sync") + .description( + "Extract a jargon-free ess-* shared package out of a team-/account-specific tool" + + " in the source repo, then sync that shared package to the open-source essentials repo." + + " Default behavior runs both phases; pass --no-extract for sources that are already generic.", + ) + .option("-s, --source <path>", "Absolute path to the source tool directory (tools/python/<name>/)") + .option("-r, --target-repo <path>", "Absolute path to the target repo root (must be a git working tree)") + .option("-t, --target-path <path>", "Path relative to --target-repo where the package should land (default: packages/python/<package-name>)") + .option("--source-repo <path>", "Source repo root (default: git root of --source)") + .option("--package-name <name>", "Name of the extracted ess-* package (default: derived from --source basename)") + .option("-m, --model <spec>", "Primary agent model id, family sentinel, or 'auto'") + .option("--review-model <spec>", "Adversarial reviewer model id, family sentinel, or 'auto'") + .option("--max-revisions <n>", "Maximum scan-and-revise iterations per phase", parseInteger, DEFAULT_MAX_REVISIONS) + .option("--dry-run", "Write everything to a temp directory instead of --target-path", false) + .option("--no-source-scan", "Skip the informational source pre-scan") + .option("--no-adversarial-review", "Skip the LLM reviewer; deterministic scanners only") + .option("--no-extract", "Skip the in-source extract phase; assume --source is already a generic package and only run the sync to essentials") + .option("--no-fast-copy", "Always let the primary agent author the sync, even when the source scan is completely clean") + .option("--list-models", "Print the model catalog for the current API key and exit", false) + .addHelpText( + "after", + "\nRead-only commands:\n" + + " essentials-sync scan <path>...\n" + + " Run the deterministic scanners; exits 1 on critical findings.\n" + + " essentials-sync status --source-repo <a> --target-repo <b>\n" + + " Compare every ess-* item in two repos.\n", + ); +} async function main(cmd: Command): Promise<void> { const opts = cmd.opts(); @@ -292,24 +317,6 @@ async function handleListModels(apiKey: string): Promise<void> { } } -async function loadJargonOverrides(sourceAbs: string): Promise<string[]> { - const { promises: fs } = await import("node:fs"); - const path = await import("node:path"); - const configPath = path.join(sourceAbs, ".essentials-sync-jargon.json"); - try { - const raw = await fs.readFile(configPath, "utf8"); - return parseJargonOverrides(JSON.parse(raw) as unknown).terms; - } catch (error) { - if (error instanceof Error && "code" in error && (error as { code?: string }).code === "ENOENT") { - return []; - } - console.warn( - chalk.yellow(`[jargon] could not read jargon overrides: ${(error as Error).message}`), - ); - return []; - } -} - async function prepareDryRunTarget(originalRelPath: string): Promise<{ repo: string; relPath: string }> { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); diff --git a/tools/typescript/essentials-sync/src/copy.ts b/tools/typescript/essentials-sync/src/copy.ts index 9587697..1629937 100644 --- a/tools/typescript/essentials-sync/src/copy.ts +++ b/tools/typescript/essentials-sync/src/copy.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { globby } from "globby"; -import { HARD_SKIP_DIRECTORIES, isUnscannableTextFile } from "./scanners/text-files.js"; +import { listPublishableFiles } from "./file-set.js"; +import { isUnscannableTextFile } from "./scanners/text-files.js"; export interface CopyTreeResult { filesCopied: string[]; @@ -15,28 +15,16 @@ export interface CopyTreeResult { // Copies sourceAbs into targetAbs without deleting anything already in the // target, so target-only files (LICENSE, NOTICE) survive a re-sync. // -// Traversal deliberately mirrors the scanners: .gitignore is honored and the -// same cache/build directories are skipped. That invariant is what makes the -// fast path safe -- every file this copies is a file the scanners inspected, so -// a clean scan really does cover the whole payload. Copying gitignored files -// would break it, since those are exactly the paths (.env, local credentials) -// the scanners never see. +// The file list is the same one the scanners read (listPublishableFiles). That +// invariant is what makes the fast path safe -- every file this copies is a +// file the scanners inspected, so a clean scan really does cover the whole +// payload. Copying gitignored files would break it, since those are exactly +// the paths (.env, local credentials) the scanners never see. export async function copyTreeVerbatim( sourceAbs: string, targetAbs: string, ): Promise<CopyTreeResult> { - // onlyFiles is off so symlinks come back as entries we can classify and - // report; with it on, globby drops them silently and the copy would be - // quietly incomplete. - const relPaths = await globby("**/*", { - cwd: sourceAbs, - dot: true, - gitignore: true, - onlyFiles: false, - followSymbolicLinks: false, - ignore: HARD_SKIP_DIRECTORIES.map((dir) => `**/${dir}/**`), - }); - relPaths.sort(); + const relPaths = await listPublishableFiles(sourceAbs); const filesCopied: string[] = []; const unscannedFiles: string[] = []; diff --git a/tools/typescript/essentials-sync/src/ess-items.ts b/tools/typescript/essentials-sync/src/ess-items.ts new file mode 100644 index 0000000..6f9c7c7 --- /dev/null +++ b/tools/typescript/essentials-sync/src/ess-items.ts @@ -0,0 +1,32 @@ +import { execa } from "execa"; + +// The ess-* item that contains repoRelPath (a repo-relative, "/"-separated +// path). An item is either a directory named ess-* (packages/python/ess-auth) +// or a file named ess-* directly inside a directory named ess +// (instructions/ess/ess-okf-docs.instructions.md). Other files that merely +// start with ess- (scripts/ess-scan.sh) are not items. Returns null when +// nothing matches, or when a dot-directory comes first (.agents/skills/ess-*, +// .cursor/rules/ess-*): those are installed copies of an item. +export function findEssItemRoot(repoRelPath: string): string | null { + const parts = repoRelPath.split("/"); + const lastIndex = parts.length - 1; + for (let index = 0; index <= lastIndex; index += 1) { + const part = parts[index] ?? ""; + if (part.startsWith(".")) return null; + if (!part.startsWith("ess-")) continue; + const isDirectory = index < lastIndex; + if (isDirectory || parts[index - 1] === "ess") { + return parts.slice(0, index + 1).join("/"); + } + } + return null; +} + +// Root of the git work tree containing directory, or null outside one. +export async function findGitRoot(directory: string): Promise<string | null> { + const result = await execa("git", ["rev-parse", "--show-toplevel"], { + cwd: directory, + reject: false, + }); + return result.exitCode === 0 ? result.stdout.trim() : null; +} diff --git a/tools/typescript/essentials-sync/src/file-set.ts b/tools/typescript/essentials-sync/src/file-set.ts new file mode 100644 index 0000000..5718aaa --- /dev/null +++ b/tools/typescript/essentials-sync/src/file-set.ts @@ -0,0 +1,106 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { execa } from "execa"; +import { globby } from "globby"; + +// Always skipped, even when the scanned tree has no .gitignore. These are +// caches, build output, and VCS/dependency directories that never carry +// meaningful findings. +export const HARD_SKIP_DIRECTORIES = [ + "node_modules", ".git", ".venv", "venv", "__pycache__", ".pytest_cache", + ".ruff_cache", ".mypy_cache", "dist", "build", ".pulumi", +]; + +// Working notes for the operator. Target repos gitignore `tmp[-.]*`, so these +// are never published; skipping them keeps scanning and copying in lockstep. +const WORKING_NOTE_PATTERN = /^tmp[-.]/; + +// Lists the files under rootAbs that would be published, as sorted paths +// relative to rootAbs. +// +// Scanners and the copier both read this list, so a clean scan covers exactly +// the files a copy ships. Inside a git work tree the list comes from +// `git ls-files`, which applies every ignore source git does: .gitignore files +// in ancestor directories, .git/info/exclude, and the global excludes file. +// globby only reads .gitignore files at or below its cwd, so a package-level +// walk misses rules in the repo root and would ship a root-ignored `.env`. +// Outside a work tree (fixtures, scratch copies) globby is the fallback. +// +// rootAbs may also be a single file. The result is then [""] when the file is +// publishable and [] when it is not, so path.join(rootAbs, entry) still names +// the file. +export async function listPublishableFiles(rootAbs: string): Promise<string[]> { + const stat = await fs.lstat(rootAbs); + if (!stat.isDirectory()) { + const name = path.basename(rootAbs); + const listed = (await listWithGit(path.dirname(rootAbs), name)) ?? [name]; + return listed.includes(name) && !isSkipped(name) ? [""] : []; + } + + const fromGit = await listWithGit(rootAbs, "."); + const candidates = fromGit ?? (await listWithGlobby(rootAbs)); + const kept = candidates.filter((relPath) => !isSkipped(relPath)); + return [...new Set(kept)].sort(); +} + +function isSkipped(relPath: string): boolean { + const parts = relPath.split("/"); + if (parts.some((part) => HARD_SKIP_DIRECTORIES.includes(part))) { + return true; + } + return WORKING_NOTE_PATTERN.test(parts[parts.length - 1] ?? ""); +} + +async function listWithGit(rootAbs: string, pathspec: string): Promise<string[] | null> { + const inside = await execa("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: rootAbs, + reject: false, + }); + if (inside.exitCode !== 0 || inside.stdout.trim() !== "true") { + return null; + } + + // --cached keeps tracked files even when an ignore rule would match them; + // --others --exclude-standard adds untracked files that are not ignored. + // Paths come back relative to cwd, which is rootAbs. + const listed = await execa( + "git", + ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", `:(literal)${pathspec}`], + { cwd: rootAbs }, + ); + const relPaths = listed.stdout.split("\0").filter((entry) => entry !== ""); + + // A tracked file deleted from the work tree is still in the index; it is + // not on disk, so there is nothing to scan or copy. + const present: string[] = []; + for (const relPath of relPaths) { + try { + await fs.lstat(path.join(rootAbs, relPath)); + present.push(relPath); + } catch { + // Deleted in the work tree. + } + } + return present; +} + +async function listWithGlobby(rootAbs: string): Promise<string[]> { + // onlyFiles is off so symlinks come back as entries the copier can classify + // and report; globby would otherwise drop them silently. + const entries = await globby("**/*", { + cwd: rootAbs, + dot: true, + gitignore: true, + onlyFiles: false, + followSymbolicLinks: false, + ignore: HARD_SKIP_DIRECTORIES.map((dir) => `**/${dir}/**`), + }); + const files: string[] = []; + for (const relPath of entries) { + const stat = await fs.lstat(path.join(rootAbs, relPath)); + if (!stat.isDirectory()) { + files.push(relPath); + } + } + return files; +} diff --git a/tools/typescript/essentials-sync/src/jargon-overrides.ts b/tools/typescript/essentials-sync/src/jargon-overrides.ts new file mode 100644 index 0000000..85f61a8 --- /dev/null +++ b/tools/typescript/essentials-sync/src/jargon-overrides.ts @@ -0,0 +1,25 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import chalk from "chalk"; +import { parseJargonOverrides } from "./jargon-list.js"; + +export const JARGON_OVERRIDES_FILE = ".essentials-sync-jargon.json"; + +// Reads extra jargon terms from <directory>/.essentials-sync-jargon.json. A +// missing file means no extra terms; an unreadable one is warned about and +// treated the same way so a typo never blocks a scan. +export async function loadJargonOverrides(directory: string): Promise<string[]> { + const configPath = path.join(directory, JARGON_OVERRIDES_FILE); + try { + const raw = await fs.readFile(configPath, "utf8"); + return parseJargonOverrides(JSON.parse(raw) as unknown).terms; + } catch (error) { + if (error instanceof Error && "code" in error && (error as { code?: string }).code === "ENOENT") { + return []; + } + console.warn( + chalk.yellow(`[jargon] could not read ${configPath}: ${(error as Error).message}`), + ); + return []; + } +} diff --git a/tools/typescript/essentials-sync/src/scan-command.ts b/tools/typescript/essentials-sync/src/scan-command.ts new file mode 100644 index 0000000..3461e67 --- /dev/null +++ b/tools/typescript/essentials-sync/src/scan-command.ts @@ -0,0 +1,108 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { Command } from "commander"; +import chalk from "chalk"; +import { runScanners } from "./scanners/index.js"; +import { findEssItemRoot, findGitRoot } from "./ess-items.js"; +import { loadJargonOverrides } from "./jargon-overrides.js"; +import type { Finding } from "./types.js"; + +// `essentials-sync scan <path>...`: runs the deterministic scanners and exits 1 +// on any critical finding. Read-only, so it never prompts and has no +// --dry-run. Returns the process exit code. +export async function runScanCommand(argv: string[], cwd = process.cwd()): Promise<number> { + const program = new Command("essentials-sync scan") + .description( + "Scan files or directories with the deterministic scanners (secrets, jargon, PII)." + + " A path inside an ess-* item scans the whole item. Exits 1 on critical findings.", + ) + .argument("<paths...>", "Files or directories to scan") + .option("--no-trufflehog", "Skip the trufflehog secret scanner") + .option("--no-secretlint", "Skip the secretlint secret scanner") + .parse(argv, { from: "user" }); + const opts = program.opts<{ trufflehog: boolean; secretlint: boolean }>(); + + const gitRoot = await findGitRoot(cwd); + const roots = await resolveScanRoots(program.args, cwd, gitRoot); + if (roots.length === 0) { + console.log("No existing paths to scan."); + return 0; + } + + const findings: Finding[] = []; + for (const root of roots) { + const report = await runScanners({ + rootPath: root, + jargonSeverity: "critical", + extraJargonTerms: await loadExtraTerms(root, gitRoot), + skipTrufflehog: !opts.trufflehog, + skipSecretlint: !opts.secretlint, + }); + findings.push(...report.findings); + } + + for (const finding of findings) { + console.log(formatScanFinding(finding, cwd)); + } + const critical = findings.filter((finding) => finding.severity === "critical").length; + const summary = `${critical} critical, ${findings.length - critical} warning finding(s) in ${roots.length} path(s).`; + console.log(critical > 0 ? chalk.red(summary) : chalk.green(summary)); + return critical > 0 ? 1 : 0; +} + +// Maps each path to what gets scanned: the enclosing ess-* item when there is +// one, else the path itself. Missing paths (files deleted in a commit) still +// map to their item, which is scanned if it exists. Roots nested inside +// another root are dropped. +export async function resolveScanRoots( + paths: string[], + cwd: string, + gitRoot: string | null, +): Promise<string[]> { + const candidates = new Set<string>(); + for (const given of paths) { + const abs = path.resolve(cwd, given); + const item = gitRoot ? findEssItemRoot(toRepoRelative(gitRoot, abs)) : null; + const root = item && gitRoot ? path.join(gitRoot, item) : abs; + if (await pathExists(root)) candidates.add(root); + } + const sorted = [...candidates].sort(); + return sorted.filter( + (root) => !sorted.some((other) => other !== root && root.startsWith(`${other}${path.sep}`)), + ); +} + +// Secret findings print the rule only. Scanner messages are masked, but the +// rule is all a reader needs to find the line, so the message is left out. +export function formatScanFinding(finding: Finding, cwd: string): string { + const location = `${path.relative(cwd, finding.file) || finding.file}:${finding.line}`; + const rule = finding.term ?? finding.type; + const detail = finding.type === "secret" ? "" : ` - ${finding.message}`; + return `${location} [${finding.severity}] ${finding.source} ${rule}${detail}`; +} + +async function loadExtraTerms(root: string, gitRoot: string | null): Promise<string[]> { + const directories = new Set<string>(); + if (gitRoot) directories.add(gitRoot); + if ((await fs.lstat(root)).isDirectory()) directories.add(root); + const terms = new Set<string>(); + for (const directory of directories) { + for (const term of await loadJargonOverrides(directory)) terms.add(term); + } + return [...terms]; +} + +function toRepoRelative(gitRoot: string, abs: string): string { + const relative = path.relative(gitRoot, abs); + if (relative.startsWith("..") || path.isAbsolute(relative)) return ""; + return relative.split(path.sep).join("/"); +} + +async function pathExists(target: string): Promise<boolean> { + try { + await fs.lstat(target); + return true; + } catch { + return false; + } +} diff --git a/tools/typescript/essentials-sync/src/scanners/pii.ts b/tools/typescript/essentials-sync/src/scanners/pii.ts index d6ee707..2396491 100644 --- a/tools/typescript/essentials-sync/src/scanners/pii.ts +++ b/tools/typescript/essentials-sync/src/scanners/pii.ts @@ -15,9 +15,14 @@ interface PiiPattern { // (`PLR0913`) in the wild. const PII_PATTERNS: readonly PiiPattern[] = [ { + // Allows example.com, example.net, example.org, and any subdomain of them + // (bob@corp.example.com). The domain must end there, so a lookalike such + // as x@example.com.evil.org is still flagged. Other reserved names + // (.example, .invalid, .test) are flagged on purpose: placeholders use + // example.com everywhere. name: "email", - regex: /\b[A-Za-z0-9._%+-]+@(?!example\.(?:com|org|net))[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, - message: "Email address that is not on a documented example domain.", + regex: /\b[A-Za-z0-9._%+-]+@(?!(?:[A-Za-z0-9-]+\.)*example\.(?:com|org|net)(?!\.?[A-Za-z0-9-]))[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, + message: "Email address that is not on example.com, example.net, or example.org.", }, { name: "phone", diff --git a/tools/typescript/essentials-sync/src/scanners/secretlint.ts b/tools/typescript/essentials-sync/src/scanners/secretlint.ts index a5f6842..88197a4 100644 --- a/tools/typescript/essentials-sync/src/scanners/secretlint.ts +++ b/tools/typescript/essentials-sync/src/scanners/secretlint.ts @@ -1,6 +1,10 @@ import path from "node:path"; import { execa } from "execa"; import type { Finding } from "../types.js"; +import { listPublishableFiles } from "../file-set.js"; + +// Keeps each secretlint invocation's argv well under the OS limit. +const FILES_PER_INVOCATION = 500; interface SecretlintJsonResult { filePath: string; @@ -22,7 +26,23 @@ export async function scanWithSecretlint( ): Promise<Finding[]> { const packageRoot = packageRootPath(); const configPath = options.configPath ?? path.join(packageRoot, ".secretlintrc.json"); + // Pass the publishable files rather than a glob over rootPath, which would + // also read gitignored files that never ship. + const files = (await listPublishableFiles(rootPath)).map((relPath) => path.join(rootPath, relPath)); + const findings: Finding[] = []; + for (let start = 0; start < files.length; start += FILES_PER_INVOCATION) { + const batch = files.slice(start, start + FILES_PER_INVOCATION); + findings.push(...(await runSecretlint(packageRoot, configPath, batch))); + } + return findings; +} + +async function runSecretlint( + packageRoot: string, + configPath: string, + files: string[], +): Promise<Finding[]> { let result; try { result = await execa( @@ -34,7 +54,8 @@ export async function scanWithSecretlint( configPath, "--format", "json", - `${rootPath}/**/*`, + "--maskSecrets", + ...files, ], { reject: false, diff --git a/tools/typescript/essentials-sync/src/scanners/text-files.ts b/tools/typescript/essentials-sync/src/scanners/text-files.ts index ab1d789..0d45cfb 100644 --- a/tools/typescript/essentials-sync/src/scanners/text-files.ts +++ b/tools/typescript/essentials-sync/src/scanners/text-files.ts @@ -1,15 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { globbyStream } from "globby"; import type { Finding } from "../types.js"; - -// Always skipped, even when the scanned tree has no .gitignore. These are -// caches, build output, and VCS/dependency directories that never carry -// meaningful findings. -export const HARD_SKIP_DIRECTORIES = [ - "node_modules", ".git", ".venv", "venv", "__pycache__", ".pytest_cache", - ".ruff_cache", ".mypy_cache", "dist", "build", ".pulumi", -]; +import { listPublishableFiles } from "../file-set.js"; const BINARY_EXTENSIONS = [ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".zip", ".tar", ".gz", @@ -30,28 +22,14 @@ export async function isUnscannableTextFile(filePath: string): Promise<boolean> return stat.size > MAX_FILE_BYTES; } -// Yields absolute paths of candidate text files under rootPath. Traversal, -// .gitignore handling, and directory/extension skipping are delegated to -// globby; the only thing globby cannot express is the per-file size cap, so -// oversized files are filtered with a stat after globbing. +// Yields absolute paths of the publishable text files under rootPath: the +// shared file set minus symlinks, binary extensions, and oversized files. export async function* walkTextFiles(rootPath: string): AsyncGenerator<string> { - const ignore = [ - ...HARD_SKIP_DIRECTORIES.map((dir) => `**/${dir}/**`), - ...BINARY_EXTENSIONS.map((ext) => `**/*${ext}`), - "**/tmp-*", - "**/tmp.*", - ]; - const stream = globbyStream("**/*", { - cwd: rootPath, - absolute: true, - dot: true, - gitignore: true, - ignore, - }); - for await (const entry of stream) { - const filePath = entry as string; - const stat = await fs.stat(filePath); - if (stat.size > MAX_FILE_BYTES) continue; + for (const relPath of await listPublishableFiles(rootPath)) { + const filePath = path.join(rootPath, relPath); + const stat = await fs.lstat(filePath); + if (!stat.isFile()) continue; + if (await isUnscannableTextFile(filePath)) continue; yield filePath; } } diff --git a/tools/typescript/essentials-sync/src/scanners/trufflehog.ts b/tools/typescript/essentials-sync/src/scanners/trufflehog.ts index 9dbb4ab..f8c056b 100644 --- a/tools/typescript/essentials-sync/src/scanners/trufflehog.ts +++ b/tools/typescript/essentials-sync/src/scanners/trufflehog.ts @@ -1,5 +1,10 @@ +import path from "node:path"; import { execa } from "execa"; import type { Finding } from "../types.js"; +import { listPublishableFiles } from "../file-set.js"; + +// Keeps each trufflehog invocation's argv well under the OS limit. +const FILES_PER_INVOCATION = 500; export interface TrufflehogOptions { binary?: string; @@ -24,12 +29,24 @@ export async function scanWithTrufflehog( options: TrufflehogOptions = {}, ): Promise<Finding[]> { const binary = options.binary ?? "trufflehog"; + // Scan the publishable files rather than the directory: a directory scan + // reads gitignored files (.env, .venv) that never ship. + const files = (await listPublishableFiles(rootPath)).map((relPath) => path.join(rootPath, relPath)); + + const findings: Finding[] = []; + for (let start = 0; start < files.length; start += FILES_PER_INVOCATION) { + const batch = files.slice(start, start + FILES_PER_INVOCATION); + findings.push(...(await runTrufflehog(binary, batch))); + } + return findings; +} +async function runTrufflehog(binary: string, files: string[]): Promise<Finding[]> { let result; try { result = await execa( binary, - ["filesystem", rootPath, "--json", "--no-update"], + ["filesystem", ...files, "--json", "--no-update"], { reject: false, stripFinalNewline: false, diff --git a/tools/typescript/essentials-sync/src/status-command.ts b/tools/typescript/essentials-sync/src/status-command.ts new file mode 100644 index 0000000..a66cc7b --- /dev/null +++ b/tools/typescript/essentials-sync/src/status-command.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { Command } from "commander"; +import chalk from "chalk"; +import { findEssItemRoot } from "./ess-items.js"; +import { listPublishableFiles } from "./file-set.js"; + +export type ItemState = "identical" | "differs" | "only in source" | "only in target"; +export type FileState = "changed" | "only in source" | "only in target"; + +export interface ItemStatus { + item: string; + state: ItemState; + files: Array<{ path: string; state: FileState }>; +} + +// Each item's own LICENSE is allowed to differ: the open-source copy carries +// the license text, the source repo may not. +const IGNORED_ITEM_FILES = new Set(["LICENSE"]); + +// `essentials-sync status`: compares every ess-* item in two repos, file by +// file. Read-only, so it never prompts and has no --dry-run. Returns the +// process exit code. +export async function runStatusCommand(argv: string[]): Promise<number> { + const program = new Command("essentials-sync status") + .description( + "Report whether each ess-* item is identical in two repos. Compares the" + + " publishable files (git ls-files, not ignored) by content and executable bit," + + " skipping each item's LICENSE.", + ) + .requiredOption("--source-repo <path>", "Root of the first repo") + .requiredOption("--target-repo <path>", "Root of the second repo") + .parse(argv, { from: "user" }); + const opts = program.opts<{ sourceRepo: string; targetRepo: string }>(); + + const statuses = await compareEssItems(path.resolve(opts.sourceRepo), path.resolve(opts.targetRepo)); + for (const status of statuses) { + console.log(formatItemStatus(status)); + } + const identical = statuses.filter((status) => status.state === "identical").length; + console.log(chalk.dim(`${identical} of ${statuses.length} item(s) identical.`)); + return 0; +} + +export async function compareEssItems(sourceRepo: string, targetRepo: string): Promise<ItemStatus[]> { + const [source, target] = await Promise.all([indexEssItems(sourceRepo), indexEssItems(targetRepo)]); + const items = [...new Set([...source.keys(), ...target.keys()])].sort(); + return items.map((item) => compareItem(item, source.get(item), target.get(item))); +} + +function compareItem( + item: string, + source: Map<string, string> | undefined, + target: Map<string, string> | undefined, +): ItemStatus { + if (!target) return { item, state: "only in source", files: [] }; + if (!source) return { item, state: "only in target", files: [] }; + + const files: ItemStatus["files"] = []; + for (const file of [...new Set([...source.keys(), ...target.keys()])].sort()) { + const sourceHash = source.get(file); + const targetHash = target.get(file); + if (sourceHash === undefined) files.push({ path: file, state: "only in target" }); + else if (targetHash === undefined) files.push({ path: file, state: "only in source" }); + else if (sourceHash !== targetHash) files.push({ path: file, state: "changed" }); + } + return { item, state: files.length === 0 ? "identical" : "differs", files }; +} + +// Maps each ess-* item in the repo to its files (relative to the item; "" for +// an item that is a single file) and a fingerprint of each file. +async function indexEssItems(repoRoot: string): Promise<Map<string, Map<string, string>>> { + const items = new Map<string, Map<string, string>>(); + for (const relPath of await listPublishableFiles(repoRoot)) { + const item = findEssItemRoot(relPath); + if (!item) continue; + const inner = relPath === item ? "" : relPath.slice(item.length + 1); + if (IGNORED_ITEM_FILES.has(inner)) continue; + + let files = items.get(item); + if (!files) { + files = new Map(); + items.set(item, files); + } + files.set(inner, await fingerprint(path.join(repoRoot, relPath))); + } + return items; +} + +async function fingerprint(filePath: string): Promise<string> { + const stat = await fs.lstat(filePath); + if (stat.isSymbolicLink()) return `symlink:${await fs.readlink(filePath)}`; + const digest = createHash("sha256").update(await fs.readFile(filePath)).digest("hex"); + return (stat.mode & 0o111) !== 0 ? `${digest}:x` : digest; +} + +export function formatItemStatus(status: ItemStatus): string { + const color = status.state === "identical" ? chalk.green : chalk.yellow; + const lines = [`${color(status.state.padEnd(15))} ${status.item}`]; + for (const file of status.files) { + lines.push(` ${file.state.padEnd(15)} ${file.path || path.basename(status.item)}`); + } + return lines.join("\n"); +} diff --git a/tools/typescript/essentials-sync/tests/file-set.test.ts b/tools/typescript/essentials-sync/tests/file-set.test.ts new file mode 100644 index 0000000..1e70d89 --- /dev/null +++ b/tools/typescript/essentials-sync/tests/file-set.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { execa } from "execa"; +import { listPublishableFiles } from "../src/file-set.js"; +import { copyTreeVerbatim } from "../src/copy.js"; +import { scanPii } from "../src/scanners/pii.js"; + +const tempRoots: string[] = []; + +async function makeRepo(files: Record<string, string>): Promise<string> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "file-set-test-")); + tempRoots.push(root); + await execa("git", ["init", "--quiet"], { cwd: root }); + for (const [relPath, contents] of Object.entries(files)) { + const abs = path.join(root, relPath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, contents, "utf8"); + } + return root; +} + +afterEach(async () => { + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) await fs.rm(root, { recursive: true, force: true }); + } +}); + +describe("listPublishableFiles", () => { + // globby reads .gitignore files at or below its cwd only, so a rule in the + // repo root used to be invisible when the package directory was scanned. + it("honors .gitignore rules from the repo root", async () => { + const repo = await makeRepo({ + ".gitignore": ".env\n", + "pkg/README.md": "# Package\n", + "pkg/.env": "OWNER=jdoe@corp.internal.net\n", + }); + + const files = await listPublishableFiles(path.join(repo, "pkg")); + + expect(files).toEqual(["README.md"]); + }); + + it("keeps tracked and untracked files that are not ignored", async () => { + const repo = await makeRepo({ "pkg/tracked.md": "a\n", "pkg/new.md": "b\n" }); + await execa("git", ["add", "pkg/tracked.md"], { cwd: repo }); + + const files = await listPublishableFiles(path.join(repo, "pkg")); + + expect(files).toEqual(["new.md", "tracked.md"]); + }); + + it("drops working notes and cache directories", async () => { + const repo = await makeRepo({ + "pkg/SKILL.md": "# Skill\n", + "pkg/tmp-RECOMMENDATIONS.md": "notes\n", + "pkg/scripts/__pycache__/x.pyc": "bytecode\n", + }); + + const files = await listPublishableFiles(path.join(repo, "pkg")); + + expect(files).toEqual(["SKILL.md"]); + }); + + it("does not scan or copy a file ignored only by the repo root", async () => { + const repo = await makeRepo({ + ".gitignore": ".env\n", + "pkg/README.md": "# Package\n", + "pkg/.env": "OWNER=jdoe@corp.internal.net\n", + }); + const pkg = path.join(repo, "pkg"); + const target = await fs.mkdtemp(path.join(os.tmpdir(), "file-set-target-")); + tempRoots.push(target); + + expect(await scanPii(pkg, { severity: "critical" })).toEqual([]); + const result = await copyTreeVerbatim(pkg, target); + + expect(result.filesCopied).toEqual(["README.md"]); + await expect(fs.stat(path.join(target, ".env"))).rejects.toThrow(); + }); +}); diff --git a/tools/typescript/essentials-sync/tests/read-only-commands.test.ts b/tools/typescript/essentials-sync/tests/read-only-commands.test.ts new file mode 100644 index 0000000..003fb59 --- /dev/null +++ b/tools/typescript/essentials-sync/tests/read-only-commands.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { execa } from "execa"; +import { findEssItemRoot } from "../src/ess-items.js"; +import { formatScanFinding, resolveScanRoots, runScanCommand } from "../src/scan-command.js"; +import { compareEssItems } from "../src/status-command.js"; + +const tempRoots: string[] = []; + +async function makeRepo(files: Record<string, string>): Promise<string> { + // realpath: git reports /private/var/... for macOS temp dirs under /var. + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "read-only-test-"))); + tempRoots.push(root); + await execa("git", ["init", "--quiet"], { cwd: root }); + for (const [relPath, contents] of Object.entries(files)) { + const abs = path.join(root, relPath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, contents, "utf8"); + } + return root; +} + +afterEach(async () => { + vi.restoreAllMocks(); + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) await fs.rm(root, { recursive: true, force: true }); + } +}); + +describe("findEssItemRoot", () => { + it.each([ + ["packages/python/ess-auth/src/ess_auth/x.py", "packages/python/ess-auth"], + ["skills/ess/ess-git-tidy/SKILL.md", "skills/ess/ess-git-tidy"], + ["instructions/ess/ess-okf-docs.instructions.md", "instructions/ess/ess-okf-docs.instructions.md"], + [".agents/skills/ess-git-tidy/SKILL.md", null], + ["tools/typescript/essentials-sync/scripts/ess-scan.sh", null], + ["plans/ess-verbatim-sync.md", null], + ["tools/python/langsmith-onboarding/README.md", null], + ])("%s -> %s", (relPath, expected) => { + expect(findEssItemRoot(relPath)).toBe(expected); + }); +}); + +describe("scan", () => { + it("scans the whole ess-* item that contains a changed file", async () => { + const repo = await makeRepo({ + "packages/python/ess-demo/README.md": "# Demo\n", + "packages/python/ess-demo/src/a.py": "x = 1\n", + "instructions/ess/ess-rule.instructions.md": "# Rule\n", + "notes/plain.md": "# Plain\n", + }); + + const roots = await resolveScanRoots( + ["packages/python/ess-demo/src/a.py", "packages/python/ess-demo/README.md", "instructions/ess/ess-rule.instructions.md", "notes/plain.md"], + repo, + repo, + ); + + expect(roots).toEqual([ + path.join(repo, "instructions/ess/ess-rule.instructions.md"), + path.join(repo, "notes/plain.md"), + path.join(repo, "packages/python/ess-demo"), + ]); + }); + + it("exits 1 on critical findings and 0 when clean", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const repo = await makeRepo({ + "packages/python/ess-clean/README.md": "Contact bob@corp.example.com\n", + "packages/python/ess-dirty/README.md": "Contact bob@corp.internal.net\n", + }); + const skipSecretScanners = ["--no-trufflehog", "--no-secretlint"]; + + expect(await runScanCommand(["packages/python/ess-clean", ...skipSecretScanners], repo)).toBe(0); + expect(await runScanCommand(["packages/python/ess-dirty/README.md", ...skipSecretScanners], repo)).toBe(1); + }); + + it("loads extra jargon terms from the repo root", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const repo = await makeRepo({ + ".essentials-sync-jargon.json": JSON.stringify({ terms: ["widgetco"] }), + "skills/ess/ess-demo/SKILL.md": "Ask the WidgetCo help desk.\n", + }); + + expect(await runScanCommand(["skills/ess/ess-demo/SKILL.md", "--no-trufflehog", "--no-secretlint"], repo)).toBe(1); + }); + + it("prints only the rule for secret findings", () => { + const line = formatScanFinding( + { + file: "/repo/pkg/config.py", + line: 3, + type: "secret", + severity: "critical", + source: "secretlint", + message: "found token: ***", + term: "@secretlint/secretlint-rule-github", + }, + "/repo", + ); + + expect(line).toBe("pkg/config.py:3 [critical] secretlint @secretlint/secretlint-rule-github"); + }); +}); + +describe("status", () => { + it("classifies every ess-* item in either repo", async () => { + const source = await makeRepo({ + ".gitignore": ".env\n", + "skills/ess/ess-same/SKILL.md": "same\n", + "skills/ess/ess-same/.env": "ignored in source\n", + "skills/ess/ess-changed/SKILL.md": "new\n", + "packages/python/ess-new/README.md": "new\n", + "instructions/ess/ess-rule.instructions.md": "rule\n", + ".agents/skills/ess-same/SKILL.md": "installed copy\n", + }); + const target = await makeRepo({ + "skills/ess/ess-same/SKILL.md": "same\n", + "skills/ess/ess-same/LICENSE": "Apache-2.0\n", + "skills/ess/ess-changed/SKILL.md": "old\n", + "skills/ess/ess-changed/extra.md": "extra\n", + "instructions/ess/ess-rule.instructions.md": "rule\n", + "skills/ess/ess-legacy/SKILL.md": "legacy\n", + }); + + const statuses = await compareEssItems(source, target); + + expect(statuses).toEqual([ + { item: "instructions/ess/ess-rule.instructions.md", state: "identical", files: [] }, + { item: "packages/python/ess-new", state: "only in source", files: [] }, + { + item: "skills/ess/ess-changed", + state: "differs", + files: [ + { path: "SKILL.md", state: "changed" }, + { path: "extra.md", state: "only in target" }, + ], + }, + { item: "skills/ess/ess-legacy", state: "only in target", files: [] }, + { item: "skills/ess/ess-same", state: "identical", files: [] }, + ]); + }); + + it("treats a lost executable bit as a change", async () => { + const source = await makeRepo({ "skills/ess/ess-x/run.sh": "echo hi\n" }); + const target = await makeRepo({ "skills/ess/ess-x/run.sh": "echo hi\n" }); + await fs.chmod(path.join(source, "skills/ess/ess-x/run.sh"), 0o755); + + const [status] = await compareEssItems(source, target); + + expect(status?.state).toBe("differs"); + }); +}); diff --git a/tools/typescript/essentials-sync/tests/scanners.test.ts b/tools/typescript/essentials-sync/tests/scanners.test.ts index edc8fe6..0c89eb9 100644 --- a/tools/typescript/essentials-sync/tests/scanners.test.ts +++ b/tools/typescript/essentials-sync/tests/scanners.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { promises as fs } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { scanJargon } from "../src/scanners/jargon.js"; @@ -37,4 +39,35 @@ describe("pii scanner", () => { const findings = await scanPii(dirtyFixture, { severity: "critical" }); expect(findings.some((f) => f.term === "email")).toBe(true); }); + + it.each([ + "bob@example.com", + "bob@example.org", + "bob@corp.example.com", + "ada@a.b.example.net", + "Contact bob@example.com.", + ])("allows %s", async (line) => { + expect(await emailFindings(line)).toBe(0); + }); + + it.each([ + "bob@corp.example", + "x@example.invalid", + "x@host.com", + "x@example.com.evil.org", + "x@notexample.com", + ])("flags %s", async (line) => { + expect(await emailFindings(line)).toBe(1); + }); }); + +async function emailFindings(line: string): Promise<number> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pii-test-")); + try { + await fs.writeFile(path.join(root, "sample.md"), `${line}\n`, "utf8"); + const findings = await scanPii(root, { severity: "critical" }); + return findings.filter((f) => f.term === "email").length; + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} From acc3247075cca671b360339b196695bb53b8237b Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 09:39:17 -0500 Subject: [PATCH 03/20] fix(essentials-sync): match example.com in any case Domains are case-insensitive, so Alice.Smith@Example.COM is a placeholder too. Tests that check case handling no longer need to lowercase their domains. --- tools/typescript/essentials-sync/src/scanners/pii.ts | 10 +++++----- .../typescript/essentials-sync/tests/scanners.test.ts | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/typescript/essentials-sync/src/scanners/pii.ts b/tools/typescript/essentials-sync/src/scanners/pii.ts index 2396491..37e2faa 100644 --- a/tools/typescript/essentials-sync/src/scanners/pii.ts +++ b/tools/typescript/essentials-sync/src/scanners/pii.ts @@ -16,12 +16,12 @@ interface PiiPattern { const PII_PATTERNS: readonly PiiPattern[] = [ { // Allows example.com, example.net, example.org, and any subdomain of them - // (bob@corp.example.com). The domain must end there, so a lookalike such - // as x@example.com.evil.org is still flagged. Other reserved names - // (.example, .invalid, .test) are flagged on purpose: placeholders use - // example.com everywhere. + // (bob@corp.example.com), in any case. The domain must end there, so a + // lookalike such as x@example.com.evil.org is still flagged. Other + // reserved names (.example, .invalid, .test) are flagged on purpose: + // placeholders use example.com everywhere. name: "email", - regex: /\b[A-Za-z0-9._%+-]+@(?!(?:[A-Za-z0-9-]+\.)*example\.(?:com|org|net)(?!\.?[A-Za-z0-9-]))[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, + regex: /\b[A-Za-z0-9._%+-]+@(?!(?:[A-Za-z0-9-]+\.)*example\.(?:com|org|net)(?!\.?[A-Za-z0-9-]))[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi, message: "Email address that is not on example.com, example.net, or example.org.", }, { diff --git a/tools/typescript/essentials-sync/tests/scanners.test.ts b/tools/typescript/essentials-sync/tests/scanners.test.ts index 0c89eb9..b5b7fde 100644 --- a/tools/typescript/essentials-sync/tests/scanners.test.ts +++ b/tools/typescript/essentials-sync/tests/scanners.test.ts @@ -45,6 +45,7 @@ describe("pii scanner", () => { "bob@example.org", "bob@corp.example.com", "ada@a.b.example.net", + "Alice.Smith@Example.COM", "Contact bob@example.com.", ])("allows %s", async (line) => { expect(await emailFindings(line)).toBe(0); From ec3d6d264241ee45eaa6dcefdf9ad05da16b56e2 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 10:39:52 -0500 Subject: [PATCH 04/20] fix(essentials-sync): allow example.edu emails IANA reserves example.edu for documentation alongside example.com, .net, and .org, so the PII scanner now allows it too. Registrable lookalikes such as example.io are still flagged. --- .../essentials-sync/src/scanners/pii.ts | 15 ++++++++------- .../essentials-sync/tests/scanners.test.ts | 2 ++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tools/typescript/essentials-sync/src/scanners/pii.ts b/tools/typescript/essentials-sync/src/scanners/pii.ts index 37e2faa..700d5c2 100644 --- a/tools/typescript/essentials-sync/src/scanners/pii.ts +++ b/tools/typescript/essentials-sync/src/scanners/pii.ts @@ -15,14 +15,15 @@ interface PiiPattern { // (`PLR0913`) in the wild. const PII_PATTERNS: readonly PiiPattern[] = [ { - // Allows example.com, example.net, example.org, and any subdomain of them - // (bob@corp.example.com), in any case. The domain must end there, so a - // lookalike such as x@example.com.evil.org is still flagged. Other - // reserved names (.example, .invalid, .test) are flagged on purpose: - // placeholders use example.com everywhere. + // Allows the four domains IANA reserves for documentation (example.com, + // .net, .org, .edu) and any subdomain of them (bob@corp.example.com), in + // any case. The domain must end there, so a lookalike such as + // x@example.com.evil.org is still flagged. Other reserved names (.example, + // .invalid, .test) and registrable ones (example.io) are flagged on + // purpose: placeholders stick to the four. name: "email", - regex: /\b[A-Za-z0-9._%+-]+@(?!(?:[A-Za-z0-9-]+\.)*example\.(?:com|org|net)(?!\.?[A-Za-z0-9-]))[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi, - message: "Email address that is not on example.com, example.net, or example.org.", + regex: /\b[A-Za-z0-9._%+-]+@(?!(?:[A-Za-z0-9-]+\.)*example\.(?:com|org|net|edu)(?!\.?[A-Za-z0-9-]))[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi, + message: "Email address that is not on example.com, example.net, example.org, or example.edu.", }, { name: "phone", diff --git a/tools/typescript/essentials-sync/tests/scanners.test.ts b/tools/typescript/essentials-sync/tests/scanners.test.ts index b5b7fde..b0ede33 100644 --- a/tools/typescript/essentials-sync/tests/scanners.test.ts +++ b/tools/typescript/essentials-sync/tests/scanners.test.ts @@ -45,6 +45,7 @@ describe("pii scanner", () => { "bob@example.org", "bob@corp.example.com", "ada@a.b.example.net", + "dean@registrar.example.edu", "Alice.Smith@Example.COM", "Contact bob@example.com.", ])("allows %s", async (line) => { @@ -57,6 +58,7 @@ describe("pii scanner", () => { "x@host.com", "x@example.com.evil.org", "x@notexample.com", + "x@example.io", ])("flags %s", async (line) => { expect(await emailFindings(line)).toBe(1); }); From 6e23974a52905be37bd4ae3027a8ebacfeb7877b Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 10:49:18 -0500 Subject: [PATCH 05/20] fix(essentials-sync): run secretlint on scan root secretlint ran from the tool directory and rejected every path outside it with exit 2, which the scanner read as no findings, so it never scanned another repo. It now runs in the scanned directory on relative paths, and any exit code other than 0 or 1 is reported as a scanner error. --- .../src/scanners/secretlint.ts | 45 ++++++++++++------- .../essentials-sync/tests/scanners.test.ts | 24 ++++++++++ 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/tools/typescript/essentials-sync/src/scanners/secretlint.ts b/tools/typescript/essentials-sync/src/scanners/secretlint.ts index 88197a4..49aed6b 100644 --- a/tools/typescript/essentials-sync/src/scanners/secretlint.ts +++ b/tools/typescript/essentials-sync/src/scanners/secretlint.ts @@ -1,3 +1,4 @@ +import { promises as fs } from "node:fs"; import path from "node:path"; import { execa } from "execa"; import type { Finding } from "../types.js"; @@ -27,13 +28,18 @@ export async function scanWithSecretlint( const packageRoot = packageRootPath(); const configPath = options.configPath ?? path.join(packageRoot, ".secretlintrc.json"); // Pass the publishable files rather than a glob over rootPath, which would - // also read gitignored files that never ship. - const files = (await listPublishableFiles(rootPath)).map((relPath) => path.join(rootPath, relPath)); + // also read gitignored files that never ship. secretlint rejects any path + // outside its cwd, so it runs in the scanned directory on relative paths. + const isFile = !(await fs.lstat(rootPath)).isDirectory(); + const cwd = isFile ? path.dirname(rootPath) : rootPath; + const files = (await listPublishableFiles(rootPath)).map((relPath) => + isFile ? path.basename(rootPath) : relPath, + ); const findings: Finding[] = []; for (let start = 0; start < files.length; start += FILES_PER_INVOCATION) { const batch = files.slice(start, start + FILES_PER_INVOCATION); - findings.push(...(await runSecretlint(packageRoot, configPath, batch))); + findings.push(...(await runSecretlint(packageRoot, configPath, cwd, batch))); } return findings; } @@ -41,25 +47,19 @@ export async function scanWithSecretlint( async function runSecretlint( packageRoot: string, configPath: string, + cwd: string, files: string[], ): Promise<Finding[]> { let result; try { result = await execa( - "npx", - [ - "--no-install", - "secretlint", - "--secretlintrc", - configPath, - "--format", - "json", - "--maskSecrets", - ...files, - ], + "secretlint", + ["--secretlintrc", configPath, "--format", "json", "--maskSecrets", ...files], { reject: false, - cwd: packageRoot, + cwd, + preferLocal: true, + localDir: packageRoot, }, ); } catch (error) { @@ -71,7 +71,20 @@ async function runSecretlint( throw error; } - return parseSecretlintOutput(result.stdout ?? ""); + // 0 is clean and 1 is findings. Anything else means secretlint never + // scanned, which must not read as a clean result. + if (result.exitCode !== 0 && result.exitCode !== 1) { + const reason = (result.stderr || result.stdout || "").trim().split("\n")[0]; + throw new Error(`secretlint exited ${result.exitCode}: ${reason}`); + } + + // secretlint reports real paths, which differ from cwd under a symlinked + // directory such as macOS /tmp. + const realCwd = await fs.realpath(cwd); + return parseSecretlintOutput(result.stdout ?? "").map((finding) => ({ + ...finding, + file: path.join(cwd, path.relative(realCwd, finding.file)), + })); } function isMissingBinaryError(error: unknown): boolean { diff --git a/tools/typescript/essentials-sync/tests/scanners.test.ts b/tools/typescript/essentials-sync/tests/scanners.test.ts index b0ede33..26654d2 100644 --- a/tools/typescript/essentials-sync/tests/scanners.test.ts +++ b/tools/typescript/essentials-sync/tests/scanners.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; +import { generateKeyPairSync } from "node:crypto"; import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { scanJargon } from "../src/scanners/jargon.js"; import { scanPii } from "../src/scanners/pii.js"; +import { scanWithSecretlint } from "../src/scanners/secretlint.js"; const here = path.dirname(fileURLToPath(import.meta.url)); const cleanFixture = path.join(here, "fixtures", "clean-package"); @@ -64,6 +66,28 @@ describe("pii scanner", () => { }); }); +describe("secretlint scanner", () => { + // A key generated per run, in a directory outside this package: secretlint + // once ran from the package directory and silently skipped such paths. + it.each([ + ["a directory", (dir: string) => dir], + ["a single file", (dir: string) => path.join(dir, "key.pem")], + ])("flags a private key in %s outside the package", async (_label, pickRoot) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "secretlint-test-")); + try { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + await fs.writeFile(path.join(dir, "key.pem"), pem, "utf8"); + const findings = await scanWithSecretlint(pickRoot(dir)); + expect(findings.map((f) => [f.file, f.term])).toEqual([ + [path.join(dir, "key.pem"), "@secretlint/secretlint-rule-privatekey"], + ]); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); +}); + async function emailFindings(line: string): Promise<number> { const root = await fs.mkdtemp(path.join(os.tmpdir(), "pii-test-")); try { From a82dc74acce83d1db2f2ddcee14329eb7236040a Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 10:49:32 -0500 Subject: [PATCH 06/20] build: install ess-scan hook from package.json A root package.json lets the ess-scan hook use pre-commit's node language. It carries only the scanners' dependencies, so the hook no longer installs @cursor/sdk, and a scan-only entry point replaces the shell launcher that built the whole tool on first run. --- .gitignore | 3 + .pre-commit-hooks.yaml | 7 +- package-lock.json | 1649 +++++++++++++++++ package.json | 30 + tools/typescript/essentials-sync/.gitignore | 1 - tools/typescript/essentials-sync/README.md | 2 +- .../essentials-sync/scripts/ess-scan.sh | 18 - .../essentials-sync/src/ess-items.ts | 2 +- .../essentials-sync/src/ess-scan.ts | 13 + .../tests/read-only-commands.test.ts | 2 +- 10 files changed, 1703 insertions(+), 24 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json delete mode 100755 tools/typescript/essentials-sync/scripts/ess-scan.sh create mode 100644 tools/typescript/essentials-sync/src/ess-scan.ts diff --git a/.gitignore b/.gitignore index 4d3dc49..ec5a1a6 100644 --- a/.gitignore +++ b/.gitignore @@ -272,6 +272,9 @@ Pulumi.*.yaml # Output files output/ +# Node.js +node_modules/ + # Claude .claude/settings.json diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index d0fcd18..86518bf 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -4,7 +4,10 @@ Runs the essentials-sync deterministic scanners on each changed ess-* item and fails on critical findings. Needs Node.js 22 or later; trufflehog is used when installed. - entry: tools/typescript/essentials-sync/scripts/ess-scan.sh - language: script + entry: ess-scan + language: node + # Use the Node on PATH. Otherwise pre-commit skips a Node under $HOME (nvm) + # and downloads one through nodeenv, which fails on prerelease versions. + language_version: system files: '(^|/)ess-[^/]+/|(^|/)ess/ess-[^/]+$' require_serial: true diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5a35853 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1649 @@ +{ + "name": "essentials", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "essentials", + "version": "0.0.0", + "hasInstallScript": true, + "dependencies": { + "@secretlint/secretlint-rule-preset-recommend": "^9.0.0", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "execa": "^9.5.1", + "globby": "^14.1.0", + "secretlint": "^9.0.0" + }, + "bin": { + "ess-scan": "tools/typescript/essentials-sync/dist/ess-scan.js" + }, + "devDependencies": { + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@secretlint/config-creator": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-9.3.4.tgz", + "integrity": "sha512-GRMYfHJ+rewwB26CC3USVObqSQ/mDLXzXcUMJw/wJisPr3HDZmdsYlcsNnaAcGN+EZmvqSDkgSibQm1hyZpzbg==", + "license": "MIT", + "dependencies": { + "@secretlint/types": "^9.3.4" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-9.3.4.tgz", + "integrity": "sha512-sy+yWDWh4cbAbpQYLiO39DjwNGEK1EUhTqNamLLBo163BdJP10FIWhqpe8mtGQBSBXRtxr8Hg/gc3Xe4meIoww==", + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^9.3.4", + "@secretlint/resolver": "^9.3.4", + "@secretlint/types": "^9.3.4", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/core": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-9.3.4.tgz", + "integrity": "sha512-ErIVHI6CJd191qdNKuMkH3bZQo9mWJsrSg++bQx64o0WFuG5nPvkYrDK0p/lebf+iQuOnzvl5HrZU6GU9a6o+Q==", + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^9.3.4", + "@secretlint/types": "^9.3.4", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/formatter": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-9.3.4.tgz", + "integrity": "sha512-ARpoBOKz6WP3ocLITCFkR1/Lj636ugpBknylhlpc45r5aLdvmyvWAJqodlw5zmUCfgD6JXeAMf3Hi60aAiuqWQ==", + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^9.3.4", + "@secretlint/types": "^9.3.4", + "@textlint/linter-formatter": "^14.7.2", + "@textlint/module-interop": "^14.7.2", + "@textlint/types": "^14.7.2", + "chalk": "^4.1.2", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "terminal-link": "^2.1.1" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-9.3.4.tgz", + "integrity": "sha512-S0u8i+CnPmyAKtuccgot9L5cmw6DqJc0F+b3hhVIALd8kkeLt3RIXOOej15tU7N0V1ISph90Gz92V72ovsprgQ==", + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^9.3.4", + "@secretlint/core": "^9.3.4", + "@secretlint/formatter": "^9.3.4", + "@secretlint/profiler": "^9.3.4", + "@secretlint/source-creator": "^9.3.4", + "@secretlint/types": "^9.3.4", + "debug": "^4.4.1", + "p-map": "^4.0.0" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-9.3.4.tgz", + "integrity": "sha512-99WmaHd4dClNIm5BFsG++E6frNIZ3qVwg6s804Ql/M19pDmtZOoVCl4/UuzWpwNniBqLIgn9rHQZ/iGlIW3wyw==", + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-9.3.4.tgz", + "integrity": "sha512-L1lIrcjzqcspPzZttmOvMmOFDpJTYFyRBONg94TZBWrpv4x0w5G2SYR+K7EE1SbYQAiPxw1amoXT1YRP8cZF2A==", + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-9.3.4.tgz", + "integrity": "sha512-RvzrLNN2A0B2bYQgRSRjh2dkdaIDuhXjj4SO5bElK1iBtJNiD6VBTxSSY1P3hXYaBeva7MEF+q1PZ3cCL8XYOA==", + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-9.3.4.tgz", + "integrity": "sha512-I9ZA1gm9HJNaAhZiQdInY9VM04VTAGDV4bappVbEJzMUDnK/LTbYqfQ88RPqgCGCqa6ee8c0/j5Bn7ypweouIw==", + "license": "MIT", + "dependencies": { + "@secretlint/types": "^9.3.4", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-9.3.4.tgz", + "integrity": "sha512-z9rdKHNeL4xa48+367RQJVw1d7/Js9HIQ+gTs/angzteM9osfgs59ad3iwVRhCGYbeUoUUDe2yxJG2ylYLaH3Q==", + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@textlint/ast-node-types": { + "version": "14.8.4", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-14.8.4.tgz", + "integrity": "sha512-+fI7miec/r9VeniFV9ppL4jRCmHNsTxieulTUf/4tvGII3db5hGriKHC4p/diq1SkQ9Sgs7kg6UyydxZtpTz1Q==", + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "14.8.4", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-14.8.4.tgz", + "integrity": "sha512-sZ0UfYRDBNHnfMVBqLqqYnqTB7Ec169ljlmo+SEHR1T+dHUPYy1/DZK4p7QREXlBSFL4cnkswETCbc9xRodm4Q==", + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "14.8.4", + "@textlint/resolver": "14.8.4", + "@textlint/types": "14.8.4", + "chalk": "^4.1.2", + "debug": "^4.4.1", + "js-yaml": "^3.14.1", + "lodash": "^4.17.21", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "license": "MIT" + }, + "node_modules/@textlint/module-interop": { + "version": "14.8.4", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-14.8.4.tgz", + "integrity": "sha512-1LdPYLAVpa27NOt6EqvuFO99s4XLB0c19Hw9xKSG6xQ1K82nUEyuWhzTQKb3KJ5Qx7qj14JlXZLfnEuL6A16Bw==", + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "14.8.4", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-14.8.4.tgz", + "integrity": "sha512-nMDOgDAVwNU9ommh+Db0U+MCMNDPbQ/1HBNjbnHwxZkCpcT6hsAJwBe38CW/DtWVUv8yeR4R40IYNPT84srNwA==", + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "14.8.4", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-14.8.4.tgz", + "integrity": "sha512-9nyY8vVXlr8hHKxa6+37omJhXWCwovMQcgMteuldYd4dOxGm14AK2nXdkgtKEUQnzLGaXy46xwLCfhQy7V7/YA==", + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "14.8.4" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "license": "MIT" + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "license": "BSD-2-Clause" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/ignore": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", + "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", + "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-7.1.1.tgz", + "integrity": "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.21.4", + "error-ex": "^1.3.2", + "json-parse-even-better-errors": "^3.0.0", + "lines-and-columns": "^2.0.3", + "type-fest": "^3.8.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, + "node_modules/rc-config-loader/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/rc-config-loader/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/read-pkg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-8.1.0.tgz", + "integrity": "sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ==", + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.1", + "normalize-package-data": "^6.0.0", + "parse-json": "^7.0.0", + "type-fest": "^4.2.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/secretlint": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-9.3.4.tgz", + "integrity": "sha512-iNOzgMX/+W1SQNW/TW6eikGChyaPiazr2AEXjzjpoB0R6QJEulvlwhn0KLT1/xjPfdYrk3yiXZM40csUqET8uQ==", + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^9.3.4", + "@secretlint/formatter": "^9.3.4", + "@secretlint/node": "^9.3.4", + "@secretlint/profiler": "^9.3.4", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^8.1.0" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "license": "CC0-1.0" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f1adb6 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "essentials", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "ess-scan": "tools/typescript/essentials-sync/dist/ess-scan.js" + }, + "files": [ + "tools/typescript/essentials-sync/.secretlintrc.json", + "tools/typescript/essentials-sync/dist/" + ], + "scripts": { + "install": "test -f tools/typescript/essentials-sync/dist/ess-scan.js || tsc --project tools/typescript/essentials-sync --noCheck" + }, + "dependencies": { + "@secretlint/secretlint-rule-preset-recommend": "^9.0.0", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "execa": "^9.5.1", + "globby": "^14.1.0", + "secretlint": "^9.0.0" + }, + "devDependencies": { + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=22" + } +} diff --git a/tools/typescript/essentials-sync/.gitignore b/tools/typescript/essentials-sync/.gitignore index d8421de..ed45f73 100644 --- a/tools/typescript/essentials-sync/.gitignore +++ b/tools/typescript/essentials-sync/.gitignore @@ -1,5 +1,4 @@ node_modules/ -dist/ .env .env.* !.env.example diff --git a/tools/typescript/essentials-sync/README.md b/tools/typescript/essentials-sync/README.md index 6375f8a..b4a01ed 100644 --- a/tools/typescript/essentials-sync/README.md +++ b/tools/typescript/essentials-sync/README.md @@ -149,7 +149,7 @@ Repos that keep `ess-*` items to sync here can run the same deterministic scanne - id: ess-scan ``` -It runs `essentials-sync scan` on each changed item and fails on critical findings. Its first run installs and builds the tool inside pre-commit's clone, which needs Node.js 22 or later. +It runs `essentials-sync scan` on each changed item and fails on critical findings. pre-commit installs it from this repo's root `package.json`, which carries only the scanners' dependencies, and needs Node.js 22 or later. ## Usage diff --git a/tools/typescript/essentials-sync/scripts/ess-scan.sh b/tools/typescript/essentials-sync/scripts/ess-scan.sh deleted file mode 100755 index c0e13f2..0000000 --- a/tools/typescript/essentials-sync/scripts/ess-scan.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Runs `essentials-sync scan` on the given paths, installing and building the -# tool first if this checkout has not been built yet. Used as the ess-scan -# pre-commit hook, whose clone of this repo starts with no node_modules or dist. -set -euo pipefail - -TOOL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -if [[ ! -f "${TOOL_DIR}/dist/cli.js" ]]; then - echo "ess-scan: building essentials-sync (first run only)" >&2 - ( - cd "${TOOL_DIR}" - npm ci --no-audit --no-fund --loglevel=error >&2 - npm run --silent build >&2 - ) -fi - -exec node "${TOOL_DIR}/dist/cli.js" scan "$@" diff --git a/tools/typescript/essentials-sync/src/ess-items.ts b/tools/typescript/essentials-sync/src/ess-items.ts index 6f9c7c7..f819a01 100644 --- a/tools/typescript/essentials-sync/src/ess-items.ts +++ b/tools/typescript/essentials-sync/src/ess-items.ts @@ -4,7 +4,7 @@ import { execa } from "execa"; // path). An item is either a directory named ess-* (packages/python/ess-auth) // or a file named ess-* directly inside a directory named ess // (instructions/ess/ess-okf-docs.instructions.md). Other files that merely -// start with ess- (scripts/ess-scan.sh) are not items. Returns null when +// start with ess- (src/ess-scan.ts) are not items. Returns null when // nothing matches, or when a dot-directory comes first (.agents/skills/ess-*, // .cursor/rules/ess-*): those are installed copies of an item. export function findEssItemRoot(repoRelPath: string): string | null { diff --git a/tools/typescript/essentials-sync/src/ess-scan.ts b/tools/typescript/essentials-sync/src/ess-scan.ts new file mode 100644 index 0000000..57b1017 --- /dev/null +++ b/tools/typescript/essentials-sync/src/ess-scan.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +// `essentials-sync scan` on its own, for the ess-scan pre-commit hook. It +// imports none of the sync command's modules, so the hook installs only the +// scanners' dependencies (see the root package.json), not @cursor/sdk. +import { runScanCommand } from "./scan-command.js"; + +runScanCommand(process.argv.slice(2)).then( + (exitCode) => process.exit(exitCode), + (error: unknown) => { + console.error(`ess-scan: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }, +); diff --git a/tools/typescript/essentials-sync/tests/read-only-commands.test.ts b/tools/typescript/essentials-sync/tests/read-only-commands.test.ts index 003fb59..5678907 100644 --- a/tools/typescript/essentials-sync/tests/read-only-commands.test.ts +++ b/tools/typescript/essentials-sync/tests/read-only-commands.test.ts @@ -36,7 +36,7 @@ describe("findEssItemRoot", () => { ["skills/ess/ess-git-tidy/SKILL.md", "skills/ess/ess-git-tidy"], ["instructions/ess/ess-okf-docs.instructions.md", "instructions/ess/ess-okf-docs.instructions.md"], [".agents/skills/ess-git-tidy/SKILL.md", null], - ["tools/typescript/essentials-sync/scripts/ess-scan.sh", null], + ["tools/typescript/essentials-sync/src/ess-scan.ts", null], ["plans/ess-verbatim-sync.md", null], ["tools/python/langsmith-onboarding/README.md", null], ])("%s -> %s", (relPath, expected) => { From 540267d179f039b5bd5735671c2d7569d4d419d0 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 12:01:17 -0500 Subject: [PATCH 07/20] feat(ess): sync ess- items verbatim from source Every ess- item now matches its source repo byte for byte, and essentials-sync status reports 30 of 30 identical in both directions. - Add ess-load-test, the ess-cli-write-safety and ess-docs-example-urls instructions, and the ess-gh-copilot-review and ess-my-groups skills. - Rename git-tidy, mcp-hide-secrets, and pr-review to their ess- names. - Update ess-auth, ess-browser, ess-langsmith-client, ess-outlook, ess-passwords, ess-service-now-incident, ess-messages, ess-hello-jwt-auth-code, and ess-summarize-change-log. ess-browser no longer ships company IdP defaults. - Replace the cli-write-safety rule with ess-cli-write-safety and register ess-load-test in the uv workspace. --- ...te-safety.mdc => ess-cli-write-safety.mdc} | 7 +- .../src/ess_hello_jwt_auth_code/test_main.py | 2 +- examples/python/ess-messages/.env.example | 9 +- examples/python/ess-messages/README.md | 17 +- .../ess/ess-cli-write-safety.instructions.md | 96 + .../ess/ess-docs-example-urls.instructions.md | 35 + packages/python/ess-auth/pyproject.toml | 1 + .../python/ess-auth/src/ess_auth/__init__.py | 26 +- .../src/ess_auth/async_token_helper.py | 394 ++++ .../ess-auth/src/ess_auth/client_identity.py | 63 + .../python/ess-auth/src/ess_auth/cooldown.py | 158 ++ .../ess-auth/src/ess_auth/rate_limit.py | 231 ++ .../src/ess_auth/test_async_token_helper.py | 446 ++++ .../src/ess_auth/test_client_identity.py | 61 + .../ess-auth/src/ess_auth/test_cooldown.py | 107 + .../ess-auth/src/ess_auth/test_rate_limit.py | 189 ++ .../src/ess_auth/test_token_helper.py | 344 +++ .../ess-auth/src/ess_auth/token_helper.py | 279 ++- packages/python/ess-browser/README.md | 110 +- .../ess-browser/src/ess_browser/__init__.py | 24 +- .../ess-browser/src/ess_browser/auth.py | 85 +- .../src/ess_browser/authenticated_page.py | 80 + .../ess-browser/src/ess_browser/exceptions.py | 37 + .../ess-browser/src/ess_browser/session.py | 206 +- .../ess-browser/src/ess_browser/site.py | 83 + .../ess-browser/src/ess_browser/test_auth.py | 101 +- .../ess_browser/test_authenticated_page.py | 214 ++ .../src/ess_browser/test_session.py | 214 +- .../ess-browser/src/ess_browser/test_site.py | 117 + .../ess-langsmith-client/docs/README.md | 2 +- .../ess-langsmith-client/docs/api-keys.md | 6 +- .../ess-langsmith-client/docs/listeners.md | 18 +- .../src/ess_langsmith_client/client.py | 120 + .../src/ess_langsmith_client/secrets.py | 26 +- .../src/ess_langsmith_client/test_cli.py | 6 +- .../src/ess_langsmith_client/test_client.py | 128 +- .../src/ess_langsmith_client/test_secrets.py | 49 + .../tools/deploy_docker.py | 8 +- .../tools/deploy_github.py | 7 +- .../src/ess_langsmith_client/tools/keys.py | 42 +- .../tools/list_listeners.py | 134 -- .../ess_langsmith_client/tools/listeners.py | 313 +++ .../src/ess_langsmith_client/tools/main.py | 4 +- .../tools/secret_options.py | 13 + .../tools/test_deploy_docker.py | 41 + .../tools/test_deploy_github.py | 23 + .../ess_langsmith_client/tools/test_keys.py | 71 + .../tools/test_listeners.py | 178 ++ .../python/ess-load-test/Pulumi.example.yaml | 11 + packages/python/ess-load-test/Pulumi.yaml | 7 + packages/python/ess-load-test/README.md | 45 + packages/python/ess-load-test/pyproject.toml | 36 + .../src/ess_load_test/__init__.py | 19 + .../src/ess_load_test/__main__.py | 25 + .../src/ess_load_test/app_config.py | 105 + .../src/ess_load_test/auth/__init__.py | 25 + .../src/ess_load_test/auth/browser.py | 201 ++ .../src/ess_load_test/auth/oauth.py | 74 + .../src/ess_load_test/cli/__init__.py | 5 + .../src/ess_load_test/cli/_helpers.py | 261 +++ .../src/ess_load_test/cli/build_cli.py | 632 ++++++ .../src/ess_load_test/cli/load_params.py | 180 ++ .../src/ess_load_test/cluster/__init__.py | 26 + .../src/ess_load_test/cluster/outputs.py | 29 + .../src/ess_load_test/cluster/runner.py | 678 ++++++ .../src/ess_load_test/infra/__init__.py | 1 + .../src/ess_load_test/infra/__main__.py | 5 + .../src/ess_load_test/infra/config.py | 52 + .../src/ess_load_test/infra/constants.py | 47 + .../src/ess_load_test/infra/instances.py | 214 ++ .../src/ess_load_test/infra/network.py | 170 ++ .../src/ess_load_test/infra/stack.py | 86 + .../src/ess_load_test/locust/__init__.py | 1 + .../src/ess_load_test/locust/locustfile.py | 42 + .../src/ess_load_test/locust/runtime.py | 188 ++ .../ess_load_test/locust/scenario_default.py | 31 + .../src/ess_load_test/ui/__init__.py | 35 + .../src/ess_load_test/ui/local_port.py | 61 + .../src/ess_load_test/ui/workflow.py | 251 +++ .../ess-load-test/tests/test_auth_token.py | 80 + .../ess-load-test/tests/test_browser_auth.py | 177 ++ .../python/ess-load-test/tests/test_config.py | 18 + .../ess-load-test/tests/test_instances.py | 58 + .../ess-load-test/tests/test_load_params.py | 172 ++ .../ess-load-test/tests/test_local_port.py | 67 + .../tests/test_locust_scenarios.py | 65 + .../tests/test_locustfile_env.py | 143 ++ .../python/ess-load-test/tests/test_runner.py | 66 + .../tests/test_security_group_rules.py | 71 + .../ess-load-test/tests/test_ui_workflow.py | 78 + packages/python/ess-outlook/.env.example | 14 +- packages/python/ess-outlook/README.md | 67 +- packages/python/ess-outlook/pyproject.toml | 1 + .../ess-outlook/src/ess_outlook/graph.py | 185 +- .../src/ess_passwords/__main__.py | 12 +- .../src/ess_passwords/test_main.py | 4 +- .../python/ess-service-now-incident/README.md | 10 +- .../src/ess_service_now_incident/cli.py | 9 +- .../src/ess_service_now_incident/client.py | 12 +- .../tests/test_cli.py | 11 + .../tests/test_client.py | 8 +- .../tests/test_get_incident.py | 11 + pyproject.toml | 1 + skills/README.md | 11 +- .../LICENSE | 0 skills/ess/ess-gh-copilot-review/SKILL.md | 85 + .../scripts/review-pr.sh | 212 ++ .../scripts/test_review_pr.sh | 606 +++++ .../LICENSE | 0 .../ess/{git-tidy => ess-git-tidy}/SKILL.md | 22 +- .../references/classification.md | 19 +- .../references/safety.md | 2 +- .../scripts/audit.sh | 83 +- .../{git-tidy => ess-git-tidy}/scripts/lib.sh | 24 +- .../scripts/test_audit.sh | 102 +- .../LICENSE | 0 .../SKILL.md | 55 +- .../references/install-cursor.md | 10 +- .../references/launchagent-naming.md | 0 .../references/launchctl.md | 31 +- .../references/rationale.md | 2 + .../scripts/check-inline-secrets.py | 0 .../scripts/fix-launchagents-dir.sh | 24 +- .../scripts/install.sh | 137 +- .../ess/ess-mcp-hide-secrets/scripts/lib.sh | 77 + .../scripts/load-mcp-env.sh | 29 +- .../local.your-username.cursor-mcp-env.plist | 9 +- .../scripts/mcp.env.example | 2 +- .../scripts/preflight.sh | 4 +- .../scripts/render-plist.py | 62 + .../ess/ess-mcp-hide-secrets/scripts/run.sh | 202 ++ .../scripts/status.sh | 2 +- .../ess-mcp-hide-secrets/scripts/test_run.sh | 333 +++ .../scripts/verify.sh | 0 skills/ess/ess-my-groups/LICENSE | 202 ++ skills/ess/ess-my-groups/SKILL.md | 151 ++ .../ess-my-groups/assets/groups.example.toml | 26 + .../ess-my-groups/references/groups-format.md | 97 + skills/ess/ess-my-groups/scripts/my_groups.py | 1953 +++++++++++++++++ .../ess-my-groups/scripts/test_my_groups.py | 1139 ++++++++++ skills/ess/ess-pr-review/LICENSE | 202 ++ .../ess/{pr-review => ess-pr-review}/SKILL.md | 81 +- .../references/agent-conventions.md | 52 + .../references/batch-mechanics.md | 39 +- .../references/deterministic-checks.md | 14 +- .../references/github-cli.md | 0 .../references/output-format.md | 0 .../references/pylint-disables.md | 13 +- .../scripts/create-review-worktree.sh} | 8 +- .../scripts/find-review-requests.sh | 422 ++++ .../scripts/lib.sh | 2 +- .../scripts/lint-python.sh} | 4 +- .../scripts/lint-ts.sh} | 4 +- .../scripts/report.py | 6 +- .../ess-pr-review/scripts/scan-disables.sh | 85 + .../scripts/scan-pr.sh | 71 +- .../scripts/test_find_review_requests.sh | 321 +++ .../scripts/test_report.py | 2 +- .../scripts/test_scan_checkout.sh | 129 ++ .../scripts/test_scan_disables.sh | 51 + skills/ess/ess-summarize-change-log/SKILL.md | 8 +- .../ess-summarize-change-log/examples-good.md | 10 +- .../ess/ess-summarize-change-log/examples.md | 30 +- .../scripts/test_validate_summary.py | 2 +- skills/ess/mcp-hide-secrets/scripts/lib.sh | 19 - skills/ess/mcp-hide-secrets/scripts/run.sh | 114 - .../pr-review/references/agent-conventions.md | 43 - .../pr-review/scripts/find_review_requests.sh | 85 - skills/ess/pr-review/scripts/scan_disables.sh | 57 - uv.lock | 445 +++- 170 files changed, 17035 insertions(+), 805 deletions(-) rename .cursor/rules/{cli-write-safety.mdc => ess-cli-write-safety.mdc} (92%) create mode 100644 instructions/ess/ess-cli-write-safety.instructions.md create mode 100644 instructions/ess/ess-docs-example-urls.instructions.md create mode 100644 packages/python/ess-auth/src/ess_auth/async_token_helper.py create mode 100644 packages/python/ess-auth/src/ess_auth/client_identity.py create mode 100644 packages/python/ess-auth/src/ess_auth/cooldown.py create mode 100644 packages/python/ess-auth/src/ess_auth/rate_limit.py create mode 100644 packages/python/ess-auth/src/ess_auth/test_async_token_helper.py create mode 100644 packages/python/ess-auth/src/ess_auth/test_client_identity.py create mode 100644 packages/python/ess-auth/src/ess_auth/test_cooldown.py create mode 100644 packages/python/ess-auth/src/ess_auth/test_rate_limit.py create mode 100644 packages/python/ess-auth/src/ess_auth/test_token_helper.py create mode 100644 packages/python/ess-browser/src/ess_browser/authenticated_page.py create mode 100644 packages/python/ess-browser/src/ess_browser/exceptions.py create mode 100644 packages/python/ess-browser/src/ess_browser/site.py create mode 100644 packages/python/ess-browser/src/ess_browser/test_authenticated_page.py create mode 100644 packages/python/ess-browser/src/ess_browser/test_site.py delete mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_listeners.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/listeners.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/secret_options.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_docker.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_github.py create mode 100644 packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_listeners.py create mode 100644 packages/python/ess-load-test/Pulumi.example.yaml create mode 100644 packages/python/ess-load-test/Pulumi.yaml create mode 100644 packages/python/ess-load-test/README.md create mode 100644 packages/python/ess-load-test/pyproject.toml create mode 100644 packages/python/ess-load-test/src/ess_load_test/__init__.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/__main__.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/app_config.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/auth/__init__.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/auth/browser.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/auth/oauth.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/cli/__init__.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/cli/load_params.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/cluster/__init__.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/cluster/outputs.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/cluster/runner.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/infra/__init__.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/infra/__main__.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/infra/config.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/infra/constants.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/infra/instances.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/infra/network.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/infra/stack.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/locust/__init__.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/locust/locustfile.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/locust/runtime.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/locust/scenario_default.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/ui/__init__.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/ui/local_port.py create mode 100644 packages/python/ess-load-test/src/ess_load_test/ui/workflow.py create mode 100644 packages/python/ess-load-test/tests/test_auth_token.py create mode 100644 packages/python/ess-load-test/tests/test_browser_auth.py create mode 100644 packages/python/ess-load-test/tests/test_config.py create mode 100644 packages/python/ess-load-test/tests/test_instances.py create mode 100644 packages/python/ess-load-test/tests/test_load_params.py create mode 100644 packages/python/ess-load-test/tests/test_local_port.py create mode 100644 packages/python/ess-load-test/tests/test_locust_scenarios.py create mode 100644 packages/python/ess-load-test/tests/test_locustfile_env.py create mode 100644 packages/python/ess-load-test/tests/test_runner.py create mode 100644 packages/python/ess-load-test/tests/test_security_group_rules.py create mode 100644 packages/python/ess-load-test/tests/test_ui_workflow.py rename skills/ess/{git-tidy => ess-gh-copilot-review}/LICENSE (100%) create mode 100644 skills/ess/ess-gh-copilot-review/SKILL.md create mode 100644 skills/ess/ess-gh-copilot-review/scripts/review-pr.sh create mode 100755 skills/ess/ess-gh-copilot-review/scripts/test_review_pr.sh rename skills/ess/{mcp-hide-secrets => ess-git-tidy}/LICENSE (100%) rename skills/ess/{git-tidy => ess-git-tidy}/SKILL.md (88%) rename skills/ess/{git-tidy => ess-git-tidy}/references/classification.md (88%) rename skills/ess/{git-tidy => ess-git-tidy}/references/safety.md (98%) rename skills/ess/{git-tidy => ess-git-tidy}/scripts/audit.sh (87%) rename skills/ess/{git-tidy => ess-git-tidy}/scripts/lib.sh (83%) mode change 100755 => 100644 rename skills/ess/{git-tidy => ess-git-tidy}/scripts/test_audit.sh (66%) rename skills/ess/{pr-review => ess-mcp-hide-secrets}/LICENSE (100%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/SKILL.md (50%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/references/install-cursor.md (82%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/references/launchagent-naming.md (100%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/references/launchctl.md (66%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/references/rationale.md (91%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/scripts/check-inline-secrets.py (100%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/scripts/fix-launchagents-dir.sh (74%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/scripts/install.sh (54%) create mode 100755 skills/ess/ess-mcp-hide-secrets/scripts/lib.sh rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/scripts/load-mcp-env.sh (57%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/scripts/local.your-username.cursor-mcp-env.plist (61%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/scripts/mcp.env.example (84%) rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/scripts/preflight.sh (92%) create mode 100755 skills/ess/ess-mcp-hide-secrets/scripts/render-plist.py create mode 100755 skills/ess/ess-mcp-hide-secrets/scripts/run.sh rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/scripts/status.sh (93%) create mode 100755 skills/ess/ess-mcp-hide-secrets/scripts/test_run.sh rename skills/ess/{mcp-hide-secrets => ess-mcp-hide-secrets}/scripts/verify.sh (100%) create mode 100644 skills/ess/ess-my-groups/LICENSE create mode 100644 skills/ess/ess-my-groups/SKILL.md create mode 100644 skills/ess/ess-my-groups/assets/groups.example.toml create mode 100644 skills/ess/ess-my-groups/references/groups-format.md create mode 100644 skills/ess/ess-my-groups/scripts/my_groups.py create mode 100644 skills/ess/ess-my-groups/scripts/test_my_groups.py create mode 100644 skills/ess/ess-pr-review/LICENSE rename skills/ess/{pr-review => ess-pr-review}/SKILL.md (71%) create mode 100644 skills/ess/ess-pr-review/references/agent-conventions.md rename skills/ess/{pr-review => ess-pr-review}/references/batch-mechanics.md (77%) rename skills/ess/{pr-review => ess-pr-review}/references/deterministic-checks.md (82%) rename skills/ess/{pr-review => ess-pr-review}/references/github-cli.md (100%) rename skills/ess/{pr-review => ess-pr-review}/references/output-format.md (100%) rename skills/ess/{pr-review => ess-pr-review}/references/pylint-disables.md (86%) rename skills/ess/{pr-review/scripts/create_review_worktree.sh => ess-pr-review/scripts/create-review-worktree.sh} (95%) create mode 100755 skills/ess/ess-pr-review/scripts/find-review-requests.sh rename skills/ess/{pr-review => ess-pr-review}/scripts/lib.sh (98%) rename skills/ess/{pr-review/scripts/lint_python.sh => ess-pr-review/scripts/lint-python.sh} (98%) rename skills/ess/{pr-review/scripts/lint_ts.sh => ess-pr-review/scripts/lint-ts.sh} (98%) rename skills/ess/{pr-review => ess-pr-review}/scripts/report.py (98%) create mode 100755 skills/ess/ess-pr-review/scripts/scan-disables.sh rename skills/ess/{pr-review => ess-pr-review}/scripts/scan-pr.sh (76%) create mode 100755 skills/ess/ess-pr-review/scripts/test_find_review_requests.sh rename skills/ess/{pr-review => ess-pr-review}/scripts/test_report.py (99%) create mode 100644 skills/ess/ess-pr-review/scripts/test_scan_checkout.sh create mode 100644 skills/ess/ess-pr-review/scripts/test_scan_disables.sh delete mode 100755 skills/ess/mcp-hide-secrets/scripts/lib.sh delete mode 100755 skills/ess/mcp-hide-secrets/scripts/run.sh delete mode 100644 skills/ess/pr-review/references/agent-conventions.md delete mode 100755 skills/ess/pr-review/scripts/find_review_requests.sh delete mode 100755 skills/ess/pr-review/scripts/scan_disables.sh diff --git a/.cursor/rules/cli-write-safety.mdc b/.cursor/rules/ess-cli-write-safety.mdc similarity index 92% rename from .cursor/rules/cli-write-safety.mdc rename to .cursor/rules/ess-cli-write-safety.mdc index 24e6f5b..1d6608e 100644 --- a/.cursor/rules/cli-write-safety.mdc +++ b/.cursor/rules/ess-cli-write-safety.mdc @@ -1,6 +1,6 @@ --- description: Every CLI in this repo that writes or destroys must support --dry-run, confirm by default, and accept --yes -alwaysApply: true +globs: **/tools/**, **/cli.py, **/commands/**, **/*.sh, **/__main__.py --- # CLI Write Safety @@ -46,10 +46,7 @@ Must not prompt, and must not offer `--dry-run` — there is nothing to preview. ## Where the flags live Put `--dry-run` on the group when it governs every subcommand, and `--yes` on each write -command. `keys delete` in `packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py` -shows the confirmation half: it lists the keys it will delete, prompts by default, and -accepts `--yes`. It does not yet offer `--dry-run`, and its prompt goes to stdout; do not -copy those two gaps. The complete shape: +command. The complete shape: ```python @click.option("--dry-run", is_flag=True, default=False, diff --git a/examples/python/ess-hello-jwt-auth-code/src/ess_hello_jwt_auth_code/test_main.py b/examples/python/ess-hello-jwt-auth-code/src/ess_hello_jwt_auth_code/test_main.py index 03b67e4..6682e57 100644 --- a/examples/python/ess-hello-jwt-auth-code/src/ess_hello_jwt_auth_code/test_main.py +++ b/examples/python/ess-hello-jwt-auth-code/src/ess_hello_jwt_auth_code/test_main.py @@ -1,4 +1,4 @@ -# ruff: noqa: S106, PLR2004 -- test credentials are not real secrets; magic numbers expected +# ruff: noqa: S106, PLR2004, B110 -- test credentials are not real secrets; magic numbers expected; server may close before response is read """Tests for the OAuth 2.0 Authorization Code Flow.""" from __future__ import annotations diff --git a/examples/python/ess-messages/.env.example b/examples/python/ess-messages/.env.example index 0d54656..f308026 100644 --- a/examples/python/ess-messages/.env.example +++ b/examples/python/ess-messages/.env.example @@ -1,8 +1,15 @@ -# Outlook (Microsoft Graph) +# Outlook (Microsoft Graph). CLIENT_ID and TENANT_ID are required for the +# email source unless GRAPH_ACCESS_TOKEN is set; there is no default client ID. # CLIENT_ID: Microsoft Entra app registration (e.g., the "Office Desktop Apps" public client) CLIENT_ID= # TENANT_ID: your organization's Microsoft Entra (Azure AD) tenant ID TENANT_ID= +# Optional: a token from https://developer.microsoft.com/graph/graph-explorer, +# for tenants where Conditional Access blocks the device-code flow (~1h TTL) +# GRAPH_ACCESS_TOKEN= +# Optional: browser sign-in instead of device code. Requires CLIENT_ID to be an +# app registration that lists "http://localhost" as a redirect URI. +# GRAPH_INTERACTIVE_AUTH=1 # Webex OAuth integration (from https://developer.webex.com/my-apps) WEBEX_CLIENT_ID= diff --git a/examples/python/ess-messages/README.md b/examples/python/ess-messages/README.md index e2fa835..de14443 100644 --- a/examples/python/ess-messages/README.md +++ b/examples/python/ess-messages/README.md @@ -33,13 +33,28 @@ uv sync --all-packages ## Auth Requires credentials for one or both sources. See: -- [Outlook setup](../../../packages/python/ess-outlook/.env.example) -- `CLIENT_ID`, `TENANT_ID` +- [Outlook setup](../../../packages/python/ess-outlook/README.md#configuration) -- + `CLIENT_ID` and `TENANT_ID`, both required unless `GRAPH_ACCESS_TOKEN` is set - [Webex setup](../../../packages/python/ess-webex/.env.example) -- `WEBEX_CLIENT_ID`/`WEBEX_CLIENT_SECRET` or `WEBEX_ACCESS_TOKEN` Copy this example's `.env.example` to `.env` and fill in values, or set the variables in your shell environment. Never commit `.env` -- it is meant to hold secrets. +If Conditional Access blocks the Outlook device-code flow, set +`GRAPH_ACCESS_TOKEN` to a token from +[Graph Explorer](https://developer.microsoft.com/graph/graph-explorer) -- +see [Bring your own token](../../../packages/python/ess-outlook/README.md#bring-your-own-token). +If Outlook authentication fails, the command exits unless you pass +`--webex-only`, which skips Outlook and fetches Webex only. Within +ess-outlook, multiple auth methods are tried before failure (supplied token, +cached refresh, optional browser sign-in, then device code). + +For a permanent fix, ask your identity team for a dedicated app registration: a +single-tenant public client with delegated `Mail.ReadWrite` on Microsoft Graph and +`http://localhost` as a redirect URI. Set its Application (client) ID and Directory +(tenant) ID as `CLIENT_ID` and `TENANT_ID`. + ## Usage ```bash diff --git a/instructions/ess/ess-cli-write-safety.instructions.md b/instructions/ess/ess-cli-write-safety.instructions.md new file mode 100644 index 0000000..2c602f0 --- /dev/null +++ b/instructions/ess/ess-cli-write-safety.instructions.md @@ -0,0 +1,96 @@ +--- +description: Every CLI in this repo that writes or destroys must support --dry-run, confirm by default, and accept --yes +applyTo: "**/tools/**,**/cli.py,**/commands/**,**/*.sh,**/__main__.py" +--- + +# CLI Write Safety + +Applies to **every command-line tool produced in this repo**, whatever the language or +argument parser: Click groups, `argparse` scripts under `tools/`, Django management +commands, TypeScript `bin` entries, and shell scripts. + +## The three requirements + +Any command that writes remote or local state, deletes, rotates a credential, or is +otherwise not trivially reversible must have all three: + +1. **`--dry-run`** — runs the same code path, prints exactly what would change, exits 0, + and touches nothing. It is not a separate "plan" branch that can drift from the real + one; resolve the same targets and build the same payloads, then stop before the write. +2. **Confirmation by default** — print a summary of what is about to happen (counts, + target names, account/stack/environment), then prompt. Abort on anything but yes. +3. **`--yes`** (alias `-y`) — bypasses the prompt, and nothing else. It must never imply + or cancel `--dry-run`. + +## Use these exact flag names + +`--dry-run` and `--yes`. Do not invent `--apply`, `--submit`, `--commit`, `--force`, +`--no-confirm`, or `--for-real`. + +Opt-in-to-write flags look safe but are not: they make the *safe* path the one the +operator has to remember, and every tool picks a different word for it, so muscle memory +from one tool does nothing for the next. Prompting by default gives the same protection +without depending on recall. + +## Precedence and non-interactive runs + +- `--dry-run` wins if both flags are passed. +- When a **real write** would occur, never silently proceed without explicit consent. + Without a TTY, or in `--json` mode, fail and tell the operator to pass `--yes`. + `--dry-run` skips this check because nothing is written. + +## Read-only commands + +Must not prompt, and must not offer `--dry-run` — there is nothing to preview. + +## Where the flags live + +Put `--dry-run` on the group when it governs every subcommand, and `--yes` on each write +command. The complete shape: + +```python +@click.option("--dry-run", is_flag=True, default=False, + help="Show planned changes without submitting them.") +... +@click.option("--yes", is_flag=True, default=False, help="Skip the confirmation prompt.") +``` + +with a single guard called by every write path: + +```python +def _confirm_or_abort(options, account, locations, *, skip: bool) -> None: + """Require explicit consent before writing credentials.""" + if skip or options.dry_run: + return + + # Real write only; --dry-run returned above. --json has no interactive + # prompt to fall back on, so make the caller opt in rather than silently + # skipping the guard. + if options.as_json: + raise click.UsageError("--json requires --yes, which confirms the write.") + + click.confirm(f"\nSet a new password for {account} in {len(locations)} location(s)?", + abort=True, err=True) +``` + +For interactive **real writes**, prompt on **stderr** (`err=True`) so piping stdout to +a file or `jq` still shows the question. Dry-run and `--yes` paths skip the prompt, so +`--dry-run --json` without `--yes` is valid. + +`@click.confirmation_option(prompt=...)` gives you `--yes` for free and satisfies +requirements 2 and 3, but you still owe `--dry-run`. + +For `argparse`, the equivalent is `--dry-run` / `--yes` store-true flags plus an explicit +`sys.stdin.isatty()` check before `input()` for real writes (not `--dry-run`); fail with +a message to pass `--yes` if non-interactive. + +## Tests (Click / Python) + +For Click-based Python CLIs, co-locate `test_*.py` per the repo's testing convention. +Two cases at minimum, using Click's `CliRunner`: + +- `--dry-run` performs no writes (assert the client/session mock was never called). +- `--yes` skips the prompt (assert the command succeeds with no stdin). + +TypeScript, shell, and Django commands must still prove the same three requirements, +using that language's test tooling and conventions. diff --git a/instructions/ess/ess-docs-example-urls.instructions.md b/instructions/ess/ess-docs-example-urls.instructions.md new file mode 100644 index 0000000..998bd3e --- /dev/null +++ b/instructions/ess/ess-docs-example-urls.instructions.md @@ -0,0 +1,35 @@ +--- +description: >- + Use example.com, example.net, example.org, or example.edu, or a subdomain of + one, for every illustrative host, URL, and email in code, tests, comments, + and docs. +applyTo: "**" +--- + +# Example URLs + +Placeholder hosts, URLs, and email addresses use one of four reserved domains, or a +subdomain of one: `example.com`, `example.net`, `example.org`, or `example.edu`. RFC 2606 +reserves the first three, and IANA holds all four. Each serves a page that reads: "This +domain is for use in documentation examples without needing permission." + +| Placeholder for | Use | Not | +| --------------- | --- | --- | +| A remote host | `host.example.com` | `host.com`, `myhost` | +| An identity provider | `idp.example.com` | `login.vendor.com` | +| A deployed app | `app.example.com` | `my-app.awsapprunner.com` | +| A person | `ada@example.com` | an address at `x.com` or a real company | +| A second organization | `corp.example.net` | `corp.example`, `example.invalid` | + +- **Default to `example.com`.** Use `example.net`, `example.org`, or `example.edu` when an + example needs organizations that are visibly distinct. +- **Only these four.** Other `example.<tld>` names, such as `example.io` or `example.dev`, + are ordinary domains someone can register. The reserved TLDs `.example`, `.invalid`, and + `.test` are safe but off-convention: a short fixed list keeps placeholders searchable, + and secret and PII scanners can allow exactly that list. +- **Real endpoints stay real.** A host the code actually calls (`github.com`, + `graph.microsoft.com`, `api.smith.langchain.com`) is not a placeholder; leave it. +- **Exception: a domain the code enforces.** When the code under test accepts only a + provider's domain, keep the provider suffix and make `example` the leftmost label. A + client that rejects any host outside `*.service-now.com` is tested with + `example.service-now.com`. diff --git a/packages/python/ess-auth/pyproject.toml b/packages/python/ess-auth/pyproject.toml index 69d3a92..49bea97 100644 --- a/packages/python/ess-auth/pyproject.toml +++ b/packages/python/ess-auth/pyproject.toml @@ -14,3 +14,4 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/ess_auth"] +exclude = ["**/test_*.py"] diff --git a/packages/python/ess-auth/src/ess_auth/__init__.py b/packages/python/ess-auth/src/ess_auth/__init__.py index 23dd940..2b553fd 100644 --- a/packages/python/ess-auth/src/ess_auth/__init__.py +++ b/packages/python/ess-auth/src/ess_auth/__init__.py @@ -1,12 +1,36 @@ """OAuth2 client-credentials authentication and JWT verification utilities.""" +from .async_token_helper import AsyncTokenHelper, TokenClientOptions +from .client_identity import client_identity_headers +from .cooldown import FailureCooldown from .jwt import JWKSVerifier, decode_jwt, generate_jwt -from .token_helper import AuthenticationError, TokenHelper +from .rate_limit import ( + RateLimitStatus, + diagnostic_headers, + parse_rate_limit_headers, + rate_limit_hold_secs, +) +from .token_helper import ( + AuthenticationError, + TokenEndpointCoolingDownError, + TokenHelper, + TokenRequestFailedError, +) __all__ = [ + "AsyncTokenHelper", "AuthenticationError", + "FailureCooldown", "JWKSVerifier", + "RateLimitStatus", + "TokenClientOptions", + "TokenEndpointCoolingDownError", "TokenHelper", + "TokenRequestFailedError", + "client_identity_headers", "decode_jwt", + "diagnostic_headers", "generate_jwt", + "parse_rate_limit_headers", + "rate_limit_hold_secs", ] diff --git a/packages/python/ess-auth/src/ess_auth/async_token_helper.py b/packages/python/ess-auth/src/ess_auth/async_token_helper.py new file mode 100644 index 0000000..5fbdd72 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/async_token_helper.py @@ -0,0 +1,394 @@ +"""Async OAuth2 client-credentials token helper. + +The async counterpart to :class:`ess_auth.TokenHelper`, for callers running +under an event loop. It exists separately rather than as an ``async`` method +on the sync helper because it needs per-event-loop lock state that has no +meaning in synchronous code. + +Why the per-loop locks +---------------------- +An ``asyncio.Lock`` binds to the running event loop the first time a coroutine +has to *wait* on a held lock (uncontended acquire/release never reaches that +path, which is why this only surfaces under concurrent refreshes). Frameworks +that create a fresh loop per request -- LangGraph among them -- leave a token +helper cached at module scope outliving the loop it first ran on, so once +contention has bound a shared lock to one loop, a second loop's contended +acquire raises:: + + RuntimeError: <asyncio.locks.Lock object ...> is bound to a different event loop + +This helper therefore caches one lock per running loop in a +``WeakKeyDictionary``. The values are weak references to the locks rather than +the locks themselves: once bound, a lock holds a *strong* reference to its +loop, and a strong value pointing back at its own weak key pins that key +forever, so the cache would grow with every request loop instead of shrinking. +A lock survives for as long as a caller is inside its ``async with``, which is +the only window where lock identity matters. Entries whose loop has been +closed are dropped on access so eviction does not wait for a collection cycle. + +An ``id()``-keyed dict would be unsafe because CPython reuses ``id`` values +after a loop is garbage collected. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +import time +import weakref +from collections.abc import Mapping +from dataclasses import dataclass + +import httpx + +from .client_identity import client_identity_headers +from .config import EXPIRATION_WINDOW +from .cooldown import FailureCooldown +from .rate_limit import diagnostic_headers +from .token_helper import ( + MIN_CACHE_LIFETIME_SECS, + STALE_TOKEN_SAFETY_SECS, + TokenEndpointCoolingDownError, + TokenRequestFailedError, + apply_rate_limit_headers, + error_detail, + parse_access_token, + parse_expires_in, +) + +logger = logging.getLogger(__name__) + +# Bounds lock wait plus network so a hung endpoint cannot stall a backlog of +# queued callers indefinitely. +_DEFAULT_TOTAL_TIMEOUT_SECS = 35.0 +_DEFAULT_REQUEST_TIMEOUT_SECS = 30.0 + + +@dataclass(frozen=True) +class TokenClientOptions: + """Optional behaviour for a token helper. + + Grouped into one object so the constructor stays readable as the number + of knobs grows. + + Attributes + ---------- + refresh_margin: + Seconds before expiry to trigger a proactive refresh. + scope: + OAuth2 scope, sent only when non-empty. Authorization servers differ + on whether an empty ``scope`` is acceptable. + credentials_in_body: + Send ``client_id``/``client_secret`` as form fields instead of HTTP + Basic auth. Both are valid client authentication methods and servers + differ on which they accept, so this is explicit rather than assumed. + cooldown_secs: + Override the failure cooldown window. See :mod:`ess_auth.cooldown`. + label: + Short name distinguishing this helper in log lines. + request_headers: + Extra headers for the token POST. ``None`` (the default) sends the + identification header configured in the environment; see + :mod:`ess_auth.client_identity`. + + Notes + ----- + The cooldown bounds attempts **per event loop**, not per process. The + locks are per-loop by necessity (an ``asyncio.Lock`` cannot be shared + across loops), so callers on different loops can pass the cooldown check + concurrently before the first failure records it. The worst case is one + in-flight attempt per live loop per window rather than a strict one, which + still collapses the per-request amplification this exists to stop. Making + it strict would need process-wide coordination that blocks event loops, so + it is deliberately not done here. + """ + + refresh_margin: int = EXPIRATION_WINDOW + scope: str = "" + credentials_in_body: bool = False + cooldown_secs: float | None = None + label: str = "OAuth" + request_headers: Mapping[str, str] | None = None + + +class AsyncTokenHelper: + """Manages OAuth2 client-credentials tokens for async callers. + + Uses ``threading.Lock`` for the cache read and token write (both + instantaneous) and a per-loop ``asyncio.Lock`` to serialise refreshes, so + waiting callers yield the event loop rather than blocking a thread. + + A token is refreshed ``refresh_margin`` seconds early. If that refresh + fails or is suppressed, the cached token keeps being served until it + actually expires, so one failed refresh never costs a still-valid token. + Rate-limit headers on any token response extend the cooldown; see + :mod:`ess_auth.rate_limit`. + + Parameters + ---------- + client_id: + OAuth2 client ID. + secret: + OAuth2 client secret. + token_url: + Token endpoint URL. + options: + Optional behaviour; see :class:`TokenClientOptions`. + """ + + def __init__( + self, + client_id: str, + secret: str, + *, + token_url: str, + options: TokenClientOptions | None = None, + ) -> None: + opts = options or TokenClientOptions() + self._client_id = client_id + self._secret = secret + self._token_url = token_url + self._refresh_margin = opts.refresh_margin + self._scope = opts.scope + self._credentials_in_body = opts.credentials_in_body + self._label = opts.label + self._request_headers = dict( + client_identity_headers() + if opts.request_headers is None + else opts.request_headers + ) + self._cooldown = FailureCooldown(opts.cooldown_secs) + + self._access_token: str | None = None + # ``_expires_at`` is when to refresh; ``_valid_until`` is when the + # token actually dies, which bounds how long a stale one is served. + self._expires_at: float = 0.0 + self._valid_until: float = 0.0 + self._lock = threading.Lock() + self._async_locks: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, weakref.ref[asyncio.Lock] + ] = weakref.WeakKeyDictionary() + + @property + def scope(self) -> str: + """The configured OAuth2 scope, empty when unscoped.""" + return self._scope + + @property + def token_url(self) -> str: + """The token endpoint URL, for caller-side diagnostics.""" + return self._token_url + + @property + def label(self) -> str: + """Short name distinguishing this helper in caller logs.""" + return self._label + + def _evict_closed_loops(self) -> None: + """Drop cache entries whose loop has been closed. + + Callers must hold ``self._lock``. + """ + closed = [loop for loop in self._async_locks if loop.is_closed()] + for loop in closed: + del self._async_locks[loop] + + def _get_async_lock(self) -> asyncio.Lock: + """Return the ``asyncio.Lock`` bound to the running loop. + + The caller must keep the returned lock referenced for as long as it + needs it; the cache holds it weakly. See the module docstring. + """ + loop = asyncio.get_running_loop() + with self._lock: + self._evict_closed_loops() + cached = self._async_locks.get(loop) + lock = cached() if cached is not None else None + if lock is None: + lock = asyncio.Lock() + self._async_locks[loop] = weakref.ref(lock) + return lock + + def _cached_token(self) -> str | None: + """Return the cached token when it is still inside its refresh window.""" + with self._lock: + if self._access_token and time.monotonic() < self._expires_at: + return self._access_token + return None + + def _stale_token(self) -> str | None: + """Return the cached token if it outlives a failed or suppressed refresh.""" + with self._lock: + token = self._access_token + valid_for = self._valid_until - time.monotonic() + if token is None or valid_for <= STALE_TOKEN_SAFETY_SECS: + return None + logger.warning( + "OAUTH_TOKEN_STALE_SERVED label=%s valid_for_s=%.0f token_url=%s", + self._label, + valid_for, + self._token_url, + ) + return token + + async def fetch_token(self) -> str: + """Return a valid access token, refreshing if necessary. + + Raises + ------ + TokenEndpointCoolingDownError + When a recent failure is still suppressing requests. + TokenRequestFailedError + When the token endpoint rejects or fails the request. + """ + cached = self._cached_token() + if cached is not None: + return cached + return await self.refresh() + + async def refresh(self) -> str: + """Perform the client-credentials grant and cache the result.""" + try: + return await asyncio.wait_for( + self._refresh_locked(), timeout=_DEFAULT_TOTAL_TIMEOUT_SECS + ) + except TimeoutError as exc: + logger.warning( + "%s token refresh exceeded %ss; abandoning attempt", + self._label, + _DEFAULT_TOTAL_TIMEOUT_SECS, + ) + # ``wait_for`` fires outside ``_do_refresh``, so without this the + # breaker stays closed and the next caller immediately starts a + # fresh attempt against an endpoint already known to be stuck -- + # the amplification path this class exists to close. Re-raised as + # the documented failure type so a hang reaches callers as an + # ``AuthenticationError`` like every other token failure. + self._log_cooldown() + stale = self._stale_token() + if stale is not None: + return stale + raise TokenRequestFailedError( + f"{self._label} token refresh exceeded " + f"{_DEFAULT_TOTAL_TIMEOUT_SECS:.0f}s" + ) from exc + + async def _refresh_locked(self) -> str: + async with self._get_async_lock(): + # Re-check: a concurrent caller may have refreshed while we waited. + cached = self._cached_token() + if cached is not None: + return cached + + cooling_for = self._cooldown.remaining() + if cooling_for > 0: + stale = self._stale_token() + if stale is not None: + return stale + raise TokenEndpointCoolingDownError( + f"{self._label} token endpoint is cooling down for " + f"{cooling_for:.0f}s after a recent failure; not retrying " + f"({self._token_url})" + ) + + try: + return await self._do_refresh() + except TokenRequestFailedError: + stale = self._stale_token() + if stale is None: + raise + return stale + + def _build_request_kwargs(self) -> dict: + """Return the form data and auth for the token POST.""" + data = {"grant_type": "client_credentials"} + if self._scope: + data["scope"] = self._scope + if self._credentials_in_body: + data["client_id"] = self._client_id + data["client_secret"] = self._secret + return {"data": data} + return {"data": data, "auth": (self._client_id, self._secret)} + + def _log_cooldown(self, response: httpx.Response | None = None) -> None: + """Open the breaker and record the window, which grows per failure. + + When the failed *response* is available, its rate-limit headers can + extend the window and its request-ID headers are logged. + """ + window = self._cooldown.start() + diagnostics: dict[str, str] = {} + status: int | str = "none" + if response is not None: + apply_rate_limit_headers(self._cooldown, response, label=self._label) + diagnostics = diagnostic_headers(response.headers) + status = response.status_code + # One line per failed attempt, including ones absorbed by serving a + # stale token, so this is the authoritative denial count. + logger.warning( + "OAUTH_TOKEN_COOLDOWN label=%s status=%s consecutive_failures=%d " + "window_s=%.0f token_url=%s diagnostics=%s", + self._label, + status, + self._cooldown.consecutive_failures, + window, + self._token_url, + diagnostics, + ) + + async def _do_refresh(self) -> str: + """Execute the token request, starting the cooldown on any failure.""" + try: + async with httpx.AsyncClient( + timeout=_DEFAULT_REQUEST_TIMEOUT_SECS + ) as client: + response = await client.post( + self._token_url, + headers=self._request_headers, + **self._build_request_kwargs(), + ) + response.raise_for_status() + payload = response.json() + access_token = parse_access_token(payload) + expires_in = parse_expires_in(payload) + except httpx.HTTPStatusError as exc: + self._log_cooldown(exc.response) + raise TokenRequestFailedError( + f"{self._label} token request failed: HTTP {exc.response.status_code}", + status_code=exc.response.status_code, + detail=error_detail(exc.response), + diagnostics=diagnostic_headers(exc.response.headers), + ) from exc + except httpx.HTTPError as exc: + self._log_cooldown() + raise TokenRequestFailedError( + f"{self._label} token request failed: {exc}" + ) from exc + except (ValueError, KeyError, TypeError) as exc: + # A malformed body would otherwise retry as hard as a denial. + # Carry status and detail like the HTTP paths do: a 2xx with an + # unusable body is exactly the case where a caller logging + # status=None and an empty detail has nothing to go on. + self._log_cooldown(response) + raise TokenRequestFailedError( + f"{self._label} token response was not usable: {exc}", + status_code=response.status_code, + detail=error_detail(response), + diagnostics=diagnostic_headers(response.headers), + ) from exc + + now = time.monotonic() + with self._lock: + self._access_token = access_token + self._expires_at = now + max( + expires_in - self._refresh_margin, MIN_CACHE_LIFETIME_SECS + ) + self._valid_until = now + expires_in + self._cooldown.clear() + # After clear(): a granted token can still report a nearly spent + # shared budget, and the hold must survive the reset. + apply_rate_limit_headers(self._cooldown, response, label=self._label) + + logger.info("%s token refreshed (expires_in=%ds)", self._label, expires_in) + return access_token diff --git a/packages/python/ess-auth/src/ess_auth/client_identity.py b/packages/python/ess-auth/src/ess_auth/client_identity.py new file mode 100644 index 0000000..4371550 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/client_identity.py @@ -0,0 +1,63 @@ +"""Identify our token traffic to the edge in front of the token endpoint. + +Operators of an identity platform can match an agreed header key/value pair +at their edge or CDN layer and treat the traffic as a known service rather +than an anonymous source behind a shared NAT address. The pair is agreed out +of band, so it is configured through the environment (seeded from a secret +store) and never committed: + +* ``OAUTH_TOKEN_CLIENT_HEADER_NAME`` -- header name, e.g. ``X-Client-App`` +* ``OAUTH_TOKEN_CLIENT_HEADER_VALUE`` -- agreed value; treat as a secret + +Both must be set for the header to be sent. The value is never logged. +""" + +from __future__ import annotations + +import logging +import os +import re + +logger = logging.getLogger(__name__) + +HEADER_NAME_ENV = "OAUTH_TOKEN_CLIENT_HEADER_NAME" +HEADER_VALUE_ENV = "OAUTH_TOKEN_CLIENT_HEADER_VALUE" + +# RFC 9110 ``token`` characters. A name outside this set would make httpx +# raise on every request, which is worse than not identifying at all. +_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") +# Header names that would change how the token request authenticates or is +# routed rather than merely labelling it. +_RESERVED_NAMES = frozenset( + {"authorization", "content-type", "content-length", "host", "cookie"} +) + + +def client_identity_headers() -> dict[str, str]: + """Return the configured identification header, or an empty dict. + + Returns: + ``{name: value}`` when both environment variables are set and the + name is a valid, non-reserved header name; otherwise ``{}``. + """ + name = os.environ.get(HEADER_NAME_ENV, "").strip() + value = os.environ.get(HEADER_VALUE_ENV, "").strip() + if not name and not value: + return {} + if not name or not value: + logger.warning( + "%s and %s must both be set; token requests will not carry an " + "identification header", + HEADER_NAME_ENV, + HEADER_VALUE_ENV, + ) + return {} + if not _HEADER_NAME_RE.match(name) or name.lower() in _RESERVED_NAMES: + logger.warning( + "%s=%r is not an allowed header name; token requests will not " + "carry an identification header", + HEADER_NAME_ENV, + name, + ) + return {} + return {name: value} diff --git a/packages/python/ess-auth/src/ess_auth/cooldown.py b/packages/python/ess-auth/src/ess_auth/cooldown.py new file mode 100644 index 0000000..aa3d308 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/cooldown.py @@ -0,0 +1,158 @@ +"""Failure cooldown shared by the sync and async token helpers. + +A denied token request is not cached, so without a cooldown the next caller +retries immediately. Where the token is fetched per outbound request -- for +example from an ``httpx`` event hook -- that turns one denial into one +upstream request per call. + +That is a feedback loop rather than mere noise. Token endpoints commonly +rate-limit per source IP, and a whole Kubernetes cluster egresses through a +single NAT address, so retries escalate an intermittent denial into a +sustained one. A production deployment that hit this measured roughly ten +times its normal token-request rate, with more than twice as many denials as +successes over ten minutes, escalating to a total block. + +This is a circuit breaker, not a retry policy +--------------------------------------------- +Nothing here sleeps, loops, or re-issues a request. A failure opens the +breaker; callers arriving while it is open fail fast without touching the +network, and the next caller after the window gets one attempt. That is the +opposite of ``tenacity``-style backoff, which keeps sending requests on a +schedule -- the behaviour this exists to stop. + +The *window* does grow exponentially, which is where backoff genuinely +applies. A fixed window is weak during a sustained outage: at 60s, a 90-minute +block still admits ~90 attempts per event loop. Doubling to a cap of one hour +admits about 7 over the same period, while still recovering promptly from a +brief blip. Jitter keeps a fleet of pods that started together from +synchronising their probes. + +There is no standard-library primitive for this, and pulling in a +circuit-breaker dependency is not worth it, so it is implemented here -- +deliberately small. +""" + +from __future__ import annotations + +import os +import secrets +import threading +import time + +_DEFAULT_COOLDOWN_SECS = 60.0 +_DEFAULT_MAX_COOLDOWN_SECS = 3600.0 +# Spread of the jitter applied to each window, as a fraction of its length. +_JITTER_FRACTION = 0.2 + + +def _read_env_float(name: str, default: float) -> float: + """Return a non-negative float from the environment, or *default*. + + Read from the environment so the windows can be retuned without a + redeploy; consumers that roll a new release to change an environment + variable pay a real cost for it. + """ + raw = os.environ.get(name) + if raw is None: + return default + try: + value = float(raw) + except ValueError: + return default + return value if value >= 0 else default + + +class FailureCooldown: + """Tracks whether token requests are currently suppressed. + + The window doubles on each consecutive failure, from ``cooldown_secs`` up + to ``max_cooldown_secs``, and resets on the first success. + + Thread-safe: the sync helper is called from worker threads and the async + helper from event loops, and one instance may be shared by both. + + Parameters + ---------- + cooldown_secs: + First window, in seconds. Defaults to + ``OAUTH_TOKEN_FAILURE_COOLDOWN_SECS`` or 60. + max_cooldown_secs: + Ceiling the window doubles towards. Defaults to + ``OAUTH_TOKEN_FAILURE_MAX_COOLDOWN_SECS`` or 3600. + """ + + def __init__( + self, + cooldown_secs: float | None = None, + max_cooldown_secs: float | None = None, + ) -> None: + self._base_secs = ( + _read_env_float("OAUTH_TOKEN_FAILURE_COOLDOWN_SECS", _DEFAULT_COOLDOWN_SECS) + if cooldown_secs is None + else cooldown_secs + ) + self._max_secs = ( + _read_env_float( + "OAUTH_TOKEN_FAILURE_MAX_COOLDOWN_SECS", _DEFAULT_MAX_COOLDOWN_SECS + ) + if max_cooldown_secs is None + else max_cooldown_secs + ) + self._consecutive_failures = 0 + self._retry_not_before: float = 0.0 + self._lock = threading.Lock() + + @property + def consecutive_failures(self) -> int: + """Failures since the last success, for logging and tests.""" + with self._lock: + return self._consecutive_failures + + def _window_for(self, failures: int) -> float: + """Return the jittered window length after *failures* consecutive failures.""" + # Doubling is computed on the exponent rather than by repeated + # multiplication so a long outage cannot drift the window. + raw = self._base_secs * (2 ** max(0, failures - 1)) + window = min(raw, self._max_secs) + # secrets rather than random: ruff flags the latter, and the quality + # difference is irrelevant for jitter. + jitter = (secrets.randbelow(1000) / 1000.0) * _JITTER_FRACTION + return window * (1.0 + jitter) + + def remaining(self) -> float: + """Return seconds left in the cooldown, or ``0.0`` when not cooling.""" + with self._lock: + return max(0.0, self._retry_not_before - time.monotonic()) + + def start(self) -> float: + """Open (or re-open) the breaker after a failed token request. + + Returns: + The window length just applied, in seconds. + """ + with self._lock: + self._consecutive_failures += 1 + window = self._window_for(self._consecutive_failures) + self._retry_not_before = time.monotonic() + window + return window + + def hold(self, seconds: float) -> None: + """Suppress requests for at least *seconds* without counting a failure. + + Used when the upstream says how long to wait -- a rate-limit reset or + ``Retry-After`` -- rather than when a request failed. An already + longer window is kept, and escalation is left untouched so the next + genuine failure still starts from where it would have. + """ + if seconds <= 0: + return + with self._lock: + self._retry_not_before = max( + self._retry_not_before, time.monotonic() + seconds + ) + + def clear(self) -> None: + """Close the breaker and reset escalation after a successful request.""" + with self._lock: + self._consecutive_failures = 0 + self._retry_not_before = 0.0 diff --git a/packages/python/ess-auth/src/ess_auth/rate_limit.py b/packages/python/ess-auth/src/ess_auth/rate_limit.py new file mode 100644 index 0000000..754d9da --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/rate_limit.py @@ -0,0 +1,231 @@ +"""Rate-limit awareness from HTTP response headers, for any API caller. + +Many identity platforms report their remaining budget on every response, +success or failure:: + + X-Rate-Limit-Limit: 60 # ceiling for this client/IP/device window + X-Rate-Limit-Remaining: 35 # requests left in the current window + X-Rate-Limit-Reset: 1516307596 # UTC epoch second the window resets + +The quota is often counted per source identity, and a whole Kubernetes +cluster egresses through one NAT address, so ``Remaining`` reflects what the +*fleet* has spent, not one pod. Reading it lets every pod stop before the +shared budget runs out instead of discovering the limit by being refused -- +and sustained refusals are what earn a shared egress address a reputation +block at the edge. + +Nothing here performs I/O. Callers parse the headers they already received +and feed :func:`rate_limit_hold_secs` into their own +:class:`~ess_auth.cooldown.FailureCooldown` via ``hold()``, so the same policy +applies to the token helpers and to any other HTTP client that wants it. +""" + +from __future__ import annotations + +import math +import os +import time +from collections.abc import Mapping +from dataclasses import dataclass +from email.utils import parsedate_to_datetime +from http import HTTPStatus + +# Okta's spelling first; the hyphen-less variant is common elsewhere. +_LIMIT_HEADERS = ("x-rate-limit-limit", "x-ratelimit-limit") +_REMAINING_HEADERS = ("x-rate-limit-remaining", "x-ratelimit-remaining") +_RESET_HEADERS = ("x-rate-limit-reset", "x-ratelimit-reset") + +# Headers worth keeping when a request fails. Identity-platform support will +# ask for a request or reference ID before it looks at a denial; without one, +# an escalation turns into days of exchanging evidence. Credentials and +# cookies are deliberately absent. +DIAGNOSTIC_HEADERS: tuple[str, ...] = ( + "x-okta-request-id", + "x-request-id", + "x-correlation-id", + "akamai-grn", + "x-akamai-request-id", + "x-reference-error", + "server-timing", + *_LIMIT_HEADERS, + *_REMAINING_HEADERS, + *_RESET_HEADERS, + "retry-after", +) +_DIAGNOSTIC_VALUE_MAX_CHARS = 200 + +_DEFAULT_RESERVE_FRACTION = 0.1 +_DEFAULT_MAX_HOLD_SECS = 3600.0 + + +def _read_env_fraction(name: str, default: float) -> float: + """Return a fraction in ``[0, 1)`` from the environment, or *default*.""" + raw = os.environ.get(name) + if raw is None: + return default + try: + value = float(raw) + except ValueError: + return default + return value if 0.0 <= value < 1.0 else default + + +@dataclass(frozen=True) +class RateLimitStatus: + """Budget reported by one response. Any field may be absent.""" + + limit: int | None = None + remaining: int | None = None + reset_epoch: float | None = None + retry_after_secs: float | None = None + + def is_reported(self) -> bool: + """Whether the response carried any rate-limit information at all.""" + return any( + value is not None + for value in ( + self.limit, + self.remaining, + self.reset_epoch, + self.retry_after_secs, + ) + ) + + +def _first_header(headers: Mapping[str, str], names: tuple[str, ...]) -> str | None: + for name in names: + value = headers.get(name) + if value is not None and value.strip(): + return value.strip() + return None + + +def _parse_finite(raw: str) -> float | None: + """Parse *raw* as a finite float; ``inf``/``nan``/overflow are malformed.""" + try: + value = float(raw) + except ValueError: + return None + return value if math.isfinite(value) else None + + +def _parse_int(raw: str | None) -> int | None: + if raw is None: + return None + value = _parse_finite(raw) + if value is None or value < 0: + return None + return int(value) + + +def _parse_retry_after(raw: str | None, now_epoch: float) -> float | None: + """Parse ``Retry-After`` as delta-seconds or an HTTP date.""" + if raw is None: + return None + seconds = _parse_finite(raw) + if seconds is not None: + return max(0.0, seconds) + try: + when = parsedate_to_datetime(raw) + except (TypeError, ValueError, IndexError, OverflowError): + return None + return max(0.0, when.timestamp() - now_epoch) + + +def parse_rate_limit_headers( + headers: Mapping[str, str], *, now_epoch: float | None = None +) -> RateLimitStatus: + """Return the rate-limit budget described by *headers*. + + Args: + headers: Response headers. ``httpx.Headers`` is case-insensitive; a + plain dict must use lower-case names. + now_epoch: Current UTC epoch seconds, for tests. Defaults to now. + + Returns: + A status whose fields are ``None`` where a header was missing or + unparseable. Never raises on malformed values. + """ + now = time.time() if now_epoch is None else now_epoch + reset = _parse_int(_first_header(headers, _RESET_HEADERS)) + return RateLimitStatus( + limit=_parse_int(_first_header(headers, _LIMIT_HEADERS)), + remaining=_parse_int(_first_header(headers, _REMAINING_HEADERS)), + reset_epoch=float(reset) if reset is not None else None, + retry_after_secs=_parse_retry_after(headers.get("retry-after"), now), + ) + + +def _reserve_for(limit: int, reserve_fraction: float) -> int: + """Requests to keep in hand: at least one, else a fraction of the limit.""" + return max(1, math.ceil(limit * reserve_fraction)) + + +def rate_limit_hold_secs( + status: RateLimitStatus, + *, + status_code: int, + now_epoch: float | None = None, + reserve_fraction: float | None = None, + max_hold_secs: float = _DEFAULT_MAX_HOLD_SECS, +) -> float: + """Return how long to stop calling an API after this response. + + Two cases suppress further calls: + + * **Refused for rate.** On HTTP 429, wait for the longer of the time + until the window resets and ``Retry-After``. + * **Nearly exhausted.** On any status, when ``Remaining`` has fallen to + the reserve, wait until the window resets. The reserve is + ``API_RATE_LIMIT_RESERVE_FRACTION`` of the limit (default 10%), and + never less than one request. + + Args: + status: Parsed headers from :func:`parse_rate_limit_headers`. + status_code: HTTP status of the response. + now_epoch: Current UTC epoch seconds, for tests. Defaults to now. + reserve_fraction: Override the reserve fraction. + max_hold_secs: Ceiling on the returned hold, so a bogus reset time + cannot park a caller indefinitely. + + Returns: + Seconds to suppress calls, ``0.0`` when nothing suggests waiting. + """ + now = time.time() if now_epoch is None else now_epoch + until_reset = ( + max(0.0, status.reset_epoch - now) if status.reset_epoch is not None else None + ) + fraction = ( + _read_env_fraction("API_RATE_LIMIT_RESERVE_FRACTION", _DEFAULT_RESERVE_FRACTION) + if reserve_fraction is None + else reserve_fraction + ) + + hold = 0.0 + if status_code == HTTPStatus.TOO_MANY_REQUESTS: + # The longer of the two: a reset that is stale or skewed into the past + # must not cancel an explicit Retry-After. + hold = max(until_reset or 0.0, status.retry_after_secs or 0.0) + elif ( + status.remaining is not None + and status.limit + and status.remaining <= _reserve_for(status.limit, fraction) + and until_reset is not None + ): + hold = until_reset + return min(hold, max_hold_secs) + + +def diagnostic_headers(headers: Mapping[str, str]) -> dict[str, str]: + """Return the request-ID and rate-limit headers worth logging. + + Only names in :data:`DIAGNOSTIC_HEADERS` are copied, so credentials, + cookies, and tokens can never leak through this path. Values are + truncated to keep log lines bounded. + """ + found: dict[str, str] = {} + for name in DIAGNOSTIC_HEADERS: + value = headers.get(name) + if value: + found[name] = value[:_DIAGNOSTIC_VALUE_MAX_CHARS] + return found diff --git a/packages/python/ess-auth/src/ess_auth/test_async_token_helper.py b/packages/python/ess-auth/src/ess_auth/test_async_token_helper.py new file mode 100644 index 0000000..8379cdd --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/test_async_token_helper.py @@ -0,0 +1,446 @@ +"""Tests for the async token helper. + +Style note: synchronous pytest driving ``asyncio.run`` on a fresh loop per +test, mirroring how LangGraph creates a loop per request. Re-using one loop +would mask the cross-loop lock regression these tests protect. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable +from typing import Any +from urllib.parse import parse_qs + +import httpx +import pytest + +from ess_auth import AsyncTokenHelper, TokenClientOptions +from ess_auth.token_helper import ( + TokenEndpointCoolingDownError, + TokenRequestFailedError, +) + + +def _patch_transport( + monkeypatch: pytest.MonkeyPatch, + *, + status: int = 200, + body: dict[str, Any] | None = None, + response_headers: dict[str, str] | None = None, +) -> list[httpx.Request]: + """Replace ``httpx.AsyncClient`` with a stub recording every request.""" + captured: list[httpx.Request] = [] + payload = body if body is not None else {"access_token": "tok", "expires_in": 3600} + + class _StubClient: + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _StubClient: + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def post(self, url: str, **kwargs: Any) -> httpx.Response: + request = httpx.Request( + "POST", url, data=kwargs.get("data"), headers=kwargs.get("headers") + ) + captured.append(request) + # Yield like a real network call would. Without this the holder + # never suspends, so a second caller finds the lock free, never + # waits on it, and the lock never binds to a loop -- which is + # the only state the per-loop tests below are about. + await asyncio.sleep(0) + return httpx.Response( + status, + request=request, + content=json.dumps(payload).encode(), + headers={ + "content-type": "application/json", + **(response_headers or {}), + }, + ) + + monkeypatch.setattr("ess_auth.async_token_helper.httpx.AsyncClient", _StubClient) + return captured + + +def _run_on_fresh_loop(factory: Callable[[], Any]) -> Any: + """Run ``factory()`` on a brand-new event loop and close it.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(factory()) + finally: + loop.close() + + +def _helper(**kwargs: Any) -> AsyncTokenHelper: + return AsyncTokenHelper( + "cid", + "sec", + token_url="https://idp.example.com/token", + options=TokenClientOptions(label="test", **kwargs), + ) + + +class TestCaching: + def test_concurrent_callers_share_one_network_refresh( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + captured = _patch_transport(monkeypatch) + helper = _helper() + + async def _exercise() -> tuple[str, str]: + return await asyncio.gather(helper.fetch_token(), helper.fetch_token()) + + first, second = _run_on_fresh_loop(_exercise) + assert first == second == "tok" + assert len(captured) == 1 + + def test_refresh_succeeds_across_two_event_loops( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A module-scoped helper outlives the loop that first used it. + + Guards the ``Lock is bound to a different event loop`` failure that + motivated the per-loop lock cache. The lock only binds to a loop when + a coroutine has to *wait* on it, so both loops must actually contend: + each pair below is concurrent, and the cache is expired in between so + the second pair refreshes rather than returning the first loop's + token. + """ + _patch_transport(monkeypatch) + helper = _helper() + + async def _contend() -> Any: + # gather() on a cold cache forces one caller to wait on the lock. + return await asyncio.gather(helper.refresh(), helper.refresh()) + + assert _run_on_fresh_loop(_contend) == ["tok", "tok"] + + # Expire the cache so the second loop cannot short-circuit via + # _cached_token(); without this the lock is never contended twice and + # the cross-loop regression would go unnoticed. + helper._expires_at = 0.0 # noqa: SLF001 -- simulating token expiry + + assert _run_on_fresh_loop(_contend) == ["tok", "tok"] + + def test_short_lived_token_is_still_cached( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A lifetime at or below the refresh margin must not defeat the cache. + + Subtracting the margin unclamped puts the expiry in the past, so + every caller refreshes and one token request per call returns -- + the amplification the cooldown exists to stop, through the cache. + """ + captured = _patch_transport( + monkeypatch, body={"access_token": "tok", "expires_in": 30} + ) + helper = _helper(refresh_margin=120) + + async def _exercise() -> None: + await helper.fetch_token() + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + assert len(captured) == 1 + + @pytest.mark.parametrize("expires_in", [0, -1]) + def test_already_expired_token_is_rejected( + self, monkeypatch: pytest.MonkeyPatch, expires_in: int + ) -> None: + """A dead token must not be cached by the minimum-lifetime floor.""" + _patch_transport( + monkeypatch, body={"access_token": "tok", "expires_in": expires_in} + ) + helper = _helper() + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + # Treated like any other unusable response: the breaker is open. + assert helper._cooldown.remaining() > 0 # noqa: SLF001 -- cooldown under test + + @pytest.mark.parametrize("access_token", ["", None, 12345]) + def test_unusable_access_token_is_rejected( + self, monkeypatch: pytest.MonkeyPatch, access_token: object + ) -> None: + """An empty or non-string token must not be cached.""" + _patch_transport( + monkeypatch, body={"access_token": access_token, "expires_in": 3600} + ) + helper = _helper() + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + assert helper._cooldown.remaining() > 0 # noqa: SLF001 -- cooldown under test + + +class TestLockCache: + def test_closed_loops_are_evicted(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A loop per request must not make the cache grow without bound. + + A contended ``asyncio.Lock`` strongly references its loop, so a + cache holding locks by value would pin its own weak keys forever. + """ + _patch_transport(monkeypatch) + helper = _helper() + + async def _contend() -> Any: + return await asyncio.gather(helper.refresh(), helper.refresh()) + + for _ in range(3): + _run_on_fresh_loop(_contend) + helper._expires_at = 0.0 # noqa: SLF001 -- simulating token expiry + + assert len(helper._async_locks) <= 1 # noqa: SLF001 -- cache under test + + +class TestCooldown: + def test_timeout_opens_the_cooldown(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A hung endpoint is a failed attempt, not a free pass. + + ``asyncio.wait_for`` fires outside ``_do_refresh``, so the failure + has to be recorded in ``refresh`` itself or the next caller starts + a fresh attempt against an endpoint already known to be stuck. + """ + + class _HangingClient: + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _HangingClient: + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def post(self, _url: str, **_kwargs: Any) -> httpx.Response: + await asyncio.sleep(3600) + raise AssertionError("unreachable") + + monkeypatch.setattr( + "ess_auth.async_token_helper.httpx.AsyncClient", _HangingClient + ) + monkeypatch.setattr( + "ess_auth.async_token_helper._DEFAULT_TOTAL_TIMEOUT_SECS", 0.01 + ) + helper = _helper() + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + with pytest.raises(TokenEndpointCoolingDownError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + + def test_failure_suppresses_the_next_request( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + captured = _patch_transport( + monkeypatch, status=403, body={"error": "access_denied"} + ) + helper = _helper() + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + with pytest.raises(TokenEndpointCoolingDownError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + assert len(captured) == 1 + + def test_cooldown_expiry_allows_another_attempt( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + captured = _patch_transport( + monkeypatch, status=403, body={"error": "access_denied"} + ) + helper = _helper(cooldown_secs=60.0) + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + clock["now"] = 1030.0 + with pytest.raises(TokenEndpointCoolingDownError): + await helper.fetch_token() + clock["now"] = 1100.0 # clear of the jittered window (<=72s) + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + denied_then_retried = 2 + assert len(captured) == denied_then_retried + + +class TestFullTokenLifetime: + """A failed proactive refresh must not throw away a still-valid token. + + Tokens refresh ``refresh_margin`` seconds early. Before this, a denial in + that window failed the caller even though the cached token had time + left, so every refresh denial became a user-visible tool failure. + """ + + def test_stale_token_is_served_when_refresh_is_denied( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + _patch_transport(monkeypatch) + helper = _helper(refresh_margin=60) + assert _run_on_fresh_loop(helper.fetch_token) == "tok" + + denied = _patch_transport(monkeypatch, status=403, body={"error": "x"}) + clock["now"] = 1000.0 + 3600 - 30 # inside the margin, before expiry + + assert _run_on_fresh_loop(helper.fetch_token) == "tok" + assert len(denied) == 1 + # Still suppressed: the next caller gets the stale token, no request. + assert _run_on_fresh_loop(helper.fetch_token) == "tok" + assert len(denied) == 1 + + def test_expired_token_is_not_served(self, monkeypatch: pytest.MonkeyPatch) -> None: + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + _patch_transport(monkeypatch) + helper = _helper(refresh_margin=60) + _run_on_fresh_loop(helper.fetch_token) + + _patch_transport(monkeypatch, status=403, body={"error": "x"}) + clock["now"] = 1000.0 + 3600 + 1 + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + + +class TestRateLimitHeaders: + def test_429_reset_extends_the_cooldown( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Honour the server's reset rather than our shorter first window.""" + now_epoch = 1_700_000_000.0 + monkeypatch.setattr("ess_auth.rate_limit.time.time", lambda: now_epoch) + _patch_transport( + monkeypatch, + status=429, + body={"error": "too_many_requests"}, + response_headers={ + "X-Rate-Limit-Limit": "60", + "X-Rate-Limit-Remaining": "0", + "X-Rate-Limit-Reset": str(int(now_epoch + 600)), + }, + ) + helper = _helper(cooldown_secs=60.0) + + async def _exercise() -> None: + with pytest.raises(TokenRequestFailedError): + await helper.fetch_token() + + _run_on_fresh_loop(_exercise) + first_window_ceiling = 72.0 + assert helper._cooldown.remaining() > first_window_ceiling # noqa: SLF001 + + def test_nearly_spent_budget_suppresses_the_next_refresh( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + now_epoch = 1_700_000_000.0 + monkeypatch.setattr("ess_auth.rate_limit.time.time", lambda: now_epoch) + _patch_transport( + monkeypatch, + response_headers={ + "X-Rate-Limit-Limit": "60", + "X-Rate-Limit-Remaining": "2", + "X-Rate-Limit-Reset": str(int(now_epoch + 45)), + }, + ) + helper = _helper() + + assert _run_on_fresh_loop(helper.fetch_token) == "tok" + assert helper._cooldown.remaining() > 0 # noqa: SLF001 + assert helper._cooldown.consecutive_failures == 0 # noqa: SLF001 + + def test_failure_carries_request_id_for_upstream_support( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _patch_transport( + monkeypatch, + status=403, + body={"error": "access_denied"}, + response_headers={"X-Okta-Request-Id": "req-123"}, + ) + helper = _helper() + + async def _exercise() -> TokenRequestFailedError: + with pytest.raises(TokenRequestFailedError) as err: + await helper.fetch_token() + return err.value + + error = _run_on_fresh_loop(_exercise) + assert error.diagnostics["x-okta-request-id"] == "req-123" + + +class TestClientIdentification: + def test_identification_header_comes_from_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_NAME", "X-Client-App") + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_VALUE", "agreed") + captured = _patch_transport(monkeypatch) + + _run_on_fresh_loop(_helper().fetch_token) + + assert captured[0].headers["X-Client-App"] == "agreed" + + def test_explicit_headers_override_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_NAME", "X-Client-App") + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_VALUE", "agreed") + captured = _patch_transport(monkeypatch) + + _run_on_fresh_loop(_helper(request_headers={}).fetch_token) + + assert "X-Client-App" not in captured[0].headers + + +class TestClientAuthentication: + def test_credentials_in_body_when_requested( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Some authorization servers want the credentials in the form body.""" + captured = _patch_transport(monkeypatch) + helper = _helper(credentials_in_body=True) + _run_on_fresh_loop(helper.fetch_token) + + form = parse_qs(captured[0].content.decode()) + assert form["client_id"] == ["cid"] + assert form["client_secret"] == ["sec"] + + def test_basic_auth_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured = _patch_transport(monkeypatch) + helper = _helper() + _run_on_fresh_loop(helper.fetch_token) + + assert "client_secret" not in parse_qs(captured[0].content.decode()) + + def test_scope_is_omitted_when_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured = _patch_transport(monkeypatch) + _run_on_fresh_loop(_helper().fetch_token) + + assert "scope" not in parse_qs(captured[0].content.decode()) diff --git a/packages/python/ess-auth/src/ess_auth/test_client_identity.py b/packages/python/ess-auth/src/ess_auth/test_client_identity.py new file mode 100644 index 0000000..c764327 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/test_client_identity.py @@ -0,0 +1,61 @@ +"""Tests for the edge identification header read from the environment.""" + +from __future__ import annotations + +import logging + +import pytest + +from ess_auth.client_identity import ( + HEADER_NAME_ENV, + HEADER_VALUE_ENV, + client_identity_headers, +) + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(HEADER_NAME_ENV, raising=False) + monkeypatch.delenv(HEADER_VALUE_ENV, raising=False) + + +def test_unset_sends_nothing() -> None: + assert client_identity_headers() == {} + + +def test_both_set_sends_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(HEADER_NAME_ENV, "X-Client-App") + monkeypatch.setenv(HEADER_VALUE_ENV, "agreed-value") + + assert client_identity_headers() == {"X-Client-App": "agreed-value"} + + +def test_half_configured_sends_nothing_and_warns( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setenv(HEADER_NAME_ENV, "X-Client-App") + + with caplog.at_level(logging.WARNING): + assert client_identity_headers() == {} + assert HEADER_VALUE_ENV in caplog.text + + +@pytest.mark.parametrize("name", ["Authorization", "bad name", "X:Colon"]) +def test_reserved_or_invalid_names_are_refused( + monkeypatch: pytest.MonkeyPatch, name: str +) -> None: + monkeypatch.setenv(HEADER_NAME_ENV, name) + monkeypatch.setenv(HEADER_VALUE_ENV, "agreed-value") + + assert client_identity_headers() == {} + + +def test_value_is_never_logged( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setenv(HEADER_NAME_ENV, "Authorization") + monkeypatch.setenv(HEADER_VALUE_ENV, "agreed-value") + + with caplog.at_level(logging.DEBUG): + client_identity_headers() + assert "agreed-value" not in caplog.text diff --git a/packages/python/ess-auth/src/ess_auth/test_cooldown.py b/packages/python/ess-auth/src/ess_auth/test_cooldown.py new file mode 100644 index 0000000..7f4975c --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/test_cooldown.py @@ -0,0 +1,107 @@ +"""Tests for the failure cooldown's escalation behaviour. + +A fixed window is weak during a sustained outage. An identity platform that +refuses a cluster's shared egress address for 90 minutes straight will, at a +flat 60s window, still admit roughly 90 attempts per event loop, each one +feeding the rate limiter doing the refusing. Doubling towards an hourly cap +cuts the same period to single figures. +""" + +from __future__ import annotations + +from ess_auth.cooldown import _JITTER_FRACTION, FailureCooldown + + +def _cooldown(**kwargs: float) -> FailureCooldown: + return FailureCooldown(cooldown_secs=60.0, max_cooldown_secs=3600.0, **kwargs) + + +class TestEscalation: + def test_window_doubles_on_consecutive_failures(self) -> None: + cooldown = _cooldown() + windows = [cooldown.start() for _ in range(5)] + + # Jitter only ever extends a window, so each sits in [raw, raw * 1.2]. + for attempt, window in enumerate(windows, start=1): + raw = 60.0 * (2 ** (attempt - 1)) + assert raw <= window <= raw * (1 + _JITTER_FRACTION), ( + f"attempt {attempt}: {window} outside [{raw}, {raw * 1.2}]" + ) + + def test_window_is_capped(self) -> None: + """Without a ceiling, doubling would eventually park the breaker shut.""" + cooldown = _cooldown() + for _ in range(20): + window = cooldown.start() + + assert window <= 3600.0 * (1 + _JITTER_FRACTION) + + def test_success_resets_escalation(self) -> None: + """A blip must not leave the breaker on a long window afterwards.""" + failures_before_success = 4 + cooldown = _cooldown() + for _ in range(failures_before_success): + cooldown.start() + assert cooldown.consecutive_failures == failures_before_success + + cooldown.clear() + assert cooldown.consecutive_failures == 0 + assert cooldown.remaining() == 0.0 + + first_window_again = cooldown.start() + assert first_window_again <= 60.0 * (1 + _JITTER_FRACTION) + + def test_escalation_beats_a_flat_window_over_a_long_outage(self) -> None: + """The property that motivated escalation, stated as a test. + + Ninety minutes of continuous refusal should cost single-figure + attempts, not ~90. + """ + outage_secs = 90 * 60 + cooldown = _cooldown() + + elapsed = 0.0 + attempts = 0 + while elapsed < outage_secs: + elapsed += cooldown.start() + attempts += 1 + + flat_window_attempts = outage_secs / 60 + single_figures = 10 + worthwhile_reduction = 5 + assert attempts < single_figures + assert attempts < flat_window_attempts / worthwhile_reduction + + +class TestHold: + def test_hold_suppresses_without_counting_a_failure(self) -> None: + """An upstream-requested wait is not evidence the endpoint is broken.""" + cooldown = _cooldown() + + cooldown.hold(30.0) + + assert cooldown.remaining() > 0 + assert cooldown.consecutive_failures == 0 + + def test_hold_never_shortens_an_open_window(self) -> None: + cooldown = _cooldown() + window = cooldown.start() + + cooldown.hold(1.0) + + assert cooldown.remaining() > window - 1.0 + + def test_non_positive_hold_is_ignored(self) -> None: + cooldown = _cooldown() + + cooldown.hold(0.0) + + assert cooldown.remaining() == 0.0 + + +class TestJitter: + def test_jitter_desynchronises_a_fleet(self) -> None: + """Pods that start together must not probe in lockstep.""" + windows = {_cooldown().start() for _ in range(50)} + + assert len(windows) > 1 diff --git a/packages/python/ess-auth/src/ess_auth/test_rate_limit.py b/packages/python/ess-auth/src/ess_auth/test_rate_limit.py new file mode 100644 index 0000000..b067837 --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/test_rate_limit.py @@ -0,0 +1,189 @@ +"""Tests for rate-limit header parsing and the hold policy. + +The header names and the example values follow the convention published by +Okta-backed identity platforms: ``X-Rate-Limit-Limit``, +``X-Rate-Limit-Remaining``, and ``X-Rate-Limit-Reset`` (UTC epoch seconds). +""" + +from __future__ import annotations + +import httpx +import pytest + +from ess_auth.rate_limit import ( + RateLimitStatus, + diagnostic_headers, + parse_rate_limit_headers, + rate_limit_hold_secs, +) + +_NOW = 1_516_307_536.0 +_RESET = 1_516_307_596 # sixty seconds after _NOW + + +def _headers(**values: str) -> httpx.Headers: + return httpx.Headers({k.replace("_", "-"): v for k, v in values.items()}) + + +class TestParse: + def test_reads_the_okta_headers(self) -> None: + status = parse_rate_limit_headers( + _headers( + X_Rate_Limit_Limit="60", + X_Rate_Limit_Remaining="35", + X_Rate_Limit_Reset=str(_RESET), + ), + now_epoch=_NOW, + ) + + assert status == RateLimitStatus( + limit=60, remaining=35, reset_epoch=float(_RESET) + ) + assert status.is_reported() + + def test_absent_headers_report_nothing(self) -> None: + status = parse_rate_limit_headers(httpx.Headers(), now_epoch=_NOW) + + assert not status.is_reported() + + def test_malformed_values_are_ignored_not_raised(self) -> None: + status = parse_rate_limit_headers( + _headers(X_Rate_Limit_Limit="lots", X_Rate_Limit_Remaining="-3"), + now_epoch=_NOW, + ) + + assert status.limit is None + assert status.remaining is None + + @pytest.mark.parametrize("raw", ["inf", "1e999", "nan", "-inf"]) + def test_non_finite_values_are_ignored_not_raised(self, raw: str) -> None: + """An upstream header must never abort token acquisition.""" + status = parse_rate_limit_headers( + _headers( + X_Rate_Limit_Limit=raw, + X_Rate_Limit_Remaining=raw, + X_Rate_Limit_Reset=raw, + Retry_After=raw, + ), + now_epoch=_NOW, + ) + + assert not status.is_reported() + + def test_retry_after_seconds(self) -> None: + status = parse_rate_limit_headers(_headers(Retry_After="17"), now_epoch=_NOW) + + retry_after = 17.0 + assert status.retry_after_secs == retry_after + + def test_retry_after_http_date(self) -> None: + # 2018-01-18T20:33:16Z is _RESET. + status = parse_rate_limit_headers( + _headers(Retry_After="Thu, 18 Jan 2018 20:33:16 GMT"), now_epoch=_NOW + ) + + assert status.retry_after_secs == pytest.approx(60.0) + + +class TestHold: + def test_healthy_budget_does_not_hold(self) -> None: + status = RateLimitStatus(limit=60, remaining=35, reset_epoch=_RESET) + + assert rate_limit_hold_secs(status, status_code=200, now_epoch=_NOW) == 0.0 + + def test_nearly_spent_budget_holds_until_reset(self) -> None: + """Stop before the shared egress budget runs out, not after.""" + status = RateLimitStatus(limit=60, remaining=6, reset_epoch=_RESET) + + hold = rate_limit_hold_secs(status, status_code=200, now_epoch=_NOW) + + assert hold == pytest.approx(60.0) + + def test_reserve_is_never_below_one_request(self) -> None: + """A tiny limit still keeps the last request in hand.""" + status = RateLimitStatus(limit=5, remaining=1, reset_epoch=_RESET) + + hold = rate_limit_hold_secs( + status, status_code=200, now_epoch=_NOW, reserve_fraction=0.0 + ) + + assert hold > 0 + + def test_429_holds_until_reset(self) -> None: + status = RateLimitStatus(limit=60, remaining=0, reset_epoch=_RESET) + + hold = rate_limit_hold_secs(status, status_code=429, now_epoch=_NOW) + + assert hold == pytest.approx(60.0) + + def test_429_stale_reset_does_not_cancel_retry_after(self) -> None: + """A reset skewed into the past must not drop an explicit wait.""" + status = RateLimitStatus( + limit=60, remaining=0, reset_epoch=_NOW - 30, retry_after_secs=45.0 + ) + + hold = rate_limit_hold_secs(status, status_code=429, now_epoch=_NOW) + + retry_after = 45.0 + assert hold == retry_after + + def test_429_waits_for_the_longer_of_reset_and_retry_after(self) -> None: + status = RateLimitStatus( + limit=60, remaining=0, reset_epoch=_RESET, retry_after_secs=10.0 + ) + + hold = rate_limit_hold_secs(status, status_code=429, now_epoch=_NOW) + + assert hold == pytest.approx(60.0) + + def test_429_without_reset_uses_retry_after(self) -> None: + status = RateLimitStatus(retry_after_secs=12.0) + + hold = rate_limit_hold_secs(status, status_code=429, now_epoch=_NOW) + + retry_after = 12.0 + assert hold == retry_after + + def test_hold_is_capped(self) -> None: + """A bogus reset far in the future must not park callers forever.""" + status = RateLimitStatus(limit=60, remaining=0, reset_epoch=_NOW + 10**9) + + hold = rate_limit_hold_secs( + status, status_code=429, now_epoch=_NOW, max_hold_secs=3600.0 + ) + + cap = 3600.0 + assert hold == cap + + def test_reset_in_the_past_does_not_hold(self) -> None: + status = RateLimitStatus(limit=60, remaining=0, reset_epoch=_NOW - 5) + + assert rate_limit_hold_secs(status, status_code=200, now_epoch=_NOW) == 0.0 + + def test_reserve_fraction_is_tunable_from_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("API_RATE_LIMIT_RESERVE_FRACTION", "0.5") + status = RateLimitStatus(limit=60, remaining=25, reset_epoch=_RESET) + + assert rate_limit_hold_secs(status, status_code=200, now_epoch=_NOW) > 0 + + +class TestDiagnosticHeaders: + def test_keeps_request_ids_and_budget(self) -> None: + headers = _headers( + X_Okta_Request_Id="abc123", + X_Rate_Limit_Remaining="0", + Akamai_GRN="0.1234.5678", + ) + + assert diagnostic_headers(headers) == { + "x-okta-request-id": "abc123", + "akamai-grn": "0.1234.5678", + "x-rate-limit-remaining": "0", + } + + def test_never_copies_credentials_or_cookies(self) -> None: + headers = _headers(Set_Cookie="sid=secret", Authorization="Bearer secret") + + assert diagnostic_headers(headers) == {} diff --git a/packages/python/ess-auth/src/ess_auth/test_token_helper.py b/packages/python/ess-auth/src/ess_auth/test_token_helper.py new file mode 100644 index 0000000..e51f64f --- /dev/null +++ b/packages/python/ess-auth/src/ess_auth/test_token_helper.py @@ -0,0 +1,344 @@ +"""Tests for the sync token helper's cooldown and scope behaviour. + +The cooldown exists because of an observed production incident: a denied +token request was not cached, so every subsequent caller retried immediately +against an endpoint that rate-limits per source IP, turning an intermittent +denial into a sustained one. +""" + +from __future__ import annotations + +import json +import threading +import time +from typing import Any +from urllib.parse import parse_qs + +import httpx +import pytest + +from ess_auth import token_helper +from ess_auth.token_helper import ( + AuthenticationError, + TokenEndpointCoolingDownError, + TokenHelper, + TokenRequestFailedError, +) + + +def _client( + captured: list[httpx.Request], + *, + status: int = 200, + body: dict[str, Any] | None, + response_headers: dict[str, str] | None = None, +) -> httpx.Client: + """Return an httpx.Client whose transport records and canned-responds.""" + payload = body if body is not None else {"access_token": "tok", "expires_in": 3600} + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + status, + content=json.dumps(payload).encode(), + headers={"content-type": "application/json", **(response_headers or {})}, + ) + + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def _helper(**kwargs: Any) -> TokenHelper: + return TokenHelper("cid", "sec", token_url="https://idp.example.com/token", **kwargs) + + +class TestCooldown: + def test_failure_suppresses_the_next_request(self) -> None: + captured: list[httpx.Request] = [] + helper = _helper() + http = _client(captured, status=403, body={"error": "access_denied"}) + + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + with pytest.raises(TokenEndpointCoolingDownError): + helper.fetch_token(http) + + # The second call never reached the endpoint. + assert len(captured) == 1 + + def test_cooldown_expiry_allows_another_attempt( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + captured: list[httpx.Request] = [] + helper = _helper() + http = _client(captured, status=403, body={"error": "access_denied"}) + + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + monkeypatch.setattr(token_helper.time, "monotonic", lambda: clock["now"]) + + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + + clock["now"] = 1030.0 # still inside the window + with pytest.raises(TokenEndpointCoolingDownError): + helper.fetch_token(http) + + clock["now"] = 1100.0 # clear of the jittered window (<=72s) + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + + denied_then_retried = 2 + assert len(captured) == denied_then_retried + + def test_success_clears_the_cooldown(self) -> None: + helper = _helper() + denied: list[httpx.Request] = [] + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(_client(denied, status=403, body={"e": "x"})) + + # A fresh helper state would hide a stuck cooldown, so reuse this one. + helper._cooldown.clear() # noqa: SLF001 -- simulating the window passing + granted: list[httpx.Request] = [] + assert helper.fetch_token(_client(granted, body=None)) == "tok" + assert helper._cooldown.remaining() == 0.0 # noqa: SLF001 + + def test_concurrent_threads_make_one_request(self) -> None: + """The cooldown caps attempts per client, so the check must be atomic. + + Two worker threads on a cold cache can otherwise both observe a + closed breaker before either failure opens it, and both reach the + endpoint the cooldown is meant to shield. + """ + captured: list[httpx.Request] = [] + helper = _helper() + started = threading.Barrier(2) + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + # Widen the window in which an unserialised second caller would + # still see a closed breaker, so the regression cannot pass by + # winning a microsecond-wide race. + time.sleep(0.05) + return httpx.Response( + 403, + content=json.dumps({"error": "access_denied"}).encode(), + headers={"content-type": "application/json"}, + ) + + http = httpx.Client(transport=httpx.MockTransport(handler)) + + errors: list[Exception] = [] + + def _attempt() -> None: + try: + started.wait(timeout=5) + helper.fetch_token(http) + except AuthenticationError: + # Either a live refusal (the winner) or a suppressed + # attempt (the loser); both are the expected outcome. + pass + except Exception as exc: # noqa: BLE001 -- surfaced in the main thread + errors.append(exc) + + threads = [threading.Thread(target=_attempt) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + # A worker that hung or died would leave the request-count + # assertion below trivially satisfied, so check it landed. + assert not thread.is_alive(), "worker thread did not finish" + assert errors == [], f"worker raised unexpectedly: {errors}" + + # The loser of the race is refused by the cooldown, not the endpoint. + assert len(captured) == 1 + + +class TestCaching: + def test_short_lived_token_is_still_cached(self) -> None: + """A lifetime at or below the refresh margin must not defeat the cache. + + Treating such a token as expired on arrival means one token request + per call, which is the amplification the cooldown exists to stop. + """ + captured: list[httpx.Request] = [] + helper = _helper(refresh_margin=120) + http = _client(captured, body={"access_token": "tok", "expires_in": 30}) + + assert helper.fetch_token(http) == "tok" + assert helper.fetch_token(http) == "tok" + assert len(captured) == 1 + + @pytest.mark.parametrize("expires_in", [0, -1]) + def test_already_expired_token_is_rejected(self, expires_in: int) -> None: + """A dead token must not be cached by the minimum-lifetime floor. + + The floor keeps a short-but-valid token usable; it must not make + an already-expired one look usable for a second. + """ + helper = _helper() + http = _client([], body={"access_token": "tok", "expires_in": expires_in}) + + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + # Treated like any other unusable response: the breaker is open. + assert helper._cooldown.remaining() > 0 # noqa: SLF001 -- cooldown under test + + +class TestErrorDetail: + """Coverage for the shared summarizer. + + ``error_detail`` lives alongside the sync helper and is used by the async + one too, so its behaviour is pinned down here once. + """ + + def test_prefers_the_oauth_error_fields(self) -> None: + response = httpx.Response( + 403, + json={"error": "invalid_scope", "error_description": "bad scope"}, + ) + + assert token_helper.error_detail(response) == "invalid_scope bad scope" + + def test_falls_back_to_raw_text_for_non_json(self) -> None: + """An edge proxy denial is HTML, not an OAuth error body.""" + response = httpx.Response(403, text="<html> Forbidden\n by edge </html>") + + # Whitespace is collapsed so the excerpt stays one log-friendly line. + assert token_helper.error_detail(response) == "<html> Forbidden by edge </html>" + + def test_is_truncated_to_the_cap(self) -> None: + response = httpx.Response(403, text="x" * 1000) + + detail = token_helper.error_detail(response) + + assert len(detail) == token_helper._ERROR_DETAIL_MAX_CHARS # noqa: SLF001 -- cap under test + + +class TestMalformedResponses: + @pytest.mark.parametrize("access_token", ["", None, 12345]) + def test_unusable_access_token_is_rejected(self, access_token: object) -> None: + """An empty or non-string token must not be cached. + + It would otherwise clear the cooldown and leave callers sending + ``Bearer`` followed by nothing useful. + """ + helper = _helper() + http = _client([], body={"access_token": access_token, "expires_in": 3600}) + + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + assert helper._cooldown.remaining() > 0 # noqa: SLF001 -- cooldown under test + + def test_malformed_2xx_honours_a_spent_rate_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A spent budget on an unusable 2xx must hold until the reset. + + The generic failure window is ~60s; the reset here is 10 minutes out. + """ + now_epoch = 1_700_000_000.0 + monkeypatch.setattr("ess_auth.rate_limit.time.time", lambda: now_epoch) + helper = _helper() + http = _client( + [], + body={"access_token": "", "expires_in": 3600}, + response_headers={ + "X-Rate-Limit-Limit": "60", + "X-Rate-Limit-Remaining": "0", + "X-Rate-Limit-Reset": str(int(now_epoch + 600)), + }, + ) + + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(http) + + first_window_ceiling = 72.0 + assert helper._cooldown.remaining() > first_window_ceiling # noqa: SLF001 + + +class TestFailureDetail: + def test_status_and_detail_are_exposed_for_caller_logging(self) -> None: + """Callers log their own context; they should not re-read the response.""" + helper = _helper() + http = _client([], status=403, body={"error": "access_denied"}) + + with pytest.raises(TokenRequestFailedError) as err: + helper.fetch_token(http) + + forbidden = 403 + assert err.value.status_code == forbidden + assert "access_denied" in err.value.detail + + def test_request_id_is_exposed_for_upstream_support(self) -> None: + helper = _helper() + http = _client( + [], + status=403, + body={"error": "access_denied"}, + response_headers={"X-Okta-Request-Id": "req-123"}, + ) + + with pytest.raises(TokenRequestFailedError) as err: + helper.fetch_token(http) + + assert err.value.diagnostics == {"x-okta-request-id": "req-123"} + + +class TestFullTokenLifetime: + def test_stale_token_is_served_when_refresh_is_denied( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + helper = _helper(refresh_margin=60) + assert helper.fetch_token(_client([], body=None)) == "tok" + + denied: list[httpx.Request] = [] + clock["now"] = 1000.0 + 3600 - 30 # inside the margin, before expiry + http = _client(denied, status=403, body={"error": "x"}) + + assert helper.fetch_token(http) == "tok" + assert helper.fetch_token(http) == "tok" + assert len(denied) == 1 + + def test_expired_token_is_not_served(self, monkeypatch: pytest.MonkeyPatch) -> None: + clock = {"now": 1000.0} + monkeypatch.setattr("ess_auth.cooldown.time.monotonic", lambda: clock["now"]) + helper = _helper(refresh_margin=60) + helper.fetch_token(_client([], body=None)) + + clock["now"] = 1000.0 + 3600 + 1 + with pytest.raises(TokenRequestFailedError): + helper.fetch_token(_client([], status=403, body={"error": "x"})) + + +class TestClientIdentification: + def test_identification_header_comes_from_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_NAME", "X-Client-App") + monkeypatch.setenv("OAUTH_TOKEN_CLIENT_HEADER_VALUE", "agreed") + captured: list[httpx.Request] = [] + + _helper().fetch_token(_client(captured, body=None)) + + assert captured[0].headers["X-Client-App"] == "agreed" + + +class TestScope: + def test_scope_is_sent_when_set(self) -> None: + captured: list[httpx.Request] = [] + helper = _helper(scope="example-scope") + helper.fetch_token(_client(captured, body=None)) + + form = parse_qs(captured[0].content.decode()) + assert form["scope"] == ["example-scope"] + + def test_scope_is_omitted_when_empty(self) -> None: + """Some authorization servers reject an empty scope parameter.""" + captured: list[httpx.Request] = [] + helper = _helper() + helper.fetch_token(_client(captured, body=None)) + + assert "scope" not in parse_qs(captured[0].content.decode()) diff --git a/packages/python/ess-auth/src/ess_auth/token_helper.py b/packages/python/ess-auth/src/ess_auth/token_helper.py index f32f04c..346b334 100644 --- a/packages/python/ess-auth/src/ess_auth/token_helper.py +++ b/packages/python/ess-auth/src/ess_auth/token_helper.py @@ -3,11 +3,19 @@ from __future__ import annotations import logging +import threading import time import httpx +from .client_identity import client_identity_headers from .config import EXPIRATION_WINDOW +from .cooldown import FailureCooldown +from .rate_limit import ( + diagnostic_headers, + parse_rate_limit_headers, + rate_limit_hold_secs, +) logger = logging.getLogger(__name__) @@ -16,6 +24,148 @@ class AuthenticationError(Exception): """Raised when a token request fails.""" +class TokenEndpointCoolingDownError(AuthenticationError): + """Raised when a token request is suppressed after a recent failure. + + Distinct from a live rejection so callers can tell "we did not ask" from + "we asked and were refused". Consumers that must not crash on this should + catch it explicitly; see ``ess_auth.cooldown`` for why the suppression + exists. + """ + + +class TokenRequestFailedError(AuthenticationError): + """Raised when the token endpoint rejects or fails a request. + + Carries the HTTP status and a short response excerpt so callers can log + their own context-rich diagnostics without re-reading the response. + + Attributes + ---------- + status_code: + HTTP status returned by the token endpoint, when there was one. + detail: + Truncated response body, useful for distinguishing an OAuth JSON + error from an HTML denial emitted by an edge proxy. + diagnostics: + Request-ID and rate-limit response headers (see + :data:`ess_auth.rate_limit.DIAGNOSTIC_HEADERS`). Identity-platform + support needs the request ID to trace a denial. + """ + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + detail: str = "", + diagnostics: dict[str, str] | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.detail = detail + self.diagnostics = diagnostics or {} + + +# Cap on token-endpoint error text copied into exceptions and logs. +_ERROR_DETAIL_MAX_CHARS = 300 + +# Floor on how long a freshly issued token stays cached. Subtracting the +# refresh margin from a token whose lifetime is at or below that margin would +# otherwise place the expiry at or before now, so every caller would refresh +# on every call -- the per-request amplification the cooldown exists to stop, +# reintroduced through the cache. Shared with the async helper. +MIN_CACHE_LIFETIME_SECS = 1.0 + +# A cached token is refreshed ``refresh_margin`` seconds before it expires. +# When that refresh fails, the old token is still good until real expiry and +# is served instead of failing the caller. This margin keeps a token from +# being handed out so close to expiry that it dies in flight. +STALE_TOKEN_SAFETY_SECS = 5.0 + + +def apply_rate_limit_headers( + cooldown: FailureCooldown, response: httpx.Response, *, label: str +) -> None: + """Suppress further token requests when *response* says to wait. + + Shared by the sync and async helpers. See :mod:`ess_auth.rate_limit`. + """ + status = parse_rate_limit_headers(response.headers) + if not status.is_reported(): + return + hold = rate_limit_hold_secs(status, status_code=response.status_code) + if hold <= 0: + return + cooldown.hold(hold) + logger.warning( + "OAUTH_RATE_LIMIT_HOLD label=%s status=%d limit=%s remaining=%s hold_s=%.0f", + label, + response.status_code, + status.limit, + status.remaining, + hold, + ) + + +def parse_access_token(payload: dict) -> str: + """Return the access token from *payload*, rejecting unusable values. + + A missing key is already a ``KeyError``, but an empty string, ``null``, + or a number is not: it would be cached, clear the cooldown, and leave + callers sending ``Bearer`` with nothing useful after it. Call from + inside the body-parsing ``try`` so the ``ValueError`` lands on the + malformed-response path. + + Raises: + ValueError: If the token is not a non-empty string. + """ + access_token = payload["access_token"] + if not isinstance(access_token, str) or not access_token: + raise ValueError( + f"access_token was {type(access_token).__name__}, " + "expected a non-empty string" + ) + return access_token + + +def parse_expires_in(payload: dict) -> int: + """Return the token lifetime from *payload*, rejecting dead tokens. + + A non-positive ``expires_in`` describes a token that has already + expired. Caching it -- even for the one-second floor -- would send a + dead credential upstream, so it is treated as an unusable response. + Call from inside the body-parsing ``try`` so the ``ValueError`` lands + on the malformed-response path that opens the failure cooldown. + + Raises: + ValueError: If ``expires_in`` is not a positive integer. + """ + expires_in = int(payload.get("expires_in", 3600)) + if expires_in <= 0: + raise ValueError(f"expires_in was {expires_in}, expected a positive lifetime") + return expires_in + + +def error_detail(response: httpx.Response) -> str: + """Summarize a token-endpoint error response for logging. + + Prefers the OAuth2 ``error``/``error_description`` fields and falls back + to raw text, which is what an edge proxy returning HTML will produce. + """ + try: + body = response.json() + except ValueError: + body = None + + fields = "" + if isinstance(body, dict): + fields = f"{body.get('error', '')} {body.get('error_description', '')}" + + detail = " ".join(fields.split()) or " ".join(response.text.split()) + return detail[:_ERROR_DETAIL_MAX_CHARS] + + class TokenHelper: """Manages OAuth2 client-credentials tokens with caching and auto-refresh. @@ -32,6 +182,18 @@ class TokenHelper: Token endpoint URL. refresh_margin: Seconds before expiry to trigger a proactive refresh. + scope: + Optional OAuth2 scope. Sent only when non-empty, so authorization + servers that reject an empty ``scope`` parameter are unaffected. + Notes + ----- + The failure cooldown window is read from + ``OAUTH_TOKEN_FAILURE_COOLDOWN_SECS``; see :mod:`ess_auth.cooldown`. + Rate-limit response headers extend it; see :mod:`ess_auth.rate_limit`. + When a proactive refresh fails, the cached token is served until it + actually expires, so a token is used for its full lifetime. The token + POST carries the identification header configured in the environment; + see :mod:`ess_auth.client_identity`. """ def __init__( @@ -41,14 +203,20 @@ def __init__( *, token_url: str, refresh_margin: int = EXPIRATION_WINDOW, + scope: str = "", ) -> None: self._client_id = client_id self._secret = secret self._token_url = token_url self._refresh_margin = refresh_margin + self._scope = scope + self._request_headers = client_identity_headers() + self._cooldown = FailureCooldown() + self._lock = threading.Lock() self._access_token: str | None = None self._expires_at: float = 0.0 + self._usable_until: float = 0.0 @property def token(self) -> str | None: @@ -58,9 +226,7 @@ def token(self) -> str | None: @property def is_expired(self) -> bool: """Whether the cached token is missing or within the refresh window.""" - return self._access_token is None or time.monotonic() >= ( - self._expires_at - self._refresh_margin - ) + return self._access_token is None or time.monotonic() >= self._usable_until def fetch_token(self, http: httpx.Client | None = None) -> str: """Return a valid access token, refreshing if necessary. @@ -71,42 +237,113 @@ def fetch_token(self, http: httpx.Client | None = None) -> str: Optional ``httpx.Client`` to reuse. A short-lived client is created automatically when *None*. """ - if not self.is_expired: - assert self._access_token is not None # noqa: S101 # nosec B101 # narrowing assertion after None check - return self._access_token - - if http is not None: - self._refresh(http) - else: - with httpx.Client(timeout=30.0) as client: - self._refresh(client) + # The cooldown check and the refresh have to be atomic: two worker + # threads arriving on a cold or expired cache can otherwise both see + # a closed breaker before either failure opens it, and both reach the + # endpoint the cooldown exists to shield. The cached read stays + # outside the lock so it never queues behind an in-flight refresh. + if self.is_expired: + with self._lock: + if self.is_expired: + try: + self._refresh_guarded(http) + except AuthenticationError: + stale = self._stale_token() + if stale is None: + raise + return stale assert self._access_token is not None # noqa: S101 # nosec B101 # narrowing assertion after None check return self._access_token + def _stale_token(self) -> str | None: + """Return the cached token if it outlives a failed refresh. + + Callers must hold ``self._lock``. + """ + valid_for = self._expires_at - time.monotonic() + if self._access_token is None or valid_for <= STALE_TOKEN_SAFETY_SECS: + return None + logger.warning( + "OAUTH_TOKEN_STALE_SERVED label=TokenHelper valid_for_s=%.0f token_url=%s", + valid_for, + self._token_url, + ) + return self._access_token + def fetch_token_info(self, http: httpx.Client | None = None) -> tuple[str, float]: """Return ``(token, expires_at_monotonic)``.""" token = self.fetch_token(http) - return token, self._expires_at + with self._lock: + return token, self._expires_at + + def _refresh_guarded(self, http: httpx.Client | None) -> None: + """Refresh once unless the failure cooldown is still open. + + Callers must hold ``self._lock``. + """ + cooling_for = self._cooldown.remaining() + if cooling_for > 0: + raise TokenEndpointCoolingDownError( + f"Token endpoint is cooling down for {cooling_for:.0f}s after a " + f"recent failure; not retrying ({self._token_url})" + ) + + if http is not None: + self._refresh(http) + else: + with httpx.Client(timeout=30.0) as client: + self._refresh(client) def _refresh(self, http: httpx.Client) -> None: logger.info("Refreshing OAuth2 access token from %s", self._token_url) + data = {"grant_type": "client_credentials"} + if self._scope: + data["scope"] = self._scope try: response = http.post( self._token_url, - data={"grant_type": "client_credentials"}, + data=data, auth=(self._client_id, self._secret), + headers=self._request_headers, ) response.raise_for_status() except httpx.HTTPStatusError as exc: - raise AuthenticationError( - f"Token request failed: HTTP {exc.response.status_code}" + self._cooldown.start() + apply_rate_limit_headers(self._cooldown, exc.response, label="TokenHelper") + raise TokenRequestFailedError( + f"Token request failed: HTTP {exc.response.status_code}", + status_code=exc.response.status_code, + detail=error_detail(exc.response), + diagnostics=diagnostic_headers(exc.response.headers), ) from exc except httpx.HTTPError as exc: - raise AuthenticationError(f"Token request failed: {exc}") from exc + self._cooldown.start() + raise TokenRequestFailedError(f"Token request failed: {exc}") from exc - payload = response.json() - self._access_token = payload["access_token"] - expires_in = int(payload.get("expires_in", 3600)) - self._expires_at = time.monotonic() + expires_in + try: + payload = response.json() + access_token = parse_access_token(payload) + expires_in = parse_expires_in(payload) + except (ValueError, KeyError, TypeError) as exc: + # A malformed body would otherwise retry as hard as a denial. + self._cooldown.start() + apply_rate_limit_headers(self._cooldown, response, label="TokenHelper") + raise TokenRequestFailedError( + f"Token response was not usable: {exc}", + status_code=response.status_code, + detail=error_detail(response), + diagnostics=diagnostic_headers(response.headers), + ) from exc + + now = time.monotonic() + self._access_token = access_token + self._expires_at = now + expires_in + self._usable_until = now + max( + expires_in - self._refresh_margin, MIN_CACHE_LIFETIME_SECS + ) + self._cooldown.clear() + # After clear(): a granted token can still report a nearly spent + # shared budget, and the hold must survive the reset. + apply_rate_limit_headers(self._cooldown, response, label="TokenHelper") logger.info("Token acquired, expires in %ds", expires_in) diff --git a/packages/python/ess-browser/README.md b/packages/python/ess-browser/README.md index eb2c85b..ab48854 100644 --- a/packages/python/ess-browser/README.md +++ b/packages/python/ess-browser/README.md @@ -3,14 +3,20 @@ Shared Playwright browser session with SSO handling. ```python -from ess_browser import BrowserSession +from ess_browser import SsoSite, open_authenticated_page, should_use_headed_browser -with BrowserSession(headed=True) as session: - page = session.new_page() - page.goto("https://example.com/") - session.wait_for_login(page, "https://example.com/") +SITE = SsoSite(url="https://app.example.com/dashboard") +PROFILE = "~/.my-tool/browser-profile" + +headed = should_use_headed_browser(SITE, profile_dir=PROFILE) + +with open_authenticated_page(SITE, headed=headed, profile_dir=PROFILE) as page: + print(page.title()) ``` +The first run opens a window so you can complete SSO; later runs reuse +the saved session and stay headless. + ## Installation From the workspace root: @@ -21,6 +27,41 @@ uv sync --all-packages ## API +### `SsoSite` + +Frozen dataclass describing one SSO-protected site. Declare it once as a +module constant instead of threading the same keyword arguments through +every call. + +| Field | Default | Description | +|---|---|---| +| `url` | *required* | Page to open; its host means "authenticated" | +| `login_complete_url` | `None` | Where the IdP lands, when that is not `url` | +| `settle_ms` | `0` | Grace period before judging an SSO redirect | +| `nav_timeout_ms` | `60_000` | Navigation timeout | +| `extra_auth_domains` | `()` | IdP hosts beyond `AUTH_DOMAINS` | + +Set `settle_ms` for sites that redirect from client-side JavaScript, where +the first load briefly lands on the app before bouncing to the IdP. Set +`login_complete_url` when the callback always returns to one canonical +host, otherwise waiting on `url` never finishes. Use `extra_auth_domains` +for a tenant's own IdP rather than widening the shared tuple. + +### `open_authenticated_page(site, *, headed, profile_dir=None, prepare=None)` + +Context manager yielding a page that has completed SSO: it navigates, +waits out any IdP redirect, and returns to `site.url` if login landed +elsewhere. Raises `SsoTimeoutError` if login never completes. Pass +`prepare` to attach listeners before the first navigation, which would +otherwise miss that navigation's own traffic. + +### `should_use_headed_browser(site, *, profile_dir, force_headed=False, force_headless=False)` + +Probes the saved session and returns whether a visible window is needed. +Raises `ValueError` if both force flags are set. Callers that prefer a +`--headless` default with a retry on SSO expiry can skip this helper and +just retry `open_authenticated_page` headed when login times out. + ### `BrowserSession` Context manager that launches a persistent Chrome browser session. @@ -38,7 +79,8 @@ Methods: - `new_page() -> Page` -- open a new browser tab - `wait_for_login(page, target_url, timeout_ms=300_000)` -- block until SSO completes -- `is_auth_redirect(current_url, target_url) -> bool` -- check if on an SSO page +- `is_auth_redirect(current_url, target_url, *, extra_auth_domains=()) -> bool` -- + check if on an SSO page #### Duo prompt auto-dismiss @@ -53,12 +95,62 @@ Set `enable_extensions=True` to load extensions installed in the profile directory. This only has an effect in headed mode; Playwright ignores extensions when running headless. +### Profile directories + +- `default_profile_dir(tool_name) -> Path` -- `~/.<tool_name>/browser-profile` +- `profile_dir_display(tool_name) -> str` -- the same path in tilde form, + for `--help` text that should not print the running user's name + +Each tool gets its own directory on purpose: one expired session should +not force a re-login everywhere. + +#### Session cookies + +`BrowserSession` carries session-scoped cookies across launches itself, +in `<profile_dir>/session-cookies.json` (written `0600`, owner-only). + +This is not belt-and-braces. The cookie that represents a completed +SSO login, often a `JSESSIONID` on the IdP's host, is session-scoped, +and Chrome restores those only under "continue where you left off", +which Playwright does not set. Without this, a persistent profile keeps +everything *except* the thing that keeps you logged in, and every launch +repeats the IdP round trip. + +Only session-scoped cookies are stored. Anything with a real expiry is +already in Chrome's own store, and duplicating it would give one cookie +two sources of truth. Saved cookies older than 12 hours are dropped +rather than replayed, and every step is best effort: a failure here +costs one login, never the run. + +A tool that opens more than one browser per command still pays for a +login per launch, because the session cookie is only written on clean +exit. Prefer one session per run and share the context. + +### Exceptions + +- `EssBrowserError` -- base class for everything this package raises +- `SsoTimeoutError` -- login did not complete before the timeout +- `sso_timeout_message(*, headed, prefix=..., retry_hint=...)` -- builds the + message, appending the retry hint only when it is actionable. The hint + defaults to "re-run with `--headed`"; pass `retry_hint` if your CLI spells + that differently so operators are not sent to a flag you do not have + +Callers are expected to catch `SsoTimeoutError` and re-raise it as their +own domain error; the message already carries the actionable hint. + ### `ess_browser.auth` Lower-level SSO detection functions: -- `AUTH_DOMAINS` -- tuple of known SSO/IdP domain patterns +- `AUTH_DOMAINS` -- tuple of public SSO/IdP domain patterns. It names no + company's own login hosts; pass those as `extra_auth_domains` - `DUO_SKIP_UPDATE_SCRIPT` -- JS snippet that dismisses the Duo update prompt -- `LOGIN_TIMEOUT_MS` -- default timeout (5 minutes) -- `is_auth_redirect(current_url, target_url) -> bool` +- `LOGIN_TIMEOUT_MS` -- login wait timeout (5 minutes) +- `NAV_TIMEOUT_MS` -- default navigation timeout (60 seconds) +- `SSO_SETTLE_MS` -- default redirect grace period (5 seconds) +- `is_auth_redirect(current_url, target_url, *, extra_auth_domains=()) -> bool` +- `is_sso_login_required(target_url, *, profile_dir, ...) -> bool` - `wait_for_login(page, target_url, timeout_ms=LOGIN_TIMEOUT_MS)` + +`AUTH_DOMAINS` includes substring patterns (`sso.`, `idp.`, `adfs.`), so +host matching is deliberately loose. diff --git a/packages/python/ess-browser/src/ess_browser/__init__.py b/packages/python/ess-browser/src/ess_browser/__init__.py index 3ad11cf..2332e9c 100644 --- a/packages/python/ess-browser/src/ess_browser/__init__.py +++ b/packages/python/ess-browser/src/ess_browser/__init__.py @@ -4,17 +4,39 @@ AUTH_DOMAINS, DUO_SKIP_UPDATE_SCRIPT, LOGIN_TIMEOUT_MS, + NAV_TIMEOUT_MS, + SSO_SETTLE_MS, is_auth_redirect, + is_sso_login_required, wait_for_login, ) -from .session import DEFAULT_PROFILE_DIR, BrowserSession +from .authenticated_page import open_authenticated_page +from .exceptions import EssBrowserError, SsoTimeoutError, sso_timeout_message +from .session import ( + DEFAULT_PROFILE_DIR, + BrowserSession, + default_profile_dir, + profile_dir_display, +) +from .site import SsoSite, should_use_headed_browser __all__ = [ "AUTH_DOMAINS", "BrowserSession", "DEFAULT_PROFILE_DIR", "DUO_SKIP_UPDATE_SCRIPT", + "EssBrowserError", "LOGIN_TIMEOUT_MS", + "NAV_TIMEOUT_MS", + "SSO_SETTLE_MS", + "SsoSite", + "SsoTimeoutError", + "default_profile_dir", "is_auth_redirect", + "is_sso_login_required", + "open_authenticated_page", + "profile_dir_display", + "should_use_headed_browser", + "sso_timeout_message", "wait_for_login", ] diff --git a/packages/python/ess-browser/src/ess_browser/auth.py b/packages/python/ess-browser/src/ess_browser/auth.py index 6cd291f..83a286b 100644 --- a/packages/python/ess-browser/src/ess_browser/auth.py +++ b/packages/python/ess-browser/src/ess_browser/auth.py @@ -4,6 +4,7 @@ import logging import sys +from pathlib import Path from urllib.parse import urlparse from playwright.sync_api import Page @@ -11,11 +12,12 @@ logger = logging.getLogger(__name__) LOGIN_TIMEOUT_MS = 5 * 60 * 1000 # 5 minutes +SSO_SETTLE_MS = 5_000 +NAV_TIMEOUT_MS = 60_000 +# Public IdPs only. Callers pass a company's own login hosts as +# ``extra_auth_domains``. AUTH_DOMAINS: tuple[str, ...] = ( - # Cisco - "id.cisco.com", - "cloudsso.cisco.com", # Duo "duosecurity.com", "login.duosecurity.com", @@ -43,6 +45,7 @@ (() => { "use strict"; const hostname = location.hostname; + // Init script runs on every navigation; no-op outside Duo-hosted pages. if (hostname !== "duosecurity.com" && !hostname.endsWith(".duosecurity.com")) return; const SKIP_TEXT = "Skip for now"; @@ -99,13 +102,83 @@ """ -def is_auth_redirect(current_url: str, target_url: str) -> bool: - """Return True if the browser landed on an SSO page instead of the target.""" +def is_auth_redirect( + current_url: str, + target_url: str, + *, + extra_auth_domains: tuple[str, ...] = (), +) -> bool: + """Return True if the browser landed on an SSO page instead of the target. + + Args: + current_url: Where the browser actually is. + target_url: Where it was asked to go. + extra_auth_domains: Host patterns to recognize as IdPs in + addition to :data:`AUTH_DOMAINS`. Lets one caller teach this + check about a tenant-specific IdP without widening the + shared tuple for everybody. + """ current_host = urlparse(current_url).netloc.lower() target_host = urlparse(target_url).netloc.lower() if current_host == target_host: return False - return any(domain in current_host for domain in AUTH_DOMAINS) + domains = AUTH_DOMAINS + extra_auth_domains + return any(domain in current_host for domain in domains) + + +def _is_profile_empty(profile_dir: Path) -> bool: + if not profile_dir.is_dir(): + return True + return not any(profile_dir.iterdir()) + + +def is_sso_login_required( + target_url: str, + *, + profile_dir: Path | str, + settle_timeout_ms: int = SSO_SETTLE_MS, + extra_auth_domains: tuple[str, ...] = (), +) -> bool: + """Return True when a headless visit to ``target_url`` lands on SSO. + + Best-effort probe: navigation or browser errors are logged and treated as + ``False`` so callers can attempt headless execution and apply their own + SSO retry logic. + + Args: + target_url: The page to probe. + profile_dir: Browser profile directory holding the saved session. + settle_timeout_ms: Grace period before reading the landed URL. + extra_auth_domains: Additional IdP host patterns, passed through + to :func:`is_auth_redirect` so the probe agrees with the + real run. + """ + from ess_browser.session import BrowserSession # noqa: PLC0415, I001 — session imports auth; lazy import breaks cycle + + effective_profile = Path(profile_dir) + + try: + # No persisted cookies yet — skip the probe and require headed login once. + if _is_profile_empty(effective_profile): + return True + + with BrowserSession(headed=False, profile_dir=effective_profile) as session: + page = session.new_page() + page.goto(target_url, wait_until="load", timeout=NAV_TIMEOUT_MS) + page.wait_for_timeout(settle_timeout_ms) + return is_auth_redirect( + page.url, + target_url, + extra_auth_domains=extra_auth_domains, + ) + except Exception as exc: + logger.warning( + "SSO probe failed for %s (profile %s): %s — assuming session is valid", + target_url, + effective_profile, + exc, + ) + return False def wait_for_login( diff --git a/packages/python/ess-browser/src/ess_browser/authenticated_page.py b/packages/python/ess-browser/src/ess_browser/authenticated_page.py new file mode 100644 index 0000000..b010d9d --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/authenticated_page.py @@ -0,0 +1,80 @@ +"""Authenticated page sessions for SSO-protected sites.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from pathlib import Path + +from playwright.sync_api import Page +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +from .auth import LOGIN_TIMEOUT_MS, is_auth_redirect, wait_for_login +from .exceptions import SsoTimeoutError, sso_timeout_message +from .session import BrowserSession +from .site import SsoSite + +logger = logging.getLogger(__name__) + + +@contextmanager +def open_authenticated_page( + site: SsoSite, + *, + headed: bool, + profile_dir: Path | str | None = None, + prepare: Callable[[Page], None] | None = None, +) -> Iterator[Page]: + """Open *site* in a browser tab that has completed SSO. + + Reuses the saved session in *profile_dir* when it is still valid. + When it is not, and the window is visible, this blocks until the + user finishes logging in. + + Args: + site: The site to open, including its SSO quirks. + headed: Show the browser window. + profile_dir: Browser profile directory for session persistence. + prepare: Called with the blank page before the first navigation. + Use it to attach listeners that would otherwise miss the + traffic that navigation itself generates. + + Yields: + A Playwright page sitting on an authenticated URL of *site*. + + Raises: + SsoTimeoutError: Login did not complete within the timeout. + """ + with BrowserSession(headed=headed, profile_dir=profile_dir) as browser: + page = browser.new_page() + if prepare is not None: + prepare(page) + logger.info("Navigating to %s", site.url) + page.goto(site.url, wait_until="load", timeout=site.nav_timeout_ms) + if site.settle_ms: + page.wait_for_timeout(site.settle_ms) + _complete_login(page, site, headed=headed) + yield page + + +def _complete_login(page: Page, site: SsoSite, *, headed: bool) -> None: + """Wait out an SSO redirect, then return to the requested page.""" + if not is_auth_redirect( + page.url, + site.url, + extra_auth_domains=site.extra_auth_domains, + ): + return + + logger.info("SSO redirect detected -- waiting for login") + try: + wait_for_login(page, site.login_target, timeout_ms=LOGIN_TIMEOUT_MS) + except PlaywrightTimeoutError as exc: + raise SsoTimeoutError(sso_timeout_message(headed=headed)) from exc + + # Login ends on the IdP callback landing page, which is not + # necessarily the page that was asked for. + if not page.url.startswith(site.url): + logger.info("Returning to %s after login", site.url) + page.goto(site.url, wait_until="load", timeout=site.nav_timeout_ms) diff --git a/packages/python/ess-browser/src/ess_browser/exceptions.py b/packages/python/ess-browser/src/ess_browser/exceptions.py new file mode 100644 index 0000000..88cf9a5 --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/exceptions.py @@ -0,0 +1,37 @@ +"""Domain exceptions for browser sessions and SSO flows.""" + +from __future__ import annotations + +HEADED_HINT = " Re-run with --headed to complete login in the browser." + + +class EssBrowserError(Exception): + """Base class for every error raised by this package.""" + + +class SsoTimeoutError(EssBrowserError): + """SSO did not complete before the login timeout elapsed.""" + + +def sso_timeout_message( + *, + headed: bool, + prefix: str = "SSO authentication timed out", + retry_hint: str = HEADED_HINT, +) -> str: + """Build the message for an SSO login that never completed. + + Args: + headed: Whether the browser window was already visible. The retry + hint is appended only when it was not, because an interactive + login cannot be completed in a headless window. + prefix: Leading sentence, so a caller can name its own IdP. + retry_hint: How *this* caller's CLI asks for a visible window. + Defaults to ``--headed``; a tool spelling it differently must + pass its own, or it sends operators to a flag it lacks. + + Returns: + The message, with the retry hint appended only when actionable. + """ + hint = "" if headed else retry_hint + return f"{prefix}.{hint}" diff --git a/packages/python/ess-browser/src/ess_browser/session.py b/packages/python/ess-browser/src/ess_browser/session.py index e98f971..dbf943f 100644 --- a/packages/python/ess-browser/src/ess_browser/session.py +++ b/packages/python/ess-browser/src/ess_browser/session.py @@ -2,7 +2,12 @@ from __future__ import annotations +import json import logging +import math +import os +import tempfile +import time from pathlib import Path from types import TracebackType from urllib.parse import urlparse @@ -21,6 +26,49 @@ _TARGET_CLOSED_MESSAGE = "Target page, context or browser has been closed" +# Session cookies carried across launches by hand, because nothing else +# will. The cookie that represents a completed SSO login (often a +# `JSESSIONID` on the IdP's host) is session-scoped, and Chrome restores +# those only under "continue where you left off", which Playwright does +# not set. Without this, every launch starts logged out and re-runs the +# IdP round trip even though the profile is persistent. +# +# Only session-scoped cookies are written here. Anything with a real +# expiry already lives in Chrome's own store, and duplicating it would +# mean two sources of truth for the same cookie. +_SESSION_COOKIE_FILE = "session-cookies.json" + +# Playwright's marker for "expires when the browser closes". +_SESSION_COOKIE_EXPIRY = -1 + +# Past this, a saved session is assumed dead and dropped rather than +# replayed. IdP sessions typically last a few hours, so a stale cookie +# would only fail -- but sending an old session identifier +# to the IdP is worth avoiding regardless. +_SESSION_COOKIE_MAX_AGE_S = 12 * 60 * 60 + +# Owner-only: these are live credentials for the duration of a session. +_SESSION_COOKIE_MODE = 0o600 + + +def default_profile_dir(tool_name: str) -> Path: + """Return the conventional profile directory for *tool_name*. + + Every consumer stores its session under ``~/.<tool>/browser-profile``. + Separate directories per tool are deliberate: one expired session + should not force a re-login everywhere. + """ + return Path.home() / f".{tool_name}" / "browser-profile" + + +def profile_dir_display(tool_name: str) -> str: + """Return the tilde form of :func:`default_profile_dir` for help text. + + Expanding the home directory in ``--help`` output bakes whichever + username happens to run the command into docs and bug reports. + """ + return f"~/.{tool_name}/browser-profile" + def _is_target_closed(exc: PlaywrightError) -> bool: """True when *exc* signals a closed page/context/browser. @@ -112,6 +160,86 @@ def _evaluate_on_existing_pages( ) +def _write_session_cookies(path: Path, cookies: list[dict]) -> None: + """Write *cookies* to *path*, owner-readable only, atomically. + + Uses an exclusive temporary file (``mkstemp``) so a leftover + ``session-cookies.json.tmp`` from a crash is never truncated and + reused. ``mkstemp`` creates the file at ``0o600``; we chmod anyway + so the mode is explicit. + """ + payload = {"saved_at": time.time(), "cookies": cookies} + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, tmp_name = tempfile.mkstemp( + prefix=f"{path.name}.", + suffix=".tmp", + dir=path.parent, + ) + tmp = Path(tmp_name) + replaced = False + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + os.chmod(tmp, _SESSION_COOKIE_MODE) + tmp.replace(path) + replaced = True + finally: + if not replaced: + tmp.unlink(missing_ok=True) + + +def _saved_at(payload: dict) -> float | None: + """Return a finite save time, or nothing when the metadata is unusable. + + ``float("NaN")`` and ``float("inf")`` do not raise, and both fail the + age comparison in the direction that would keep the cookies forever. + """ + try: + saved_at = float(payload.get("saved_at") or 0) + except (TypeError, ValueError, OverflowError): + return None + if not math.isfinite(saved_at): + return None + return saved_at + + +def _read_session_cookies(path: Path) -> list[dict]: + """Return saved session cookies, or nothing when they cannot be used. + + A missing, unreadable, malformed, or stale file all mean the same + thing to the caller: start without them and let SSO run. + """ + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + except (OSError, ValueError): + logger.debug("Saved session cookies are unreadable; ignoring them") + return [] + + if not isinstance(payload, dict): + return [] + + saved_at = _saved_at(payload) + if saved_at is None: + logger.debug("Saved session cookies have invalid metadata; ignoring them") + return [] + + age = time.time() - saved_at + if age > _SESSION_COOKIE_MAX_AGE_S: + logger.debug("Saved session cookies are stale (%.0fs old); dropping", age) + try: + path.unlink(missing_ok=True) + except OSError as exc: + logger.debug( + "Could not drop stale session cookies (%s)", type(exc).__name__ + ) + return [] + + cookies = payload.get("cookies") + return cookies if isinstance(cookies, list) else [] + + class BrowserSession: """Context manager for a persistent Chrome browser session. @@ -179,6 +307,7 @@ def __enter__(self) -> BrowserSession: for script in all_scripts: self._context.add_init_script(script) + self._restore_session_cookies() _evaluate_on_existing_pages(self._context.pages, all_scripts) except Exception: if self._pw is not None: @@ -196,6 +325,8 @@ def __exit__( exc_tb: TracebackType | None, ) -> None: try: + # Before close: the cookies are gone once the context is. + self._save_session_cookies() if self._context: self._context.close() finally: @@ -204,6 +335,59 @@ def __exit__( self._context = None self._pw = None + @property + def _session_cookie_path(self) -> Path: + """Where this profile's session cookies are parked between runs.""" + return self._profile / _SESSION_COOKIE_FILE + + def _restore_session_cookies(self) -> None: + """Re-add the session cookies saved by the previous run. + + Best effort throughout: a failure here costs one SSO login, so + it must never stop the session from opening. + """ + cookies = _read_session_cookies(self._session_cookie_path) + if not cookies or self._context is None: + return + try: + self._context.add_cookies(cookies) + except (PlaywrightError, ValueError) as exc: + logger.debug("Could not restore session cookies (%s)", type(exc).__name__) + return + logger.debug("Restored %d session cookie(s)", len(cookies)) + + def _save_session_cookies(self) -> None: + """Save session-scoped cookies so the next run stays logged in.""" + if self._context is None: + return + try: + cookies = [ + cookie + for cookie in self._context.cookies() + if cookie.get("expires") == _SESSION_COOKIE_EXPIRY + ] + except PlaywrightError as exc: + logger.debug("Could not read cookies to save (%s)", type(exc).__name__) + return + + path = self._session_cookie_path + if not cookies: + # Nothing to carry over, and a leftover file from an earlier + # run would outlive the session it belongs to. A failed delete + # must not fail the session close. + try: + path.unlink(missing_ok=True) + except OSError as exc: + logger.debug("Could not clear session cookies (%s)", type(exc).__name__) + return + + try: + _write_session_cookies(path, cookies) + except OSError as exc: + logger.debug("Could not save session cookies (%s)", type(exc).__name__) + return + logger.debug("Saved %d session cookie(s)", len(cookies)) + @property def context(self) -> BrowserContext: """The underlying Playwright browser context.""" @@ -217,9 +401,25 @@ def new_page(self) -> Page: return self.context.new_page() @staticmethod - def is_auth_redirect(current_url: str, target_url: str) -> bool: - """Check if the browser is on an SSO page instead of the target.""" - return is_auth_redirect(current_url, target_url) + def is_auth_redirect( + current_url: str, + target_url: str, + *, + extra_auth_domains: tuple[str, ...] = (), + ) -> bool: + """Check if the browser is on an SSO page instead of the target. + + Args: + current_url: Where the browser actually is. + target_url: Where it was asked to go. + extra_auth_domains: IdP host patterns to recognize in addition + to :data:`~ess_browser.AUTH_DOMAINS`. + """ + return is_auth_redirect( + current_url, + target_url, + extra_auth_domains=extra_auth_domains, + ) @staticmethod def wait_for_login( diff --git a/packages/python/ess-browser/src/ess_browser/site.py b/packages/python/ess-browser/src/ess_browser/site.py new file mode 100644 index 0000000..757168e --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/site.py @@ -0,0 +1,83 @@ +"""Per-site SSO facts and headed-mode selection.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .auth import NAV_TIMEOUT_MS, SSO_SETTLE_MS, is_sso_login_required + + +@dataclass(frozen=True) +class SsoSite: + """The auth-relevant facts about one SSO-protected site. + + Declare one per site as a module constant and hand it to + :func:`ess_browser.open_authenticated_page`. Collecting the facts in + one object is what keeps each consumer from threading the same + handful of keyword arguments through its own session helper. + + Attributes: + url: The page to open. Its host is also what "authenticated" + means for this site. + login_complete_url: Where the IdP lands after a successful login, + when that is not ``url``. An OIDC callback may always return + to one canonical host regardless of which URL began the flow, + in which case waiting on ``url`` would wait forever. + settle_ms: Grace period after load before judging whether this is + an SSO redirect. Needed for sites that redirect from + client-side JavaScript rather than an HTTP response, where + the first load lands on the app briefly before bouncing. + nav_timeout_ms: Navigation timeout for this site. + extra_auth_domains: IdP host patterns to recognize in addition to + :data:`ess_browser.AUTH_DOMAINS`. Name a tenant's own IdP + here rather than widening the shared tuple. + """ + + url: str + login_complete_url: str | None = None + settle_ms: int = 0 + nav_timeout_ms: int = NAV_TIMEOUT_MS + extra_auth_domains: tuple[str, ...] = () + + @property + def login_target(self) -> str: + """The URL that signals login is complete.""" + return self.login_complete_url or self.url + + +def should_use_headed_browser( + site: SsoSite, + *, + profile_dir: Path | str, + force_headed: bool = False, + force_headless: bool = False, +) -> bool: + """Decide whether the browser window needs to be visible. + + Args: + site: The site whose saved session is probed. + profile_dir: Browser profile directory holding that session. + force_headed: Always show the window. + force_headless: Never show the window. + + Returns: + True when a visible window is needed, either because it was + forced or because the saved SSO session has expired. + + Raises: + ValueError: Both force flags were set. + """ + if force_headed and force_headless: + msg = "Cannot pass both --headed and --headless" + raise ValueError(msg) + if force_headed: + return True + if force_headless: + return False + return is_sso_login_required( + site.url, + profile_dir=profile_dir, + settle_timeout_ms=site.settle_ms or SSO_SETTLE_MS, + extra_auth_domains=site.extra_auth_domains, + ) diff --git a/packages/python/ess-browser/src/ess_browser/test_auth.py b/packages/python/ess-browser/src/ess_browser/test_auth.py index 3dfdc7d..b06ead7 100644 --- a/packages/python/ess-browser/src/ess_browser/test_auth.py +++ b/packages/python/ess-browser/src/ess_browser/test_auth.py @@ -2,7 +2,11 @@ from __future__ import annotations -from .auth import DUO_SKIP_UPDATE_SCRIPT, is_auth_redirect +from pathlib import Path +from unittest.mock import MagicMock, patch + +from .auth import DUO_SKIP_UPDATE_SCRIPT, is_auth_redirect, is_sso_login_required +from .session import BrowserSession class TestIsAuthRedirect: @@ -30,6 +34,25 @@ def test_unrelated_host_is_not_redirect(self): "https://app.example.com/", ) + def test_company_idp_needs_extra_auth_domains(self): + # Company login hosts belong to the caller, not to AUTH_DOMAINS. + assert not is_auth_redirect( + "https://login.corp.example.net/authorize", + "https://app.example.com/", + ) + assert is_auth_redirect( + "https://login.corp.example.net/authorize", + "https://app.example.com/", + extra_auth_domains=("login.corp.example.net",), + ) + + def test_browser_session_wrapper_honors_extra_auth_domains(self): + assert BrowserSession.is_auth_redirect( + "https://login.corp.example.net/authorize", + "https://app.example.com/", + extra_auth_domains=("login.corp.example.net",), + ) + class TestDuoSkipUpdateScript: """Verify the domain guard in the JS script.""" @@ -40,3 +63,79 @@ def test_rejects_evil_subdomain(self): def test_uses_safe_observer_root(self): assert "document.body || document.documentElement" in DUO_SKIP_UPDATE_SCRIPT + + +class TestIsSsoLoginRequired: + @patch("ess_browser.session.BrowserSession") + def test_missing_profile_dir_requires_login( + self, mock_session_cls: MagicMock, tmp_path: Path + ) -> None: + profile_dir = tmp_path / "nonexistent-ess-browser-profile-test" + + assert is_sso_login_required( + "https://app.example.com/", + profile_dir=profile_dir, + ) + mock_session_cls.assert_not_called() + + @patch("ess_browser.session.BrowserSession") + def test_auth_redirect_requires_login( + self, mock_session_cls: MagicMock, tmp_path: Path + ) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.url = "https://login.duosecurity.com/frame" + + profile_dir = tmp_path / "ess-browser-profile-with-data" + with patch("ess_browser.auth._is_profile_empty", return_value=False): + assert is_sso_login_required( + "https://app.example.com/", + profile_dir=profile_dir, + ) + + @patch("ess_browser.session.BrowserSession") + def test_valid_session_does_not_require_login( + self, mock_session_cls: MagicMock, tmp_path: Path + ) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.url = "https://app.example.com/dashboard" + + profile_dir = tmp_path / "ess-browser-profile-with-data" + with patch("ess_browser.auth._is_profile_empty", return_value=False): + assert not is_sso_login_required( + "https://app.example.com/", + profile_dir=profile_dir, + ) + + @patch("ess_browser.session.BrowserSession") + def test_probe_failure_assumes_session_valid( + self, mock_session_cls: MagicMock, tmp_path: Path + ) -> None: + mock_session_cls.side_effect = RuntimeError("Timeout 30000ms exceeded") + + profile_dir = tmp_path / "ess-browser-profile-with-data" + with patch("ess_browser.auth._is_profile_empty", return_value=False): + assert not is_sso_login_required( + "https://app.example.com/", + profile_dir=profile_dir, + ) + + @patch("ess_browser.session.BrowserSession") + def test_unreadable_profile_assumes_session_valid( + self, mock_session_cls: MagicMock, tmp_path: Path + ) -> None: + profile_dir = tmp_path / "ess-browser-profile-with-data" + with patch( + "ess_browser.auth._is_profile_empty", + side_effect=OSError("Permission denied"), + ): + assert not is_sso_login_required( + "https://app.example.com/", + profile_dir=profile_dir, + ) + mock_session_cls.assert_not_called() diff --git a/packages/python/ess-browser/src/ess_browser/test_authenticated_page.py b/packages/python/ess-browser/src/ess_browser/test_authenticated_page.py new file mode 100644 index 0000000..95811e8 --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/test_authenticated_page.py @@ -0,0 +1,214 @@ +"""Unit tests for the authenticated page context manager.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +from .authenticated_page import open_authenticated_page +from .exceptions import SsoTimeoutError, sso_timeout_message +from .site import SsoSite + +APP_URL = "https://app.example.com/" +IDP_URL = "https://sso.example.com/login" + +# Deliberately matches no AUTH_DOMAINS entry -- note that the generic +# "idp." pattern would swallow any host containing it, which would make +# the extra_auth_domains tests below pass for the wrong reason. +TENANT_IDP_URL = "https://auth.tenant.example.com/authorize" + +# One navigation to arrive, then a second to return to the requested page +# after the IdP callback dropped the browser somewhere else. +NAVIGATIONS_WITH_RETURN_TRIP = 2 + + +@pytest.fixture +def page() -> MagicMock: + mock_page = MagicMock() + mock_page.url = APP_URL + return mock_page + + +@pytest.fixture +def session(page: MagicMock): + """Patch BrowserSession so no real Chrome is launched.""" + with patch("ess_browser.authenticated_page.BrowserSession") as session_cls: + session_cls.return_value.__enter__.return_value.new_page.return_value = page + yield session_cls + + +class TestHappyPath: + def test_yields_the_page_without_waiting_when_already_signed_in( + self, session: MagicMock, page: MagicMock + ) -> None: + site = SsoSite(url=APP_URL) + + with patch("ess_browser.authenticated_page.wait_for_login") as wait: + with open_authenticated_page(site, headed=False) as yielded: + assert yielded is page + + wait.assert_not_called() + page.goto.assert_called_once_with( + APP_URL, wait_until="load", timeout=site.nav_timeout_ms + ) + + def test_prepare_runs_before_the_first_navigation( + self, session: MagicMock, page: MagicMock + ) -> None: + """Listeners attached after goto would miss the navigation traffic.""" + calls: list[str] = [] + page.goto.side_effect = lambda *a, **k: calls.append("goto") + + with open_authenticated_page( + SsoSite(url=APP_URL), + headed=False, + prepare=lambda _page: calls.append("prepare"), + ): + pass + + assert calls == ["prepare", "goto"] + + def test_settle_wait_only_happens_when_configured( + self, session: MagicMock, page: MagicMock + ) -> None: + with open_authenticated_page(SsoSite(url=APP_URL), headed=False): + pass + page.wait_for_timeout.assert_not_called() + + with open_authenticated_page( + SsoSite(url=APP_URL, settle_ms=1_500), headed=False + ): + pass + page.wait_for_timeout.assert_called_once_with(1_500) + + +class TestSsoRedirect: + def test_waits_for_login_then_returns_to_the_target( + self, session: MagicMock, page: MagicMock + ) -> None: + page.url = IDP_URL + site = SsoSite(url=APP_URL) + + with patch("ess_browser.authenticated_page.wait_for_login") as wait: + with open_authenticated_page(site, headed=True): + pass + + wait.assert_called_once() + assert wait.call_args[0][1] == APP_URL + # Still on the IdP host after login, so a second goto is required. + assert page.goto.call_count == NAVIGATIONS_WITH_RETURN_TRIP + + def test_waits_on_the_callback_host_not_the_target( + self, session: MagicMock, page: MagicMock + ) -> None: + """A callback that always lands elsewhere would otherwise hang.""" + page.url = IDP_URL + site = SsoSite( + url="https://alias.example.com/goals", + login_complete_url=APP_URL, + ) + + with patch("ess_browser.authenticated_page.wait_for_login") as wait: + with open_authenticated_page(site, headed=True): + pass + + assert wait.call_args[0][1] == APP_URL + + def test_no_second_navigation_when_login_lands_on_target( + self, session: MagicMock, page: MagicMock + ) -> None: + page.url = IDP_URL + site = SsoSite(url=APP_URL) + + def land_on_target(*_args: object, **_kwargs: object) -> None: + page.url = f"{APP_URL}dashboard" + + with patch( + "ess_browser.authenticated_page.wait_for_login", + side_effect=land_on_target, + ): + with open_authenticated_page(site, headed=True): + pass + + assert page.goto.call_count == 1 + + def test_extra_auth_domains_are_honored( + self, session: MagicMock, page: MagicMock + ) -> None: + """A tenant IdP unknown to AUTH_DOMAINS must still be detected.""" + page.url = TENANT_IDP_URL + site = SsoSite(url=APP_URL, extra_auth_domains=("tenant.example.com",)) + + with patch("ess_browser.authenticated_page.wait_for_login") as wait: + with open_authenticated_page(site, headed=True): + pass + + wait.assert_called_once() + + def test_unknown_idp_host_is_not_detected_without_extras( + self, session: MagicMock, page: MagicMock + ) -> None: + """Guards the test above: without extras this host is invisible.""" + page.url = TENANT_IDP_URL + + with patch("ess_browser.authenticated_page.wait_for_login") as wait: + with open_authenticated_page(SsoSite(url=APP_URL), headed=True): + pass + + wait.assert_not_called() + + +class TestTimeout: + def test_playwright_timeout_becomes_sso_timeout_error( + self, session: MagicMock, page: MagicMock + ) -> None: + page.url = IDP_URL + + with patch( + "ess_browser.authenticated_page.wait_for_login", + side_effect=PlaywrightTimeoutError("Timeout 300000ms exceeded"), + ): + with pytest.raises(SsoTimeoutError) as excinfo: + with open_authenticated_page(SsoSite(url=APP_URL), headed=False): + pass + + assert "--headed" in str(excinfo.value) + + def test_headed_timeout_omits_the_retry_hint( + self, session: MagicMock, page: MagicMock + ) -> None: + page.url = IDP_URL + + with patch( + "ess_browser.authenticated_page.wait_for_login", + side_effect=PlaywrightTimeoutError("Timeout 300000ms exceeded"), + ): + with pytest.raises(SsoTimeoutError) as excinfo: + with open_authenticated_page(SsoSite(url=APP_URL), headed=True): + pass + + assert "--headed" not in str(excinfo.value) + + +class TestSsoTimeoutMessage: + def test_headless_gets_an_actionable_hint(self) -> None: + assert "--headed" in sso_timeout_message(headed=False) + + def test_a_caller_can_name_its_own_option(self) -> None: + """Not every CLI spells this `--headed`; sending them there misleads.""" + message = sso_timeout_message( + headed=False, + retry_hint=" Re-run with --browser headed.", + ) + assert "--browser headed" in message + assert "--headed to complete" not in message + + def test_headed_gets_no_hint(self) -> None: + assert sso_timeout_message(headed=True) == "SSO authentication timed out." + + def test_prefix_is_overridable(self) -> None: + message = sso_timeout_message(headed=True, prefix="Tenant SSO timed out") + + assert message == "Tenant SSO timed out." diff --git a/packages/python/ess-browser/src/ess_browser/test_session.py b/packages/python/ess-browser/src/ess_browser/test_session.py index 5783c65..f444209 100644 --- a/packages/python/ess-browser/src/ess_browser/test_session.py +++ b/packages/python/ess-browser/src/ess_browser/test_session.py @@ -2,13 +2,15 @@ from __future__ import annotations +import json +import time from unittest.mock import MagicMock, PropertyMock, call, patch import pytest from playwright.sync_api import Error as PlaywrightError from .auth import DUO_SKIP_UPDATE_SCRIPT -from .session import BrowserSession, _safe_origin +from .session import _SESSION_COOKIE_MODE, BrowserSession, _safe_origin class _TargetClosedError(PlaywrightError): @@ -288,10 +290,14 @@ def test_strips_query_and_path(self): ) def test_strips_userinfo(self): - assert _safe_origin("https://alice:x@host.com/p") == ("https://host.com") + assert _safe_origin("https://alice:x@host.example.com/p") == ( + "https://host.example.com" + ) def test_preserves_port(self): - assert _safe_origin("https://host.com:8443/p") == ("https://host.com:8443") + assert _safe_origin("https://host.example.com:8443/p") == ( + "https://host.example.com:8443" + ) def test_file_url_returns_scheme_only(self): assert _safe_origin("file:///etc/passwd") == "file:" @@ -300,7 +306,10 @@ def test_data_url_returns_scheme_only(self): assert _safe_origin("data:text/html,<h1>hi</h1>") == "data:" def test_malformed_port_does_not_raise(self): - assert _safe_origin("https://host:bad/path") == "https://host" + assert ( + _safe_origin("https://host.example.com:bad/path") + == "https://host.example.com" + ) def test_ipv6_brackets_preserved(self): assert _safe_origin("https://[::1]:8443/path") == "https://[::1]:8443" @@ -316,3 +325,200 @@ def test_scheme_relative_url_with_port(self): def test_empty_string(self): assert _safe_origin("") == "<unknown>" + + +class TestSessionCookiePersistence: + """Carrying the SSO session cookie across launches. + + The cookie that represents a completed SSO login is + session-scoped, and Playwright's persistent context does not restore + those -- so without this the profile persists everything *except* + the thing that keeps you logged in. + """ + + _SESSION_COOKIE = { + "name": "JSESSIONID", + "value": "opaque", + "domain": "idp.example.com", + "path": "/", + "expires": -1, + } + _PERSISTENT_COOKIE = { + "name": "DT", + "value": "opaque", + "domain": "idp.example.com", + "path": "/", + "expires": 1893456000, + } + + def _cookie_file(self, tmp_path): + return tmp_path / "profile" / "session-cookies.json" + + def test_session_cookies_are_saved_on_exit(self, _mock_playwright, tmp_path): + _mock_playwright["context"].cookies.return_value = [ + self._SESSION_COOKIE, + self._PERSISTENT_COOKIE, + ] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + saved = json.loads(self._cookie_file(tmp_path).read_text(encoding="utf-8")) + assert [c["name"] for c in saved["cookies"]] == ["JSESSIONID"] + + def test_persistent_cookies_are_left_to_chrome(self, _mock_playwright, tmp_path): + """Saving them too would make two stores disagree about one cookie.""" + _mock_playwright["context"].cookies.return_value = [self._PERSISTENT_COOKIE] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + assert not self._cookie_file(tmp_path).exists() + + def test_saved_cookies_are_owner_only(self, _mock_playwright, tmp_path): + """They are live credentials for as long as the session lasts.""" + _mock_playwright["context"].cookies.return_value = [self._SESSION_COOKIE] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + mode = self._cookie_file(tmp_path).stat().st_mode & 0o777 + assert mode == _SESSION_COOKIE_MODE + + def test_saved_cookies_are_restored_on_the_next_launch( + self, _mock_playwright, tmp_path + ): + _mock_playwright["context"].cookies.return_value = [self._SESSION_COOKIE] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + _mock_playwright["context"].add_cookies.reset_mock() + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + _mock_playwright["context"].add_cookies.assert_called_once_with( + [self._SESSION_COOKIE] + ) + + def test_stale_cookies_are_dropped_not_replayed(self, _mock_playwright, tmp_path): + """An old session identifier should not be sent to the IdP.""" + path = self._cookie_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + {"saved_at": time.time() - 48 * 3600, "cookies": [self._SESSION_COOKIE]} + ), + encoding="utf-8", + ) + _mock_playwright["context"].cookies.return_value = [] + + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + _mock_playwright["context"].add_cookies.assert_not_called() + assert not path.exists() + + @pytest.mark.parametrize("saved_at", [float("nan"), float("inf")]) + def test_non_finite_saved_at_is_not_replayed( + self, _mock_playwright, tmp_path, saved_at + ): + """NaN and inf pass float() and then fail the age check the wrong way.""" + path = self._cookie_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"saved_at": saved_at, "cookies": [self._SESSION_COOKIE]}), + encoding="utf-8", + ) + _mock_playwright["context"].cookies.return_value = [] + + with BrowserSession(profile_dir=tmp_path / "profile") as session: + assert session.context is _mock_playwright["context"] + + _mock_playwright["context"].add_cookies.assert_not_called() + + def test_a_failed_cookie_delete_does_not_stop_the_session( + self, _mock_playwright, tmp_path + ): + """A read-only profile costs a login, not the session.""" + path = self._cookie_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + {"saved_at": time.time() - 48 * 3600, "cookies": [self._SESSION_COOKIE]} + ), + encoding="utf-8", + ) + _mock_playwright["context"].cookies.return_value = [] + + with patch.object(type(path), "unlink", side_effect=OSError("read-only")): + with BrowserSession(profile_dir=tmp_path / "profile") as session: + assert session.context is _mock_playwright["context"] + + _mock_playwright["context"].add_cookies.assert_not_called() + + def test_a_corrupt_file_does_not_stop_the_session(self, _mock_playwright, tmp_path): + """One bad file should cost a login, not the whole run.""" + path = self._cookie_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{not json", encoding="utf-8") + _mock_playwright["context"].cookies.return_value = [] + + with BrowserSession(profile_dir=tmp_path / "profile") as session: + assert session.context is _mock_playwright["context"] + + _mock_playwright["context"].add_cookies.assert_not_called() + + def test_a_logged_out_session_clears_the_saved_cookies( + self, _mock_playwright, tmp_path + ): + """Otherwise a stale file outlives the session it belongs to.""" + path = self._cookie_file(tmp_path) + _mock_playwright["context"].cookies.return_value = [self._SESSION_COOKIE] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + assert path.exists() + + _mock_playwright["context"].cookies.return_value = [] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + assert not path.exists() + + def test_no_temp_file_is_left_behind(self, _mock_playwright, tmp_path): + _mock_playwright["context"].cookies.return_value = [self._SESSION_COOKIE] + with BrowserSession(profile_dir=tmp_path / "profile"): + pass + + leftovers = list((tmp_path / "profile").glob("*.tmp")) + assert leftovers == [] + + def test_a_leftover_tmp_file_is_not_reused(self, _mock_playwright, tmp_path): + """A crash leftover must not receive live cookies at a world-readable mode.""" + profile = tmp_path / "profile" + profile.mkdir(parents=True, exist_ok=True) + leftover = profile / "session-cookies.json.tmp" + leftover.write_text("stale", encoding="utf-8") + leftover.chmod(0o644) + _mock_playwright["context"].cookies.return_value = [self._SESSION_COOKIE] + + with BrowserSession(profile_dir=profile): + pass + + dest = self._cookie_file(tmp_path) + assert dest.exists() + mode = dest.stat().st_mode & 0o777 + assert mode == _SESSION_COOKIE_MODE + # The leftover path was never truncated into the destination. + assert leftover.read_text(encoding="utf-8") == "stale" + + def test_non_numeric_saved_at_does_not_stop_the_session( + self, _mock_playwright, tmp_path + ): + path = self._cookie_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"saved_at": "corrupt", "cookies": [self._SESSION_COOKIE]}), + encoding="utf-8", + ) + _mock_playwright["context"].cookies.return_value = [] + + with BrowserSession(profile_dir=tmp_path / "profile") as session: + assert session.context is _mock_playwright["context"] + + _mock_playwright["context"].add_cookies.assert_not_called() diff --git a/packages/python/ess-browser/src/ess_browser/test_site.py b/packages/python/ess-browser/src/ess_browser/test_site.py new file mode 100644 index 0000000..f95f364 --- /dev/null +++ b/packages/python/ess-browser/src/ess_browser/test_site.py @@ -0,0 +1,117 @@ +"""Unit tests for SsoSite and headed-mode selection.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from .auth import NAV_TIMEOUT_MS +from .session import default_profile_dir, profile_dir_display +from .site import SsoSite, should_use_headed_browser + +SETTLE_MS = 1_500 +TENANT_IDP_DOMAIN = "tenant.example.com" + + +class TestSsoSite: + def test_login_target_defaults_to_url(self) -> None: + site = SsoSite(url="https://app.example.com/") + + assert site.login_target == "https://app.example.com/" + + def test_login_target_prefers_explicit_callback_host(self) -> None: + site = SsoSite( + url="https://alias.example.com/goals", + login_complete_url="https://app.example.com/", + ) + + assert site.login_target == "https://app.example.com/" + + def test_defaults(self) -> None: + site = SsoSite(url="https://app.example.com/") + + assert site.settle_ms == 0 + assert site.nav_timeout_ms == NAV_TIMEOUT_MS + assert site.extra_auth_domains == () + + +class TestShouldUseHeadedBrowser: + site = SsoSite(url="https://app.example.com/") + + def test_both_force_flags_is_an_error(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="both --headed and --headless"): + should_use_headed_browser( + self.site, + profile_dir=tmp_path, + force_headed=True, + force_headless=True, + ) + + def test_force_headed_skips_the_probe(self, tmp_path: Path) -> None: + with patch("ess_browser.site.is_sso_login_required") as probe: + assert should_use_headed_browser( + self.site, + profile_dir=tmp_path, + force_headed=True, + ) + probe.assert_not_called() + + def test_force_headless_skips_the_probe(self, tmp_path: Path) -> None: + with patch("ess_browser.site.is_sso_login_required") as probe: + assert not should_use_headed_browser( + self.site, + profile_dir=tmp_path, + force_headless=True, + ) + probe.assert_not_called() + + def test_falls_back_to_the_probe(self, tmp_path: Path) -> None: + with patch( + "ess_browser.site.is_sso_login_required", return_value=True + ) as probe: + assert should_use_headed_browser(self.site, profile_dir=tmp_path) + probe.assert_called_once() + + def test_probe_inherits_site_settle_and_domains(self, tmp_path: Path) -> None: + """The probe must agree with the real run, or it picks the wrong mode.""" + site = SsoSite( + url="https://app.example.com/", + settle_ms=SETTLE_MS, + extra_auth_domains=(TENANT_IDP_DOMAIN,), + ) + + with patch( + "ess_browser.site.is_sso_login_required", return_value=False + ) as probe: + should_use_headed_browser(site, profile_dir=tmp_path) + + _, kwargs = probe.call_args + assert kwargs["settle_timeout_ms"] == SETTLE_MS + assert kwargs["extra_auth_domains"] == (TENANT_IDP_DOMAIN,) + + def test_zero_settle_uses_the_probe_default(self, tmp_path: Path) -> None: + with patch( + "ess_browser.site.is_sso_login_required", return_value=False + ) as probe: + should_use_headed_browser(self.site, profile_dir=tmp_path) + + _, kwargs = probe.call_args + assert kwargs["settle_timeout_ms"] > 0 + + +class TestProfileDirHelpers: + def test_default_profile_dir_is_per_tool(self) -> None: + result = default_profile_dir("my-tool") + + assert result.parent.name == ".my-tool" + assert result.name == "browser-profile" + + def test_display_form_does_not_leak_the_home_path(self) -> None: + assert profile_dir_display("my-tool") == "~/.my-tool/browser-profile" + + def test_display_form_matches_the_real_path(self) -> None: + displayed = profile_dir_display("my-tool").replace("~", str(Path.home()), 1) + + assert Path(displayed) == default_profile_dir("my-tool") diff --git a/packages/python/ess-langsmith-client/docs/README.md b/packages/python/ess-langsmith-client/docs/README.md index f46c8cb..53366c3 100644 --- a/packages/python/ess-langsmith-client/docs/README.md +++ b/packages/python/ess-langsmith-client/docs/README.md @@ -6,6 +6,6 @@ Detailed guides for each `langsmith-client` subcommand. Start with the [package - [Workspaces](workspaces.md) — `workspaces` list/get; find the workspace IDs other commands need. - [Deploying agents](deploying-agents.md) — `deploy docker` and `deploy github`: canonical naming, idempotent upsert, git-SHA rescue, secrets, scale. - [Building images](building-images.md) — `build`: LangGraph image build, tagging, `--push`. -- [Listeners](listeners.md) — `listeners list` for hybrid (self-hosted) deployments. +- [Listeners](listeners.md) — `listeners list` and `listeners delete` for hybrid (self-hosted) deployments. - [Projects](projects.md) — `projects` (tracing) vs `control-plane` (control-plane records). - [Testing agents](testing-agents.md) — `test-deployed` resolution flags. diff --git a/packages/python/ess-langsmith-client/docs/api-keys.md b/packages/python/ess-langsmith-client/docs/api-keys.md index a8b06f8..b061a90 100644 --- a/packages/python/ess-langsmith-client/docs/api-keys.md +++ b/packages/python/ess-langsmith-client/docs/api-keys.md @@ -48,6 +48,9 @@ Delete one or more keys by exact description. # Delete several keys uv run langsmith-client keys delete "key-1" "key-2" "key-3" +# List what would be deleted, then stop +uv run langsmith-client keys delete "key-1" --dry-run + # Skip the confirmation prompt uv run langsmith-client keys delete "key-1" --yes @@ -58,7 +61,8 @@ uv run langsmith-client keys delete "duplicated-name" --all | Option | Description | | --- | --- | | `--all` | Delete every key matching a description, even when one description matches multiple keys. | -| `--yes` | Skip the confirmation prompt. | +| `--dry-run` | Show the keys that would be deleted, then exit without deleting. Wins over `--yes`. | +| `--yes`, `-y` | Skip the confirmation prompt. Required when there is no terminal to prompt on. | ### Duplicate safeguard diff --git a/packages/python/ess-langsmith-client/docs/listeners.md b/packages/python/ess-langsmith-client/docs/listeners.md index 023beaf..9605b16 100644 --- a/packages/python/ess-langsmith-client/docs/listeners.md +++ b/packages/python/ess-langsmith-client/docs/listeners.md @@ -1,9 +1,10 @@ # Listeners -List LangSmith listeners with `langsmith-client listeners`. Listeners connect the LangSmith control plane to a self-hosted Kubernetes cluster; their ID is required when creating Docker-based deployments against that cluster. +List and delete LangSmith listeners with `langsmith-client listeners`. Listeners connect the LangSmith control plane to a self-hosted Kubernetes cluster; their ID is required when creating Docker-based deployments against that cluster. ```bash uv run langsmith-client listeners list +uv run langsmith-client listeners delete <listener-id> --dry-run ``` Requires `LANGSMITH_API_KEY` and `LANGSMITH_WORKSPACE_ID`. @@ -17,4 +18,17 @@ Requires `LANGSMITH_API_KEY` and `LANGSMITH_WORKSPACE_ID`. | `--region {us,eu}` | Control plane region (default: `us`). | | `--format {table,json}` | Output format (default: `table`). | -The table shows each listener's ID, name, and status. Feed the ID into [`deploy docker create --listener-id <id>`](deploying-agents.md) for hybrid deployments. +The table shows each listener's ID, compute ID (usually the Kubernetes cluster name), and version. Feed the ID into [`deploy docker create --listener-id <id>`](deploying-agents.md) for hybrid deployments. + +## `listeners delete LISTENER_ID...` + +Deletes one or more listeners by ID, for example after the cluster behind a listener has been torn down. It refuses to delete a listener that still has deployments placed on it, checking again after confirmation, and lists those deployments instead; delete or move them first. That check is a client-side snapshot: the control plane delete has no in-use precondition, so a deployment placed after the second check can still be removed with the listener. + +| Option | Description | +| --- | --- | +| `LISTENER_ID...` | One or more listener IDs (required). | +| `--dry-run` | Show the listeners that would be deleted, then exit without deleting. Wins over `--yes`. | +| `--yes`, `-y` | Skip the confirmation prompt. Required when there is no terminal to prompt on. | +| `--api-key`, `--workspace-id`, `--region` | As for `listeners list`. | + +Deleting a listener does not touch the cluster. Uninstall the dataplane Helm release there separately. diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py index ae7b199..5695d94 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/client.py @@ -9,6 +9,7 @@ import os import time +import uuid from http import HTTPStatus from typing import Any @@ -19,6 +20,8 @@ _REQUEST_TIMEOUT = 30 +_PAGE_SIZE = 100 + # Smith app/data API host (tracing sessions, API keys, workspaces). # Distinct from the control-plane host below (deployments and projects). _SMITH_API_URL = "https://api.smith.langchain.com" @@ -40,6 +43,25 @@ _UNHEALTHY_STATUS_MARKERS = ("FAIL", "ERROR", "DELET") +def _require_listener_id(listener_id: str) -> str: + """Return the canonical UUID form of a listener ID. + + The control plane takes the ID as a single path segment. Rejecting + anything else keeps values such as ``../deployments/<id>`` from being + interpolated into the request URL. + + Raises: + ValueError: If listener_id is not a UUID string. + """ + if not isinstance(listener_id, str): + raise ValueError(f"Listener ID must be a UUID, got {listener_id!r}.") + try: + parsed = uuid.UUID(listener_id) + except ValueError as exc: + raise ValueError(f"Listener ID must be a UUID, got {listener_id!r}.") from exc + return str(parsed) + + class ControlPlaneClient: """Client for the LangSmith Control Plane API.""" @@ -109,6 +131,104 @@ def list_listeners(self) -> dict[str, Any]: f"Failed to list listeners: {response.status_code}\n{response.text}" ) + def get_listener(self, listener_id: str) -> dict[str, Any]: + """Get a specific listener by ID. + + Args: + listener_id: Listener UUID. Other values are rejected before any + request is sent. + + Raises: + ValueError: If listener_id is not a UUID. + RuntimeError: If the API returns a non-200 status. + """ + listener_id = _require_listener_id(listener_id) + response = requests.get( + f"{self.base_url}/listeners/{listener_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + + if response.status_code == HTTPStatus.OK: + return response.json() + raise RuntimeError( + f"Failed to get listener {listener_id}: " + f"{response.status_code}\n{response.text}" + ) + + def delete_listener(self, listener_id: str) -> bool: + """Delete a listener by ID. + + Args: + listener_id: Listener UUID. Other values are rejected before any + request is sent. + + Returns: + True on success (HTTP 204). + + Raises: + ValueError: If listener_id is not a UUID. + RuntimeError: If the API returns a non-204 status, or the request + fails in transit. A transport failure means the deletion + outcome is unknown and must be checked before retrying. + """ + listener_id = _require_listener_id(listener_id) + try: + response = requests.delete( + f"{self.base_url}/listeners/{listener_id}", + headers=self.headers, + timeout=_REQUEST_TIMEOUT, + ) + except requests.RequestException as exc: + raise RuntimeError( + f"Failed to delete listener {listener_id}: {exc}. " + "The deletion outcome is unknown; check whether the listener " + "still exists before retrying, since the server may already " + "have processed the DELETE." + ) from exc + + if response.status_code == HTTPStatus.NO_CONTENT: + return True + raise RuntimeError( + f"Failed to delete listener {listener_id}: " + f"{response.status_code}\n{response.text}" + ) + + def list_all_deployments(self) -> list[dict[str, Any]]: + """List every deployment in the workspace, following pagination. + + Raises: + RuntimeError: If a page is not HTTP 200, or its body has no + ``resources`` list. A missing list is not an empty page, so a + listener delete cannot proceed when the in-use check cannot + see deployments. + """ + deployments: list[dict[str, Any]] = [] + offset = 0 + while True: + response = requests.get( + f"{self.base_url}/deployments", + headers=self.headers, + params={"limit": _PAGE_SIZE, "offset": offset}, + timeout=_REQUEST_TIMEOUT, + ) + if response.status_code != HTTPStatus.OK: + raise RuntimeError( + f"Failed to list deployments: " + f"{response.status_code}\n{response.text}" + ) + payload = response.json() + page = payload.get("resources") if isinstance(payload, dict) else None + if not isinstance(page, list): + raise RuntimeError( + "Failed to list deployments: response has no resources list.\n" + f"{response.text}" + ) + deployments.extend(page) + if len(page) < _PAGE_SIZE: + return deployments + offset += len(page) + def list_deployments(self, name_contains: str | None = None) -> dict[str, Any]: """List all deployments, optionally filtered by name.""" params = {} diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py index 552a8d5..b6d3432 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/secrets.py @@ -3,6 +3,15 @@ import os from collections.abc import Iterable +_PLATFORM_MANAGED_SECRET_NAMES = frozenset( + { + "LANGSMITH_CONTROL_PLANE_API_KEY", + "LANGSMITH_ENDPOINT", + "LANGSMITH_PROJECT", + "LANGSMITH_TRACING", + } +) + def get_env_secrets(keys: Iterable[str]) -> list[dict[str, str]]: """Read the named environment variables as deployment secrets. @@ -48,10 +57,23 @@ def merge_secrets( auto_detect_keys: Environment variable names to fall back to. Returns: - The merged list. ``cli_secrets`` win on name collisions. + The merged list. ``cli_secrets`` win on name collisions. Names managed + by the LangSmith control plane are omitted because the deployment API + rejects customer attempts to set them. """ all_secrets = parse_secrets(list(cli_secrets)) for secret in get_env_secrets(auto_detect_keys): if not any(existing["name"] == secret["name"] for existing in all_secrets): all_secrets.append(secret) - return all_secrets + filtered_secrets = [ + secret + for secret in all_secrets + if secret["name"] not in _PLATFORM_MANAGED_SECRET_NAMES + ] + if all_secrets and not filtered_secrets: + names = ", ".join(sorted(secret["name"] for secret in all_secrets)) + raise ValueError( + "All requested deployment secrets are managed by the LangSmith " + f"control plane and cannot be submitted: {names}" + ) + return filtered_secrets diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py index 0e579e7..3b59ce3 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_cli.py @@ -32,7 +32,7 @@ def _deployment(name, custom_url): class TestResolveLiveDeploymentRecord: def test_returns_live_deployment(self, monkeypatch): - resource = _deployment("hello-agent-dev", "https://a.example/lgp/x") + resource = _deployment("hello-agent-dev", "https://app.example.com/lgp/x") _stub_resources(monkeypatch, [resource]) client = ControlPlaneClient(**_CREDS) live = resolve_live_deployment_record(client, service="hello-agent", env="dev") @@ -51,12 +51,12 @@ def test_returns_none_when_missing(self, monkeypatch): class TestResolveDeploymentBaseUrl: def test_returns_custom_url_for_canonical_name(self, monkeypatch): - url = "https://a.example/lgp/hello-agent-dev-abc" + url = "https://app.example.com/lgp/hello-agent-dev-abc" _stub_resources(monkeypatch, [_deployment("hello-agent-dev", url)]) assert resolve_deployment_base_url("hello-agent", env="dev", **_CREDS) == url def test_explicit_deployment_overrides_service(self, monkeypatch): - url = "https://a.example/lgp/hello-agent-prod-dev-xyz" + url = "https://app.example.com/lgp/hello-agent-prod-dev-xyz" _stub_resources(monkeypatch, [_deployment("hello-agent-prod-dev", url)]) resolved = resolve_deployment_base_url( "hello-agent", deployment="hello-agent-prod-dev", **_CREDS diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py index c9d30c5..a0a0288 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_client.py @@ -1,9 +1,15 @@ -"""Tests for ControlPlaneClient deployment resolution.""" +"""Tests for ControlPlaneClient deployment resolution and listener calls.""" + +from http import HTTPStatus import pytest +import requests +from ess_langsmith_client import client as client_module from ess_langsmith_client.client import ControlPlaneClient +_LISTENER_ID = "11111111-1111-1111-1111-111111111111" + @pytest.fixture def client() -> ControlPlaneClient: @@ -126,3 +132,123 @@ def test_returns_none_when_all_stuck(self, client): [{"name": "hello-agent-dev", "id": "stuck", "status": "FAILED"}], ) assert client.resolve_live_deployment("hello-agent-dev") is None + + +class _FakeResponse: + def __init__(self, status_code: int, payload: dict | None = None): + self.status_code = status_code + self._payload = payload or {} + self.text = "" + + def json(self) -> dict: + return self._payload + + +class TestListAllDeployments: + def test_follows_pagination(self, client, monkeypatch): + page_size = client_module._PAGE_SIZE + pages = [ + [{"id": f"a{i}"} for i in range(page_size)], + [{"id": "last"}], + ] + offsets: list[int] = [] + + def _fake_get(url, headers, params, timeout): + offsets.append(params["offset"]) + return _FakeResponse(HTTPStatus.OK, {"resources": pages[len(offsets) - 1]}) + + monkeypatch.setattr(client_module.requests, "get", _fake_get) + deployments = client.list_all_deployments() + assert len(deployments) == page_size + 1 + assert offsets == [0, page_size] + + def test_raises_on_error(self, client, monkeypatch): + monkeypatch.setattr( + client_module.requests, + "get", + lambda *args, **kwargs: _FakeResponse(HTTPStatus.FORBIDDEN), + ) + with pytest.raises(RuntimeError, match="403"): + client.list_all_deployments() + + def test_empty_resources_is_an_empty_workspace(self, client, monkeypatch): + monkeypatch.setattr( + client_module.requests, + "get", + lambda *args, **kwargs: _FakeResponse(HTTPStatus.OK, {"resources": []}), + ) + assert client.list_all_deployments() == [] + + @pytest.mark.parametrize( + "payload", + [{}, {"resources": {}}, {"resources": None}], + ) + def test_missing_resources_fails_closed(self, client, monkeypatch, payload): + monkeypatch.setattr( + client_module.requests, + "get", + lambda *args, **kwargs: _FakeResponse(HTTPStatus.OK, payload), + ) + with pytest.raises(RuntimeError, match="resources"): + client.list_all_deployments() + + def test_non_object_body_fails_closed(self, client, monkeypatch): + class _ListBody: + status_code = HTTPStatus.OK + text = "[]" + + def json(self) -> list: + return [] + + monkeypatch.setattr( + client_module.requests, + "get", + lambda *args, **kwargs: _ListBody(), + ) + with pytest.raises(RuntimeError, match="resources"): + client.list_all_deployments() + + +class TestDeleteListener: + def test_no_content_is_success(self, client, monkeypatch): + urls: list[str] = [] + + def _fake_delete(url, headers, timeout): + urls.append(url) + return _FakeResponse(HTTPStatus.NO_CONTENT) + + monkeypatch.setattr(client_module.requests, "delete", _fake_delete) + assert client.delete_listener(_LISTENER_ID.upper()) is True + assert urls == [f"{client.base_url}/listeners/{_LISTENER_ID}"] + + def test_not_found_raises(self, client, monkeypatch): + monkeypatch.setattr( + client_module.requests, + "delete", + lambda *args, **kwargs: _FakeResponse(HTTPStatus.NOT_FOUND), + ) + with pytest.raises(RuntimeError, match="404"): + client.delete_listener(_LISTENER_ID) + + def test_rejects_non_uuid_before_request(self, client, monkeypatch): + def _boom(*args, **kwargs): + raise AssertionError("request should not be sent") + + monkeypatch.setattr(client_module.requests, "get", _boom) + monkeypatch.setattr(client_module.requests, "delete", _boom) + traversal = f"../deployments/{_LISTENER_ID}" + fragment = f"{_LISTENER_ID}#fragment" + for listener_id in (traversal, fragment): + with pytest.raises(ValueError, match="UUID"): + client.get_listener(listener_id) + with pytest.raises(ValueError, match="UUID"): + client.delete_listener(listener_id) + + def test_transport_error_outcome_is_unknown(self, client, monkeypatch): + def _fake_delete(url, headers, timeout): + raise requests.Timeout("timed out") + + monkeypatch.setattr(client_module.requests, "delete", _fake_delete) + with pytest.raises(RuntimeError, match="outcome is unknown") as exc_info: + client.delete_listener(_LISTENER_ID) + assert isinstance(exc_info.value.__cause__, requests.Timeout) diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py index 6adb0f6..067597d 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/test_secrets.py @@ -46,6 +46,55 @@ def test_merge_secrets_cli_wins_over_environment(monkeypatch): ] +def test_merge_secrets_omits_platform_managed_cli_secret(): + assert merge_secrets( + [ + "LANGSMITH_CONTROL_PLANE_API_KEY=platform-owned", + "LANGSMITH_ENDPOINT=https://langsmith.example.com", + "LANGSMITH_PROJECT=platform-project", + "LANGSMITH_TRACING=true", + "LANGSMITH_API_KEY=deployment-key", + ] + ) == [{"name": "LANGSMITH_API_KEY", "value": "deployment-key"}] + + +def test_merge_secrets_omits_platform_managed_environment_secret(monkeypatch): + monkeypatch.setenv("LANGSMITH_CONTROL_PLANE_API_KEY", "platform-owned") + monkeypatch.setenv("LANGSMITH_API_KEY", "deployment-key") + + assert merge_secrets( + [], + auto_detect_keys=[ + "LANGSMITH_CONTROL_PLANE_API_KEY", + "LANGSMITH_API_KEY", + ], + ) == [{"name": "LANGSMITH_API_KEY", "value": "deployment-key"}] + + +@pytest.mark.parametrize( + "name", + [ + "LANGSMITH_CONTROL_PLANE_API_KEY", + "LANGSMITH_ENDPOINT", + "LANGSMITH_PROJECT", + "LANGSMITH_TRACING", + ], +) +def test_merge_secrets_rejects_platform_managed_only_cli_input(name: str): + with pytest.raises(ValueError, match=name): + merge_secrets([f"{name}=platform-owned"]) + + +def test_merge_secrets_rejects_platform_managed_only_environment_input(monkeypatch): + monkeypatch.setenv("LANGSMITH_CONTROL_PLANE_API_KEY", "platform-owned") + + with pytest.raises(ValueError, match="LANGSMITH_CONTROL_PLANE_API_KEY"): + merge_secrets( + [], + auto_detect_keys=["LANGSMITH_CONTROL_PLANE_API_KEY"], + ) + + @pytest.mark.parametrize("cli_secrets", [(), []]) def test_merge_secrets_accepts_tuple_or_list(cli_secrets): assert merge_secrets(cli_secrets) == [] diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py index 5bfab7d..8ba2b64 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_docker.py @@ -52,12 +52,12 @@ get_git_sha, get_project_info, handle_wait, - merge_secrets, print_deployments, resolve_live_deployment_record, ) from ess_langsmith_client.client import _REQUEST_TIMEOUT from ess_langsmith_client.naming import resolve_deploy_base +from ess_langsmith_client.tools.secret_options import merge_cli_secrets def extract_name_from_image_uri(image_uri: str) -> str: @@ -271,6 +271,7 @@ def create( - creates a git-SHA rescue name (<service>-<env>-<sha>) only when the canonical name is stuck/orphaned. """ + secret_list = merge_cli_secrets(secrets) client = create_client(DockerDeploymentClient, api_key, workspace_id, region) # Get project info from pyproject.toml @@ -306,8 +307,6 @@ def create( "Provide --image-uri or ensure pyproject.toml exists" ) - secret_list = merge_secrets(secrets) - try: live = client.resolve_live_deployment(base) @@ -395,6 +394,7 @@ def update( wait: bool, ): """Update an existing deployment with a new Docker image.""" + secret_list = merge_cli_secrets(secrets) # Derive image URI from pyproject.toml if not provided if not image_uri: project = get_project_info(start_path=Path(project_dir)) @@ -408,8 +408,6 @@ def update( ) client = create_client(DockerDeploymentClient, api_key, workspace_id, region) - secret_list = merge_secrets(secrets) - click.echo(f"Updating deployment: {deployment_id}") click.echo(f" New image: {image_uri}") if secret_list: diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py index 7004755..303594d 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/deploy_github.py @@ -49,10 +49,10 @@ get_git_sha, get_project_info, handle_wait, - merge_secrets, print_deployments, ) from ess_langsmith_client.client import _REQUEST_TIMEOUT +from ess_langsmith_client.tools.secret_options import merge_cli_secrets class GitHubDeploymentClient(ControlPlaneClient): @@ -266,6 +266,7 @@ def create( - creates a git-SHA rescue name (<service>-<env>-<sha>) only when the canonical name is stuck/orphaned. """ + secret_list = merge_cli_secrets(secrets) if not integration_id: raise click.ClickException( "GitHub integration ID is required.\n" @@ -301,7 +302,7 @@ def create( branch=branch, config_path=config_path, build_on_push=auto_build, - secrets=merge_secrets(secrets), + secrets=secret_list, min_scale=min_scale, max_scale=max_scale, cpu=cpu, @@ -340,7 +341,7 @@ def create( deployment_type=deployment_type, build_on_push=auto_build, shareable=shareable, - secrets=merge_secrets(secrets), + secrets=secret_list, min_scale=min_scale, max_scale=max_scale, cpu=cpu, diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py index 186df0d..6ebf04d 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py @@ -12,10 +12,12 @@ langsmith-client keys create "LangSmith Deployment: my-app" langsmith-client keys delete "my-old-key" "another-old-key" langsmith-client keys delete "duplicated-name" --all + langsmith-client keys delete "my-old-key" --dry-run """ import json import os +import sys from datetime import datetime, timezone from http import HTTPStatus from typing import Any @@ -218,6 +220,21 @@ def _format_duplicate_error(duplicates: dict[str, list[dict[str, Any]]]) -> str: return "\n".join(error_lines) +def _is_interactive() -> bool: + return sys.stdin.isatty() + + +def _confirm_or_abort(key_count: int, *, skip: bool) -> None: + """Require explicit consent before deleting keys.""" + if skip: + return + # Without a terminal there is no one to answer the prompt, so make the + # caller opt in rather than reading an empty stdin as "no". + if not _is_interactive(): + raise click.UsageError("Not running interactively; pass --yes to delete.") + click.confirm(f"Delete {key_count} key(s)?", abort=True, err=True) + + def _common_options(func): func = click.option( "--api-key", @@ -349,12 +366,21 @@ def create( "matches more than one key (default: error on duplicates)." ), ) -@click.option("--yes", is_flag=True, help="Skip confirmation prompt") -def delete( +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Show the keys that would be deleted without deleting them.", +) +@click.option( + "--yes", "-y", is_flag=True, default=False, help="Skip the confirmation prompt." +) +def delete( # noqa: PLR0913 -- Click maps one parameter per option api_key: str | None, workspace_id: str | None, descriptions: tuple[str, ...], delete_all: bool, + dry_run: bool, yes: bool, ): """Delete API keys by description (name). @@ -366,11 +392,16 @@ def delete( refuses to delete and exits with an error. Pass --all to explicitly delete every matching key. + The command lists the keys it will delete and asks for confirmation. + Pass --yes to skip the prompt, which is required when not running in a + terminal. Pass --dry-run to list the keys and stop; it wins over --yes. + \b Examples: langsmith-client keys delete "my-old-key" langsmith-client keys delete "key-1" "key-2" "key-3" langsmith-client keys delete "key-1" --yes + langsmith-client keys delete "key-1" --dry-run langsmith-client keys delete "duplicated-name" --all """ try: @@ -399,8 +430,11 @@ def delete( ) click.echo() - if not yes: - click.confirm("Delete these keys?", abort=True) + if dry_run: + click.echo(f"Dry run: {len(targets)} key(s) would be deleted; nothing changed.") + return + + _confirm_or_abort(len(targets), skip=yes) deleted = 0 failed = 0 diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_listeners.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_listeners.py deleted file mode 100644 index 6226943..0000000 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/list_listeners.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env python3 -""" -List LangSmith listeners (hybrid deployment agents). - -Listeners connect the LangSmith control plane to a self-hosted Kubernetes -cluster. The listener ID is required when creating Docker-based deployments -against a self-hosted cluster. - -PREREQUISITES: -- LANGSMITH_API_KEY: Your LangSmith API key -- LANGSMITH_WORKSPACE_ID: Your LangSmith workspace ID - -USAGE: - langsmith-client listeners list - langsmith-client listeners list --format json - -CONTROL PLANE API REFERENCE: - https://docs.langchain.com/langsmith/api-ref-control-plane -""" - -import json -from typing import Any - -import click - -from ess_langsmith_client import ( - ControlPlaneClient, - common_options, - create_client, -) - -# ============================================================================= -# Output Formatters -# ============================================================================= - - -def print_table(listeners: list[dict[str, Any]]) -> None: - """Print listeners as a formatted table.""" - if not listeners: - click.echo("No listeners found.") - return - - id_width = max( - (len(str(listener.get("id", ""))) for listener in listeners), - default=36, - ) - id_width = max(id_width, len("ID")) - name_width = max( - (len(str(listener.get("name", ""))) for listener in listeners), - default=20, - ) - name_width = max(name_width, len("Name")) - - click.echo(f"\nFound {len(listeners)} listener(s):\n") - header = f"{'ID':<{id_width}} {'Name':<{name_width}} Status" - click.echo(header) - click.echo("-" * len(header)) - - for listener in listeners: - click.echo( - f"{str(listener.get('id', '')):<{id_width}} " - f"{str(listener.get('name', '')):<{name_width}} " - f"{listener.get('status', 'N/A')}" - ) - - click.echo() - - -def print_json(listeners: list[dict[str, Any]]) -> None: - """Print listeners as JSON.""" - click.echo(json.dumps(listeners, indent=2, default=str)) - - -# ============================================================================= -# CLI Commands -# ============================================================================= - - -@click.group() -@click.version_option(version="1.0.0") -def cli(): - """List LangSmith listeners for hybrid (self-hosted) deployments. - - \b - PREREQUISITES: - - LANGSMITH_API_KEY: Your LangSmith API key - - LANGSMITH_WORKSPACE_ID: Your workspace ID (from LangSmith settings) - - \b - The listener ID displayed here is used as --listener-id when creating - Docker-based deployments against a self-hosted Kubernetes cluster. - """ - pass - - -@cli.command("list") -@common_options -@click.option( - "--format", - "output_format", - type=click.Choice(["table", "json"]), - default="table", - help="Output format (default: table)", -) -def list_listeners( - region: str, - api_key: str | None, - workspace_id: str | None, - output_format: str, -): - """List listeners for hybrid deployments.""" - client: ControlPlaneClient = create_client( - ControlPlaneClient, api_key, workspace_id, region - ) - - click.echo("Fetching listeners...") - - try: - result = client.list_listeners() - listeners = ( - result.get("resources", result) if isinstance(result, dict) else result - ) - - if output_format == "json": - print_json(listeners) - else: - print_table(listeners) - - except RuntimeError as e: - raise click.ClickException(str(e)) from e - - -if __name__ == "__main__": - cli() diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/listeners.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/listeners.py new file mode 100644 index 0000000..378d8b2 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/listeners.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +List and delete LangSmith listeners (hybrid deployment agents). + +Listeners connect the LangSmith control plane to a self-hosted Kubernetes +cluster. The listener ID is required when creating Docker-based deployments +against a self-hosted cluster. + +PREREQUISITES: +- LANGSMITH_API_KEY: Your LangSmith API key +- LANGSMITH_WORKSPACE_ID: Your LangSmith workspace ID + +USAGE: + langsmith-client listeners list + langsmith-client listeners list --format json + langsmith-client listeners delete <listener-id> --dry-run + langsmith-client listeners delete <listener-id> + +CONTROL PLANE API REFERENCE: + https://docs.langchain.com/langsmith/api-ref-control-plane +""" + +import json +import sys +from typing import Any + +import click + +from ess_langsmith_client import ( + ControlPlaneClient, + common_options, + create_client, +) + +# ============================================================================= +# Output Formatters +# ============================================================================= + + +def print_table(listeners: list[dict[str, Any]]) -> None: + """Print listeners as a formatted table.""" + if not listeners: + click.echo("No listeners found.") + return + + id_width = max( + (len(str(listener.get("id", ""))) for listener in listeners), + default=36, + ) + id_width = max(id_width, len("ID")) + compute_width = max( + (len(str(listener.get("compute_id", ""))) for listener in listeners), + default=20, + ) + compute_width = max(compute_width, len("Compute ID")) + + click.echo(f"\nFound {len(listeners)} listener(s):\n") + header = f"{'ID':<{id_width}} {'Compute ID':<{compute_width}} Version" + click.echo(header) + click.echo("-" * len(header)) + + for listener in listeners: + click.echo( + f"{str(listener.get('id', '')):<{id_width}} " + f"{str(listener.get('compute_id', '')):<{compute_width}} " + f"{listener.get('version') or 'N/A'}" + ) + + click.echo() + + +def print_json(listeners: list[dict[str, Any]]) -> None: + """Print listeners as JSON.""" + click.echo(json.dumps(listeners, indent=2, default=str)) + + +def _describe_listener(listener: dict[str, Any]) -> str: + namespaces = (listener.get("compute_config") or {}).get("k8s_namespaces") or [] + return ( + f"{listener.get('id', '')} " + f"compute_id={listener.get('compute_id') or '?'} " + f"namespaces={','.join(namespaces) or '?'} " + f"created={str(listener.get('created_at') or '?')[:10]}" + ) + + +def _format_in_use_error(placements: dict[str, list[dict[str, Any]]]) -> str: + """Build the error listing deployments still placed on target listeners.""" + lines = ["Refusing to delete: these listeners still have deployments."] + for listener_id, deployments in sorted(placements.items()): + lines.append(f" {listener_id} serves {len(deployments)} deployment(s):") + lines.extend( + f" {deployment.get('name', '?')} id={deployment.get('id', '?')}" + for deployment in deployments + ) + lines.append("Delete or move those deployments first.") + return "\n".join(lines) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def _deployments_by_listener( + deployments: list[dict[str, Any]], listener_ids: tuple[str, ...] +) -> dict[str, list[dict[str, Any]]]: + """Map each target listener ID to the deployments placed on it.""" + placements: dict[str, list[dict[str, Any]]] = {} + for deployment in deployments: + listener_id = (deployment.get("source_config") or {}).get("listener_id") + if listener_id in listener_ids: + placements.setdefault(listener_id, []).append(deployment) + return placements + + +def _resolve_targets( + client: ControlPlaneClient, listener_ids: tuple[str, ...] +) -> list[dict[str, Any]]: + """Look up each listener and dedupe by the ID the API returns. + + Placement checks and deletes must use that ID. An argument the server + resolves to a different ID (for example a non-canonical UUID spelling) + would otherwise miss deployments placed on the real listener. + """ + targets: list[dict[str, Any]] = [] + seen: set[str] = set() + for listener_id in dict.fromkeys(listener_ids): + listener = client.get_listener(listener_id) + resolved_id = listener.get("id") + if not isinstance(resolved_id, str) or not resolved_id: + raise RuntimeError( + f"Listener lookup for {listener_id} did not return an id." + ) + if resolved_id in seen: + continue + seen.add(resolved_id) + targets.append(listener) + return targets + + +def _refuse_in_use(client: ControlPlaneClient, listener_ids: tuple[str, ...]) -> None: + """Raise when any target listener still has deployments placed on it.""" + try: + placements = _deployments_by_listener( + client.list_all_deployments(), listener_ids + ) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from exc + if placements: + raise click.ClickException(_format_in_use_error(placements)) + + +def _is_interactive() -> bool: + return sys.stdin.isatty() + + +def _confirm_or_abort(count: int, workspace_id: str | None, *, skip: bool) -> None: + """Require explicit consent before deleting listeners.""" + if skip: + return + if not _is_interactive(): + raise click.UsageError( + "No terminal to confirm the delete; pass --yes to confirm it." + ) + click.confirm( + f"Delete {count} listener(s) from workspace {workspace_id or '(default)'}?", + abort=True, + err=True, + ) + + +# ============================================================================= +# CLI Commands +# ============================================================================= + + +@click.group() +@click.version_option(version="1.0.0") +def cli(): + """List and delete LangSmith listeners for hybrid (self-hosted) deployments. + + \b + PREREQUISITES: + - LANGSMITH_API_KEY: Your LangSmith API key + - LANGSMITH_WORKSPACE_ID: Your workspace ID (from LangSmith settings) + + \b + The listener ID displayed here is used as --listener-id when creating + Docker-based deployments against a self-hosted Kubernetes cluster. + """ + pass + + +@cli.command("list") +@common_options +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format (default: table)", +) +def list_listeners( + region: str, + api_key: str | None, + workspace_id: str | None, + output_format: str, +): + """List listeners for hybrid deployments.""" + client: ControlPlaneClient = create_client( + ControlPlaneClient, api_key, workspace_id, region + ) + + click.echo("Fetching listeners...") + + try: + result = client.list_listeners() + listeners = ( + result.get("resources", result) if isinstance(result, dict) else result + ) + + if output_format == "json": + print_json(listeners) + else: + print_table(listeners) + + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + +@cli.command("delete") +@common_options +@click.argument("listener_ids", nargs=-1, required=True) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Show the listeners that would be deleted without deleting them.", +) +@click.option( + "--yes", "-y", is_flag=True, default=False, help="Skip the confirmation prompt." +) +def delete_listeners( # noqa: PLR0913 # one param per CLI option + region: str, + api_key: str | None, + workspace_id: str | None, + listener_ids: tuple[str, ...], + dry_run: bool, + yes: bool, +): + """Delete listeners by ID. + + Refuses to delete a listener that still has deployments placed on it, so + a live cluster cannot be detached by mistake. That check runs before the + confirmation prompt and again immediately after it. The control plane + DELETE has no in-use precondition, so a deployment placed after the + second check can still be removed with the listener. Delete or move + deployments first. + + \b + Examples: + langsmith-client listeners delete <listener-id> --dry-run + langsmith-client listeners delete <listener-id> + langsmith-client listeners delete <id-1> <id-2> --yes + """ + client: ControlPlaneClient = create_client( + ControlPlaneClient, api_key, workspace_id, region + ) + + try: + targets = _resolve_targets(client, listener_ids) + except (RuntimeError, ValueError) as e: + raise click.ClickException(str(e)) from e + + resolved_ids = tuple(str(listener["id"]) for listener in targets) + _refuse_in_use(client, resolved_ids) + + workspace_label = client.workspace_id or "(default)" + click.echo( + f"\nListeners to delete ({len(targets)}) in workspace {workspace_label}:\n" + ) + for listener in targets: + click.echo(f" {_describe_listener(listener)}") + click.echo() + + if dry_run: + click.echo("Dry run: no listeners deleted.") + return + + _confirm_or_abort(len(targets), client.workspace_id, skip=yes) + _refuse_in_use(client, resolved_ids) + + deleted = 0 + failed = 0 + for listener_id in resolved_ids: + try: + client.delete_listener(listener_id) + click.echo(click.style(f" Deleted: {listener_id}", fg="green")) + deleted += 1 + except RuntimeError as e: + click.echo(click.style(f" Failed: {listener_id} -- {e}", fg="red")) + failed += 1 + + click.echo() + if deleted: + click.echo(click.style(f"{deleted} listener(s) deleted.", fg="green")) + if failed: + raise click.ClickException(f"{failed} listener(s) failed to delete.") + + +if __name__ == "__main__": + cli() diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py index 0bf1468..5bdac9b 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/main.py @@ -26,8 +26,8 @@ deploy_docker, deploy_github, keys, - list_listeners, list_workspaces, + listeners, projects, ) @@ -51,7 +51,7 @@ def deploy() -> None: cli.add_command(keys.cli, name="keys") cli.add_command(list_workspaces.cli, name="workspaces") -cli.add_command(list_listeners.cli, name="listeners") +cli.add_command(listeners.cli, name="listeners") cli.add_command(projects.cli, name="projects") cli.add_command(control_plane.cli, name="control-plane") cli.add_command(build.build, name="build") diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/secret_options.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/secret_options.py new file mode 100644 index 0000000..f275618 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/secret_options.py @@ -0,0 +1,13 @@ +"""Click-facing validation for deployment secret options.""" + +import click + +from ess_langsmith_client.secrets import merge_secrets + + +def merge_cli_secrets(secrets: tuple[str, ...]) -> list[dict[str, str]]: + """Merge CLI secrets and present validation failures as usage errors.""" + try: + return merge_secrets(secrets) + except ValueError as exc: + raise click.UsageError(str(exc)) from exc diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_docker.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_docker.py new file mode 100644 index 0000000..76afd01 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_docker.py @@ -0,0 +1,41 @@ +"""Tests for Docker deployment CLI validation.""" + +import pytest +from click import UsageError +from click.testing import CliRunner + +from ess_langsmith_client.tools.deploy_docker import cli + + +@pytest.mark.parametrize( + "args", + [ + [ + "create", + "--deployment", + "agent-test", + "--image-uri", + "registry.example.com/agent:test", + ], + [ + "update", + "--deployment-id", + "deployment-id", + "--image-uri", + "registry.example.com/agent:test", + ], + ], +) +def test_reserved_only_secret_is_an_operator_facing_error(args: list[str]) -> None: + result = CliRunner().invoke( + cli, + [ + *args, + "--secret", + "LANGSMITH_CONTROL_PLANE_API_KEY=platform-owned", + ], + ) + + assert result.exit_code == UsageError.exit_code + assert "Error: All requested deployment secrets are managed" in result.output + assert "LANGSMITH_CONTROL_PLANE_API_KEY" in result.output diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_github.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_github.py new file mode 100644 index 0000000..f03e945 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_deploy_github.py @@ -0,0 +1,23 @@ +"""Tests for GitHub deployment CLI validation.""" + +from click import UsageError +from click.testing import CliRunner + +from ess_langsmith_client.tools.deploy_github import cli + + +def test_reserved_only_secret_is_an_operator_facing_error() -> None: + result = CliRunner().invoke( + cli, + [ + "create", + "--repo-url", + "https://git.example.com/owner/repository", + "--secret", + "LANGSMITH_CONTROL_PLANE_API_KEY=platform-owned", + ], + ) + + assert result.exit_code == UsageError.exit_code + assert "Error: All requested deployment secrets are managed" in result.output + assert "LANGSMITH_CONTROL_PLANE_API_KEY" in result.output diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py index 5845878..1ec4394 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py @@ -201,3 +201,74 @@ def test_unique_deletes_single(self, monkeypatch): result = self._run(monkeypatch, ["unique-key", "--yes"], deleted_ids) assert result.exit_code == 0 assert deleted_ids == ["id-3"] + + def test_dry_run_deletes_nothing(self, monkeypatch): + deleted_ids: list[str] = [] + result = self._run(monkeypatch, ["unique-key", "--dry-run"], deleted_ids) + assert result.exit_code == 0 + assert "unique-key" in result.output + assert "nothing changed" in result.output + assert deleted_ids == [] + + def test_dry_run_wins_over_yes(self, monkeypatch): + deleted_ids: list[str] = [] + result = self._run( + monkeypatch, ["dup-key", "--all", "--dry-run", "--yes"], deleted_ids + ) + assert result.exit_code == 0 + assert deleted_ids == [] + + def test_yes_skips_the_prompt(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + deleted_ids: list[str] = [] + result = self._run(monkeypatch, ["unique-key", "--yes"], deleted_ids) + assert result.exit_code == 0 + assert "Delete 1 key(s)?" not in result.output + assert deleted_ids == ["id-3"] + + def test_prompt_goes_to_stderr(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + monkeypatch.setattr( + keys.APIKeyClient, "list_keys", lambda self: _keys_with_duplicate() + ) + deleted_ids: list[str] = [] + monkeypatch.setattr( + keys.APIKeyClient, + "delete_key", + lambda self, key_id: deleted_ids.append(key_id), + ) + + result = CliRunner().invoke( + cli, ["delete", "--api-key", "test-key", "unique-key"], input="y\n" + ) + + assert result.exit_code == 0 + assert "Delete 1 key(s)?" in result.stderr + assert "Delete 1 key(s)?" not in result.stdout + assert deleted_ids == ["id-3"] + + def test_declining_the_prompt_deletes_nothing(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + deleted_ids: list[str] = [] + monkeypatch.setattr( + keys.APIKeyClient, "list_keys", lambda self: _keys_with_duplicate() + ) + monkeypatch.setattr( + keys.APIKeyClient, + "delete_key", + lambda self, key_id: deleted_ids.append(key_id), + ) + + result = CliRunner().invoke( + cli, ["delete", "--api-key", "test-key", "unique-key"], input="n\n" + ) + + assert result.exit_code != 0 + assert deleted_ids == [] + + def test_non_interactive_without_yes_refuses(self, monkeypatch): + deleted_ids: list[str] = [] + result = self._run(monkeypatch, ["unique-key"], deleted_ids) + assert result.exit_code != 0 + assert "--yes" in result.output + assert deleted_ids == [] diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_listeners.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_listeners.py new file mode 100644 index 0000000..f5d92e7 --- /dev/null +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_listeners.py @@ -0,0 +1,178 @@ +"""Tests for langsmith-client listeners list and delete.""" + +from typing import Any + +import pytest +from click.testing import CliRunner + +from ess_langsmith_client.tools import listeners +from ess_langsmith_client.tools.listeners import cli + +_IDLE_ID = "11111111-1111-1111-1111-111111111111" +_BUSY_ID = "22222222-2222-2222-2222-222222222222" + + +class _FakeClient: + """Stands in for ControlPlaneClient; records delete calls.""" + + workspace_id = "test-ws" + + def __init__(self) -> None: + self.deleted: list[str] = [] + + def list_listeners(self) -> dict[str, Any]: + return {"resources": [self.get_listener(_IDLE_ID)]} + + def get_listener(self, listener_id: str) -> dict[str, Any]: + return { + "id": listener_id, + "compute_id": f"cluster-{listener_id[:4]}", + "compute_config": {"k8s_namespaces": ["default"]}, + "created_at": "2026-04-06T00:00:00Z", + "version": "0.13.9", + } + + def list_all_deployments(self) -> list[dict[str, Any]]: + return [ + { + "id": "dep-1", + "name": "live-agent", + "source_config": {"listener_id": _BUSY_ID}, + }, + ] + + def delete_listener(self, listener_id: str) -> bool: + self.deleted.append(listener_id) + return True + + +@pytest.fixture +def fake_client(monkeypatch) -> _FakeClient: + client = _FakeClient() + monkeypatch.setattr(listeners, "create_client", lambda *args, **kwargs: client) + return client + + +def _invoke(args: list[str], stdin: str | None = None): + return CliRunner().invoke(cli, [*args, "--api-key", "test-key"], input=stdin) + + +class TestList: + def test_table_shows_compute_id_and_version(self, fake_client): + result = _invoke(["list"]) + assert result.exit_code == 0 + assert "cluster-1111" in result.output + assert "0.13.9" in result.output + + +class TestDelete: + def test_dry_run_deletes_nothing(self, fake_client): + result = _invoke(["delete", _IDLE_ID, "--dry-run"]) + assert result.exit_code == 0 + assert _IDLE_ID in result.output + assert "Dry run" in result.output + assert fake_client.deleted == [] + + def test_dry_run_wins_over_yes(self, fake_client): + result = _invoke(["delete", _IDLE_ID, "--dry-run", "--yes"]) + assert result.exit_code == 0 + assert fake_client.deleted == [] + + def test_yes_skips_prompt(self, fake_client): + result = _invoke(["delete", _IDLE_ID, "--yes"]) + assert result.exit_code == 0 + assert fake_client.deleted == [_IDLE_ID] + + def test_duplicate_ids_deleted_once(self, fake_client): + result = _invoke(["delete", _IDLE_ID, _IDLE_ID, "--yes"]) + assert result.exit_code == 0 + assert fake_client.deleted == [_IDLE_ID] + + def test_deletes_id_returned_by_lookup(self, fake_client): + alias = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + original = fake_client.get_listener + + def _get(listener_id: str) -> dict[str, Any]: + listener = original(listener_id) + listener["id"] = _IDLE_ID + return listener + + fake_client.get_listener = _get + result = _invoke(["delete", alias, _IDLE_ID, "--yes"]) + assert result.exit_code == 0 + assert fake_client.deleted == [_IDLE_ID] + + def test_in_use_check_uses_resolved_id(self, fake_client): + alias = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + original = fake_client.get_listener + + def _get(listener_id: str) -> dict[str, Any]: + listener = original(listener_id) + if listener_id == alias: + listener["id"] = _BUSY_ID + return listener + + fake_client.get_listener = _get + result = _invoke(["delete", alias, "--yes"]) + assert result.exit_code != 0 + assert "live-agent" in result.output + assert _BUSY_ID in result.output + assert fake_client.deleted == [] + + def test_invalid_id_is_a_click_error(self, fake_client): + def _get(listener_id: str) -> dict[str, Any]: + raise ValueError(f"Listener ID must be a UUID, got {listener_id!r}.") + + fake_client.get_listener = _get + result = _invoke(["delete", "../deployments/not-a-uuid", "--yes"]) + assert result.exit_code != 0 + assert "UUID" in result.output + assert "Traceback" not in result.output + assert fake_client.deleted == [] + + def test_recheck_after_confirm_blocks_new_placement(self, fake_client, monkeypatch): + monkeypatch.setattr(listeners, "_is_interactive", lambda: True) + phases: list[str] = [] + + def _list() -> list[dict[str, Any]]: + phases.append("before" if not phases else "after") + if phases == ["before"]: + return [] + return [ + { + "id": "dep-new", + "name": "moved-on", + "source_config": {"listener_id": _IDLE_ID}, + } + ] + + fake_client.list_all_deployments = _list + result = _invoke(["delete", _IDLE_ID], stdin="y\n") + assert result.exit_code != 0 + assert "moved-on" in result.output + assert fake_client.deleted == [] + assert phases == ["before", "after"] + + def test_refuses_listener_with_deployments(self, fake_client): + result = _invoke(["delete", _IDLE_ID, _BUSY_ID, "--yes"]) + assert result.exit_code != 0 + assert "live-agent" in result.output + assert fake_client.deleted == [] + + def test_non_interactive_without_yes_fails(self, fake_client): + result = _invoke(["delete", _IDLE_ID]) + assert result.exit_code != 0 + assert "--yes" in result.output + assert fake_client.deleted == [] + + def test_interactive_decline_aborts(self, fake_client, monkeypatch): + monkeypatch.setattr(listeners, "_is_interactive", lambda: True) + result = _invoke(["delete", _IDLE_ID], stdin="n\n") + assert result.exit_code != 0 + assert fake_client.deleted == [] + + def test_interactive_accept_deletes(self, fake_client, monkeypatch): + monkeypatch.setattr(listeners, "_is_interactive", lambda: True) + result = _invoke(["delete", _IDLE_ID], stdin="y\n") + assert result.exit_code == 0 + assert fake_client.deleted == [_IDLE_ID] diff --git a/packages/python/ess-load-test/Pulumi.example.yaml b/packages/python/ess-load-test/Pulumi.example.yaml new file mode 100644 index 0000000..24b6b37 --- /dev/null +++ b/packages/python/ess-load-test/Pulumi.example.yaml @@ -0,0 +1,11 @@ +# Copy to Pulumi.<stack>.yaml (for example Pulumi.dev.yaml), then replace +# your-aws-profile with the AWS CLI profile for your account (after your AWS SSO login). +config: + aws:profile: your-aws-profile + aws:region: us-east-1 + ess-load-test:environment: dev + ess-load-test:instanceType: t3.large + ess-load-test:masterInstanceType: t3.small + ess-load-test:workerCount: 2 + ess-load-test:useDefaultVpc: true +encryptionsalt: v1:placeholder:placeholder diff --git a/packages/python/ess-load-test/Pulumi.yaml b/packages/python/ess-load-test/Pulumi.yaml new file mode 100644 index 0000000..d934f9d --- /dev/null +++ b/packages/python/ess-load-test/Pulumi.yaml @@ -0,0 +1,7 @@ +name: ess-load-test +runtime: + name: python + options: + toolchain: uv +main: src/ess_load_test/infra +description: Generic EC2 Locust load testing cluster provisioned with Pulumi diff --git a/packages/python/ess-load-test/README.md b/packages/python/ess-load-test/README.md new file mode 100644 index 0000000..6dc4d6d --- /dev/null +++ b/packages/python/ess-load-test/README.md @@ -0,0 +1,45 @@ +# ess-load-test + +Generic EC2 Locust load testing engine. A shell package calls `build_cli()` with an +`AppConfig` and `deploy_cluster()` with its own paths, tags, and Locust scenarios. + +```bash +cd packages/python/ess-load-test +uv run ess-load-test run --help +``` + +## Installation + +From the workspace root: + +```bash +uv sync --all-packages +``` + +## Usage + +```bash +uv run ess-load-test status --stack dev --profile your-aws-profile +uv run ess-load-test run --url https://example.com --stack dev +``` + +## Shell settings + +The standalone CLI ships no organization defaults. A shell sets these on its `AppConfig`: + +| Field | Effect | Default | +|-------|--------|---------| +| `oauth.default_token_url` | OAuth token URL used when `LOAD_TEST_OAUTH_TOKEN_URL` is unset | `""` (the env var is required) | +| `extra_auth_domains` | IdP hosts that browser auth should treat as an SSO login, beyond `ess_browser.AUTH_DOMAINS` | `()` | +| `project_tag` | `Project` tag on every cluster resource; pass it to `deploy_cluster(project_tag=...)` | `component_name` | + +Changing `project_tag` on a live stack retags every resource on the next deploy. + +## Pulumi network placement (shell stack YAML) + +Shell projects pass config under their namespace (e.g. `my-load-test:*`): + +| Config key | Effect | +|------------|--------| +| `useDefaultVpc` | `true` → account default VPC; `false` without overrides → error | +| `vpcId` + `subnetIds` | When **both** set, place EC2 in that VPC/subnet (first subnet used for all instances) | diff --git a/packages/python/ess-load-test/pyproject.toml b/packages/python/ess-load-test/pyproject.toml new file mode 100644 index 0000000..d9bb4c8 --- /dev/null +++ b/packages/python/ess-load-test/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "ess-load-test" +version = "0.1.0" +description = "Generic EC2 Locust load testing engine and CLI" +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "boto3>=1.35.0", + "click>=8.1", + "ess-auth", + "ess-browser", + "locust>=2.32,<3", + "pulumi>=3.0.0", + "pulumi-aws>=6.0.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +dev = ["pytest>=7.0.0"] + +[project.scripts] +ess-load-test = "ess_load_test.__main__:cli_entry" + +[tool.uv.sources] +ess-auth = { workspace = true } +ess-browser = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/ess_load_test"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/packages/python/ess-load-test/src/ess_load_test/__init__.py b/packages/python/ess-load-test/src/ess_load_test/__init__.py new file mode 100644 index 0000000..5174a7e --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/__init__.py @@ -0,0 +1,19 @@ +"""Generic EC2 Locust load testing engine.""" + +from ess_load_test.app_config import ( + AppConfig, + LocustBundle, + LocustSyncFile, + OAuthSettings, +) +from ess_load_test.cli import build_cli +from ess_load_test.infra.stack import deploy_cluster + +__all__ = [ + "AppConfig", + "LocustBundle", + "LocustSyncFile", + "OAuthSettings", + "build_cli", + "deploy_cluster", +] diff --git a/packages/python/ess-load-test/src/ess_load_test/__main__.py b/packages/python/ess-load-test/src/ess_load_test/__main__.py new file mode 100644 index 0000000..15b7d7e --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/__main__.py @@ -0,0 +1,25 @@ +"""CLI entry point for ess-load-test.""" + +import logging +import sys + +import click + +from ess_load_test.app_config import default_app_config +from ess_load_test.cli import build_cli + +main = build_cli(default_app_config()) + + +def cli_entry() -> None: + """Console script entry point.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + try: + main() + except click.ClickException as exc: + click.echo(str(exc), err=True) + sys.exit(1) + + +if __name__ == "__main__": + cli_entry() diff --git a/packages/python/ess-load-test/src/ess_load_test/app_config.py b/packages/python/ess-load-test/src/ess_load_test/app_config.py new file mode 100644 index 0000000..2c12585 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/app_config.py @@ -0,0 +1,105 @@ +"""Application configuration for load-test shells and the generic CLI.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class LocustSyncFile: + """Local file synced to a remote path on EC2 via SSM.""" + + local_path: Path + remote_path: str + + +@dataclass(frozen=True) +class LocustBundle: + """Set of Locust sources synced to cluster instances before a run.""" + + sync_files: tuple[LocustSyncFile, ...] + + +@dataclass(frozen=True) +class OAuthSettings: + """OAuth client-credentials environment variable names and fallbacks. + + ``default_token_url`` is used when no token-URL environment variable is + set. Leave it empty to require one. + """ + + default_token_url: str = "" + client_id_env: str = "LOAD_TEST_OAUTH_CLIENT_ID" + client_secret_env: str = "LOAD_TEST_OAUTH_CLIENT_SECRET" + token_url_env: str = "LOAD_TEST_OAUTH_TOKEN_URL" + client_id_fallback_envs: tuple[str, ...] = () + client_secret_fallback_envs: tuple[str, ...] = () + token_url_fallback_envs: tuple[str, ...] = () + + +@dataclass(frozen=True) +class AppConfig: + """Wiring for a load-test CLI shell or the generic ess-load-test tool.""" + + pulumi_project_dir: Path + locust_install_dir: str + locust_bundle: LocustBundle + valid_scenarios: frozenset[str] + scenario_choices: tuple[str, ...] + default_scenario: str + ui_default_scenario: str + default_profile: str + default_stack: str + default_region: str + oauth: OAuthSettings + browser_profile_dir: Path + component_name: str + pulumi_config_namespace: str | None = None + # IdP hosts beyond ess-browser's public AUTH_DOMAINS. Browser auth + # mistakes an unrecognized login page for the target and never waits. + extra_auth_domains: tuple[str, ...] = () + # The Project tag on every cluster resource; component_name when unset. + # Changing it on a live stack retags every resource on the next deploy. + project_tag: str | None = None + + @property + def config_namespace(self) -> str: + return self.pulumi_config_namespace or self.component_name + + +def default_app_config() -> AppConfig: + """Build generic ess-load-test defaults for the standalone CLI.""" + package_root = Path(__file__).resolve().parents[2] + install_dir = "/opt/ess-load-test" + locust_dir = Path(__file__).resolve().parent / "locust" + return AppConfig( + pulumi_project_dir=package_root, + locust_install_dir=install_dir, + locust_bundle=LocustBundle( + sync_files=( + LocustSyncFile( + local_path=locust_dir / "locustfile.py", + remote_path=f"{install_dir}/locustfile.py", + ), + LocustSyncFile( + local_path=locust_dir / "runtime.py", + remote_path=f"{install_dir}/locust_runtime.py", + ), + LocustSyncFile( + local_path=locust_dir / "scenario_default.py", + remote_path=f"{install_dir}/scenario_default.py", + ), + ), + ), + valid_scenarios=frozenset({"default"}), + scenario_choices=("default",), + default_scenario="default", + ui_default_scenario="default", + default_profile="dev", + default_stack="dev", + default_region="us-east-1", + oauth=OAuthSettings(), + browser_profile_dir=Path.home() / ".ess-load-test" / "browser-profile", + component_name="ess-load-test", + ) diff --git a/packages/python/ess-load-test/src/ess_load_test/auth/__init__.py b/packages/python/ess-load-test/src/ess_load_test/auth/__init__.py new file mode 100644 index 0000000..272178d --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/auth/__init__.py @@ -0,0 +1,25 @@ +"""Authentication helpers for load test targets.""" + +from ess_load_test.auth.browser import ( + BrowserAuthError, + establish_browser_session, + fetch_auth_header_via_browser, +) +from ess_load_test.auth.oauth import ( + OAuthCredentialsError, + fetch_bearer_token, + format_auth_header, + resolve_oauth_credentials, + write_auth_header_env_file, +) + +__all__ = [ + "BrowserAuthError", + "OAuthCredentialsError", + "establish_browser_session", + "fetch_auth_header_via_browser", + "fetch_bearer_token", + "format_auth_header", + "resolve_oauth_credentials", + "write_auth_header_env_file", +] diff --git a/packages/python/ess-load-test/src/ess_load_test/auth/browser.py b/packages/python/ess-load-test/src/ess_load_test/auth/browser.py new file mode 100644 index 0000000..1950afe --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/auth/browser.py @@ -0,0 +1,201 @@ +"""Browser-based SSO auth via system Chrome (ess-browser).""" + +import logging +import time +from pathlib import Path +from urllib.parse import urlparse + +from ess_browser import LOGIN_TIMEOUT_MS, BrowserSession +from playwright.sync_api import Page, Request + +logger = logging.getLogger(__name__) + +_NEXT_AUTH_COOKIE_NAMES = frozenset( + { + "next-auth.session-token", + "__Secure-next-auth.session-token", + "__Host-next-auth.session-token", + } +) + + +class BrowserAuthError(ValueError): + """Raised when browser SSO auth cannot produce a load-test header.""" + + +def _normalize_host(hostname: str) -> str: + return hostname.lower().removeprefix("www.") + + +def _host_matches(request_host: str, target_host: str) -> bool: + normalized_request = _normalize_host(request_host) + normalized_target = _normalize_host(target_host) + return normalized_request == normalized_target or normalized_request.endswith( + f".{normalized_target}" + ) + + +def _authorization_from_request(request: Request, target_host: str) -> str | None: + auth_value = request.headers.get("authorization") + if not auth_value: + return None + request_host = urlparse(request.url).hostname + if not request_host or not _host_matches(request_host, target_host): + return None + if auth_value.lower().startswith("bearer "): + return f"Authorization: {auth_value}" + return f"Authorization: Bearer {auth_value}" + + +def _cookies_to_header(page: Page, target_url: str) -> str | None: + cookies = page.context.cookies([target_url]) + if not cookies: + return None + cookie_pairs = [f"{cookie['name']}={cookie['value']}" for cookie in cookies] + return f"Cookie: {'; '.join(cookie_pairs)}" + + +def _has_auth_session(page: Page, target_url: str) -> bool: + """Return True when NextAuth session cookies are present for the target.""" + cookies = page.context.cookies([target_url]) + return any(cookie["name"] in _NEXT_AUTH_COOKIE_NAMES for cookie in cookies) + + +def _has_any_cookies(page: Page, target_url: str) -> bool: + return bool(page.context.cookies([target_url])) + + +def _wait_for_target_session( + page: Page, + target_url: str, + *, + headed: bool, + captured_headers: list[str], + extra_auth_domains: tuple[str, ...] = (), +) -> None: + """Navigate to the target and wait until SSO produces a usable session.""" + page.goto(target_url, wait_until="load", timeout=LOGIN_TIMEOUT_MS) + deadline = time.time() + LOGIN_TIMEOUT_MS / 1000 + + if headed: + logger.info( + "Complete SSO in the Chrome window. The browser closes automatically " + "once login is detected." + ) + + while time.time() < deadline: + if captured_headers: + return + if BrowserSession.is_auth_redirect( + page.url, + target_url, + extra_auth_domains=extra_auth_domains, + ): + if not headed: + msg = ( + "SSO login is required but the browser is headless. " + "Re-run with --headed to complete login in system Chrome." + ) + raise BrowserAuthError(msg) + remaining_ms = max(1000, int((deadline - time.time()) * 1000)) + BrowserSession.wait_for_login( + page, + target_url, + timeout_ms=remaining_ms, + ) + elif _has_auth_session(page, target_url): + return + elif not headed and _has_any_cookies(page, target_url): + return + time.sleep(0.5) + + msg = ( + "Timed out waiting for browser SSO login. " + "Re-run with --headed and complete SSO in Chrome." + ) + raise BrowserAuthError(msg) + + +def establish_browser_session( + target_url: str, + *, + profile_dir: Path, + headed: bool = True, + extra_auth_domains: tuple[str, ...] = (), +) -> None: + """Open system Chrome and wait until SSO session cookies are saved. + + ``extra_auth_domains`` names IdP hosts beyond ess-browser's public + ``AUTH_DOMAINS``, such as your organization's own login host. + """ + if not target_url.strip(): + msg = "Target URL is required for browser login." + raise BrowserAuthError(msg) + + captured_headers: list[str] = [] + + with BrowserSession(headed=headed, profile_dir=profile_dir) as session: + page = session.new_page() + _wait_for_target_session( + page, + target_url, + headed=headed, + captured_headers=captured_headers, + extra_auth_domains=extra_auth_domains, + ) + + logger.info("Browser session saved in %s", profile_dir) + + +def fetch_auth_header_via_browser( + target_url: str, + *, + profile_dir: Path, + headed: bool = True, + extra_auth_domains: tuple[str, ...] = (), +) -> str: + """Log in via system Chrome and return a Locust auth header value. + + ``extra_auth_domains`` names IdP hosts beyond ess-browser's public + ``AUTH_DOMAINS``, such as your organization's own login host. + """ + if not target_url.strip(): + msg = "Target URL is required for browser auth." + raise BrowserAuthError(msg) + + target_host = urlparse(target_url).hostname + if not target_host: + msg = f"Invalid target URL for browser auth: {target_url!r}" + raise BrowserAuthError(msg) + + captured_headers: list[str] = [] + + def on_request(request: Request) -> None: + header = _authorization_from_request(request, target_host) + if header: + captured_headers.append(header) + + with BrowserSession(headed=headed, profile_dir=profile_dir) as session: + page = session.new_page() + page.on("request", on_request) + _wait_for_target_session( + page, + target_url, + headed=headed, + captured_headers=captured_headers, + extra_auth_domains=extra_auth_domains, + ) + + if captured_headers: + return captured_headers[-1] + + cookie_header = _cookies_to_header(page, target_url) + if cookie_header: + return cookie_header + + msg = ( + "Browser login completed but no Authorization header or session cookies " + f"were found for {target_url}. Try `browser login --headed` first, " + "or set LOAD_TEST_AUTH_HEADER manually." + ) + raise BrowserAuthError(msg) diff --git a/packages/python/ess-load-test/src/ess_load_test/auth/oauth.py b/packages/python/ess-load-test/src/ess_load_test/auth/oauth.py new file mode 100644 index 0000000..fc5b434 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/auth/oauth.py @@ -0,0 +1,74 @@ +"""OAuth2 client-credentials token fetch for load test targets.""" + +import os +import shlex + +from ess_auth import AuthenticationError, TokenHelper + +from ess_load_test.app_config import OAuthSettings + + +class OAuthCredentialsError(ValueError): + """Raised when OAuth client credentials are missing or invalid.""" + + +def _first_env(names: tuple[str, ...]) -> str | None: + for name in names: + value = os.environ.get(name) + if value: + return value + return None + + +def resolve_oauth_credentials(oauth: OAuthSettings) -> tuple[str, str, str]: + """Resolve client ID, secret, and token URL from environment variables.""" + client_id = _first_env((oauth.client_id_env, *oauth.client_id_fallback_envs)) + client_secret = _first_env( + (oauth.client_secret_env, *oauth.client_secret_fallback_envs) + ) + token_url = ( + _first_env((oauth.token_url_env, *oauth.token_url_fallback_envs)) + or oauth.default_token_url + ) + if not client_id or not client_secret: + msg = ( + f"OAuth client credentials are not set. Provide {oauth.client_id_env} " + f"and {oauth.client_secret_env} in the environment." + ) + raise OAuthCredentialsError(msg) + if not token_url: + msg = ( + f"OAuth token URL is not set. Provide {oauth.token_url_env} in the " + "environment, or set OAuthSettings.default_token_url in your shell." + ) + raise OAuthCredentialsError(msg) + return client_id, client_secret, token_url + + +def fetch_bearer_token(oauth: OAuthSettings) -> str: + """Fetch an access token via client credentials.""" + client_id, client_secret, token_url = resolve_oauth_credentials(oauth) + helper = TokenHelper(client_id, client_secret, token_url=token_url) + try: + return helper.fetch_token() + except AuthenticationError as exc: + raise OAuthCredentialsError(str(exc)) from exc + + +def format_auth_header(token: str) -> str: + """Format a token as an Authorization header value for Locust.""" + stripped = token.strip() + if stripped.lower().startswith("bearer "): + return f"Authorization: {stripped}" + if ":" in stripped: + return stripped + return f"Authorization: Bearer {stripped}" + + +def write_auth_header_env_file(path: str, token: str) -> None: + """Write a shell-exportable LOAD_TEST_AUTH_HEADER assignment.""" + header = format_auth_header(token) + content = f"export LOAD_TEST_AUTH_HEADER={shlex.quote(header)}\n" + with open(path, "w", encoding="utf-8") as env_file: + env_file.write(content) + os.chmod(path, 0o600) diff --git a/packages/python/ess-load-test/src/ess_load_test/cli/__init__.py b/packages/python/ess-load-test/src/ess_load_test/cli/__init__.py new file mode 100644 index 0000000..c6a9e83 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI factory for ess-load-test.""" + +from ess_load_test.cli.build_cli import build_cli + +__all__ = ["build_cli"] diff --git a/packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py b/packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py new file mode 100644 index 0000000..f722af4 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py @@ -0,0 +1,261 @@ +"""Shared Click helpers for load-test CLI commands.""" + +# pylint: disable=too-many-arguments,too-many-positional-arguments + +from pathlib import Path + +import click + +from ess_load_test.app_config import AppConfig +from ess_load_test.auth import ( + BrowserAuthError, + OAuthCredentialsError, + establish_browser_session, + fetch_auth_header_via_browser, + fetch_bearer_token, + format_auth_header, +) +from ess_load_test.cli.load_params import LoadTestParams +from ess_load_test.cluster import StackOutputError +from ess_load_test.ui import UiWorkflowConfig, UiWorkflowError, resolve_auth_header + +ENV_TARGET_URL = "LOAD_TEST_TARGET_URL" +ENV_AWS_PROFILE = "LOAD_TEST_AWS_PROFILE" +ENV_STACK = "LOAD_TEST_STACK" +ENV_AWS_REGION = "LOAD_TEST_AWS_REGION" +ENV_SCENARIO = "LOAD_TEST_SCENARIO" + + +def _browser_profile_dir(app_config: AppConfig, override: str | None) -> Path: + if override: + return Path(override) + return app_config.browser_profile_dir + + +def resolve_cli_auth_header( # noqa: PLR0913 + app_config: AppConfig, + explicit_header: str | None, + *, + fetch_token: bool, + browser_auth: bool = False, + target_url: str = "", + headed: bool = False, + browser_profile_dir: str | None = None, +) -> str | None: + profile_dir = _browser_profile_dir(app_config, browser_profile_dir) + if browser_auth: + + def resolve_browser() -> str: + return fetch_auth_header_via_browser( + target_url, + headed=headed, + profile_dir=profile_dir, + extra_auth_domains=app_config.extra_auth_domains, + ) + + return resolve_auth_header( + explicit_header, + fetch_token=fetch_token, + resolve_token=resolve_browser, + ) + + return resolve_auth_header( + explicit_header, + fetch_token=fetch_token, + resolve_token=lambda: format_auth_header(fetch_bearer_token(app_config.oauth)), + ) + + +def make_resolve_token(config: UiWorkflowConfig): + profile_dir = _browser_profile_dir( + config.app_config, + config.browser_profile_dir, + ) + if config.browser_auth: + return lambda: fetch_auth_header_via_browser( + config.target_url, + headed=config.headed, + profile_dir=profile_dir, + extra_auth_domains=config.app_config.extra_auth_domains, + ) + return lambda: format_auth_header(fetch_bearer_token(config.app_config.oauth)) + + +def browser_auth_options(app_config: AppConfig): + """Decorator factory for system-Chrome SSO auth options.""" + + def decorator(func): + func = click.option( + "--browser-auth", + is_flag=True, + help="Authenticate via system Chrome SSO instead of OAuth client credentials.", + )(func) + func = click.option( + "--headed", + is_flag=True, + default=False, + help="Show system Chrome (required on first browser SSO login).", + )(func) + func = click.option( + "--browser-profile-dir", + default=None, + help=( + f"Chrome profile directory [default: {app_config.browser_profile_dir}]" + ), + )(func) + return func + + return decorator + + +def scenario_option(app_config: AppConfig, *, default: str, show_default: bool = True): + """Decorator factory for Locust scenario selection.""" + + def decorator(func): + return click.option( + "--scenario", + default=default, + envvar=ENV_SCENARIO, + show_default=show_default, + type=click.Choice(app_config.scenario_choices), + help="Locust user scenario.", + )(func) + + return decorator + + +def workflow_options(app_config: AppConfig): + """Decorator factory for shared UI workflow options.""" + + def decorator(func): + func = click.option( + "--path", + "paths", + default="/", + show_default=True, + help="Comma-separated HTTP paths to request.", + )(func) + func = click.option( + "--auth-header", + default=None, + envvar="LOAD_TEST_AUTH_HEADER", + help="Optional auth header value or 'Header-Name: value'.", + )(func) + func = click.option( + "--fetch-token/--no-fetch-token", + default=True, + show_default=True, + help="Fetch OAuth token when credentials are set and --auth-header is omitted.", + )(func) + func = click.option( + "--stack", + default=app_config.default_stack, + envvar=ENV_STACK, + show_default=True, + help="Pulumi stack name.", + )(func) + func = click.option( + "--profile", + default=app_config.default_profile, + envvar=ENV_AWS_PROFILE, + show_default=True, + help="AWS profile name.", + )(func) + func = click.option( + "--region", + default=None, + envvar=ENV_AWS_REGION, + help="AWS region (defaults to stack output).", + )(func) + func = click.option( + "--local-port", + default=8089, + show_default=True, + help="Local port for Locust UI port-forward.", + )(func) + return func + + return decorator + + +def workflow_config( # noqa: PLR0913 + app_config: AppConfig, + *, + url: str | None, + paths: str, + scenario: str | None = None, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str, + region: str | None, + local_port: int, + browser_auth: bool = False, + headed: bool = False, + browser_profile_dir: str | None = None, + load_params: LoadTestParams | None = None, +) -> UiWorkflowConfig: + return UiWorkflowConfig( + app_config=app_config, + target_url=url or "", + paths=paths, + scenario=scenario, + stack=stack, + profile=profile, + region=region, + local_port=local_port, + fetch_token=fetch_token, + auth_header=auth_header, + browser_auth=browser_auth, + headed=headed, + browser_profile_dir=browser_profile_dir, + load_params=load_params, + ) + + +def handle_workflow_error(exc: Exception) -> None: + if isinstance( + exc, + (StackOutputError, UiWorkflowError, OAuthCredentialsError, BrowserAuthError), + ): + raise click.ClickException(str(exc)) from exc + raise exc + + +def browser_login_command_factory(app_config: AppConfig): + @click.command("login") + @click.option( + "--url", + required=True, + envvar=ENV_TARGET_URL, + help="Target base URL (scheme + host).", + ) + @click.option( + "--headed/--headless", + default=True, + show_default=True, + help="Show system Chrome (use --headless only with a saved profile).", + ) + @click.option( + "--browser-profile-dir", + default=None, + help=(f"Chrome profile directory [default: {app_config.browser_profile_dir}]"), + ) + def browser_login_command( + url: str, + headed: bool, + browser_profile_dir: str | None, + ) -> None: + """Open system Chrome, complete SSO, and save the session profile.""" + try: + establish_browser_session( + url, + headed=headed, + profile_dir=_browser_profile_dir(app_config, browser_profile_dir), + extra_auth_domains=app_config.extra_auth_domains, + ) + except BrowserAuthError as exc: + raise click.ClickException(str(exc)) from exc + click.echo("OK: browser session established") + + return browser_login_command diff --git a/packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py b/packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py new file mode 100644 index 0000000..cc04dac --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py @@ -0,0 +1,632 @@ +"""Click CLI factory for load-test tools.""" + +# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals + +import click +from botocore.exceptions import NoCredentialsError, NoRegionError, ProfileNotFound + +from ess_load_test.app_config import AppConfig +from ess_load_test.auth import ( + BrowserAuthError, + OAuthCredentialsError, + fetch_auth_header_via_browser, + fetch_bearer_token, + format_auth_header, + write_auth_header_env_file, +) +from ess_load_test.cli import _helpers as helpers +from ess_load_test.cli.load_params import ( + load_test_params_from_click, + load_test_params_options, +) +from ess_load_test.cluster import ( + LoadTestRunRequest, + StackOutputError, + load_stack_outputs, + reset_cluster_locust, + run_load_test, +) +from ess_load_test.ui import ( + LocalPortExpectation, + UiWorkflowError, + check_local_port, + cleanup_stale_port_forward, + open_locust_ui, + print_cluster_status, + run_port_forward, + run_preflight, +) + + +def build_cli(app_config: AppConfig) -> click.Command: + """Build a Click command group for load-test operations.""" + + @click.group() + def cli() -> None: + """EC2 Locust load testing for HTTP targets.""" + + @click.group() + def browser() -> None: + """System Chrome SSO login for load-test targets.""" + + browser.add_command(helpers.browser_login_command_factory(app_config)) + + @click.group() + def ui() -> None: + """Locust web UI: token, cluster start, and SSM port-forward.""" + + @ui.command("open") + @helpers.workflow_options(app_config) + @helpers.browser_auth_options(app_config) + @helpers.scenario_option(app_config, default=app_config.ui_default_scenario) + @load_test_params_options(duration_default="10m", headless_exports=False) + @click.option( + "--url", + required=True, + envvar=helpers.ENV_TARGET_URL, + help="Target base URL (scheme + host).", + ) + def ui_open_command( # noqa: PLR0913 + url: str, + paths: str, + scenario: str, + users: int, + spawn_rate: float, + duration: str | None, + user_class: tuple[str, ...], + autostart: bool, + autoquit: int | None, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str, + region: str | None, + local_port: int, + browser_auth: bool, + headed: bool, + browser_profile_dir: str | None, + ) -> None: + """Preflight, fetch auth, start Locust UI, and print port-forward.""" + load_params = load_test_params_from_click( + users=users, + spawn_rate=spawn_rate, + duration=duration, + user_class=user_class, + autostart=autostart, + autoquit=autoquit, + ) + config = helpers.workflow_config( + app_config, + url=url, + paths=paths, + scenario=scenario, + auth_header=auth_header, + fetch_token=fetch_token, + stack=stack, + profile=profile, + region=region, + local_port=local_port, + browser_auth=browser_auth, + headed=headed, + browser_profile_dir=browser_profile_dir, + load_params=load_params, + ) + try: + _, forward_command = open_locust_ui( + config, + resolve_token=helpers.make_resolve_token(config), + ) + print_cluster_status(config) + except ( + RuntimeError, + TimeoutError, + OAuthCredentialsError, + BrowserAuthError, + UiWorkflowError, + ) as exc: + helpers.handle_workflow_error(exc) + + click.echo("") + click.echo("Locust web UI started on the master (port 8089).") + click.echo(f"Open http://localhost:{local_port} after port-forward.") + click.echo("Port-forward command:") + click.echo(forward_command) + + @ui.command("forward") + @helpers.workflow_options(app_config) + @click.option( + "--exec", + "exec_forward", + is_flag=True, + help="Run port-forward (blocking).", + ) + def ui_forward_command( # noqa: PLR0913 + paths: str, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str, + region: str | None, + local_port: int, + exec_forward: bool, + ) -> None: + """Print or run the SSM port-forward command for the Locust web UI.""" + config = helpers.workflow_config( + app_config, + url="", + paths=paths, + auth_header=auth_header, + fetch_token=fetch_token, + stack=stack, + profile=profile, + region=region, + local_port=local_port, + ) + try: + forward_command = run_port_forward(config, exec_forward=exec_forward) + except StackOutputError as exc: + helpers.handle_workflow_error(exc) + + if not exec_forward: + click.echo("Start the Locust master UI first (ui open), then:") + click.echo(forward_command) + + @ui.command("cleanup-forward") + @click.option( + "--local-port", + default=8089, + show_default=True, + help="Local Locust UI port to clear.", + ) + def ui_cleanup_forward_command(local_port: int) -> None: + """Kill stale local SSM port-forward processes and verify the port is free.""" + try: + killed = cleanup_stale_port_forward(local_port) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from exc + + if killed: + click.echo( + f"Stopped stale port-forward process(es) ({killed} pkill match(es))." + ) + else: + click.echo("No stale port-forward processes found.") + click.echo(f"OK: local port {local_port} is free") + + @ui.command("check-port") + @click.option( + "--local-port", + default=8089, + show_default=True, + help="Local Locust UI port to inspect.", + ) + @click.option( + "--expect", + type=click.Choice(["free", "listening"], case_sensitive=False), + default="free", + show_default=True, + help="Exit 0 when the port matches this state (lsof exit 1 => free).", + ) + def ui_check_port_command(local_port: int, expect: str) -> None: + """Check local port state; exit 0 when --expect is met.""" + expectation = LocalPortExpectation(expect.lower()) + try: + check_local_port(local_port, expect=expectation) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from exc + + if expectation == LocalPortExpectation.FREE: + click.echo(f"OK: port {local_port} is free") + else: + click.echo(f"OK: port {local_port} is listening") + + @ui.command("reset-cluster") + @click.option( + "--stack", + default=app_config.default_stack, + envvar=helpers.ENV_STACK, + show_default=True, + help="Pulumi stack name.", + ) + @click.option( + "--profile", + default=app_config.default_profile, + envvar=helpers.ENV_AWS_PROFILE, + show_default=True, + help="AWS profile name.", + ) + @click.option( + "--region", + default=app_config.default_region, + envvar=helpers.ENV_AWS_REGION, + show_default=True, + help="AWS region.", + ) + def ui_reset_cluster_command(stack: str, profile: str, region: str) -> None: + """Stop stale Locust processes on all EC2 instances (ports 5557–5558 and 8089).""" + try: + reset_cluster_locust( + stack=stack, + profile=profile, + region=region, + app_config=app_config, + ) + except (StackOutputError, RuntimeError, TimeoutError) as exc: + raise click.ClickException(str(exc)) from exc + click.echo("OK: Locust processes stopped on all cluster instances") + + @ui.command("preflight") + @helpers.workflow_options(app_config) + @click.option( + "--url", + required=True, + envvar=helpers.ENV_TARGET_URL, + help="Target base URL (scheme + host).", + ) + def ui_preflight_command( # noqa: PLR0913 + url: str, + paths: str, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str, + region: str | None, + local_port: int, + ) -> None: + """Check AWS credentials and target URL before starting Locust.""" + config = helpers.workflow_config( + app_config, + url=url, + paths=paths, + auth_header=auth_header, + fetch_token=fetch_token, + stack=stack, + profile=profile, + region=region, + local_port=local_port, + ) + try: + run_preflight(config) + except UiWorkflowError as exc: + helpers.handle_workflow_error(exc) + click.echo("OK: preflight passed") + + cli.add_command(browser) + cli.add_command(ui) + + @click.command("fetch-token") + @click.option( + "--url", + default=None, + envvar=helpers.ENV_TARGET_URL, + help="Target base URL (required for --browser-auth).", + ) + @click.option( + "--browser-auth", + is_flag=True, + help="Log in via system Chrome SSO instead of OAuth client credentials.", + ) + @click.option( + "--headed", + is_flag=True, + default=False, + help="Show system Chrome (use on first browser SSO login).", + ) + @click.option( + "--browser-profile-dir", + default=None, + help=(f"Chrome profile directory [default: {app_config.browser_profile_dir}]"), + ) + @click.option( + "--write-env-file", + type=click.Path(dir_okay=False), + default=None, + help="Write export LOAD_TEST_AUTH_HEADER=... to this file (mode 600).", + ) + @click.option( + "--print-header/--no-print-header", + default=False, + help="Print the auth header to stdout (avoid in shared logs).", + ) + def fetch_token_command( # noqa: PLR0913 + url: str | None, + browser_auth: bool, + headed: bool, + browser_profile_dir: str | None, + write_env_file: str | None, + print_header: bool, + ) -> None: + """Fetch auth for load tests (client credentials or system Chrome SSO).""" + try: + if browser_auth: + if not url: + raise click.ClickException( + "--url or LOAD_TEST_TARGET_URL is required for --browser-auth" + ) + header = fetch_auth_header_via_browser( + url, + headed=headed, + profile_dir=helpers._browser_profile_dir( # noqa: SLF001 + app_config, + browser_profile_dir, + ), + extra_auth_domains=app_config.extra_auth_domains, + ) + else: + token = fetch_bearer_token(app_config.oauth) + header = format_auth_header(token) + except (OAuthCredentialsError, BrowserAuthError) as exc: + raise click.ClickException(str(exc)) from exc + + if write_env_file: + write_auth_header_env_file(write_env_file, header) + click.echo(f"Wrote auth header env file: {write_env_file}") + if print_header: + click.echo(header) + if not write_env_file and not print_header: + click.echo( + "Auth header fetched. Use --write-env-file or --print-header to export it." + ) + + @click.command("run") + @helpers.browser_auth_options(app_config) + @click.option( + "--url", + required=True, + envvar=helpers.ENV_TARGET_URL, + help="Target base URL (scheme + host).", + ) + @click.option( + "--path", + "paths", + default="/", + show_default=True, + help="Comma-separated HTTP paths to request.", + ) + @load_test_params_options(duration_default="5m", headless_exports=True) + @click.option( + "--auth-header", + default=None, + envvar="LOAD_TEST_AUTH_HEADER", + help="Optional auth header value or 'Header-Name: value'.", + ) + @click.option( + "--fetch-token/--no-fetch-token", + default=True, + show_default=True, + help="Fetch OAuth token when credentials are set and --auth-header is omitted.", + ) + @click.option( + "--stack", + default=app_config.default_stack, + envvar=helpers.ENV_STACK, + show_default=True, + help="Pulumi stack name.", + ) + @click.option( + "--profile", + default=None, + envvar=helpers.ENV_AWS_PROFILE, + help="AWS profile name.", + ) + @click.option( + "--region", + default=None, + envvar=helpers.ENV_AWS_REGION, + help="AWS region (defaults to stack output).", + ) + @helpers.scenario_option(app_config, default=app_config.default_scenario) + def run_command( # noqa: PLR0913 + scenario: str, + browser_auth: bool, + headed: bool, + browser_profile_dir: str | None, + url: str, + paths: str, + users: int, + spawn_rate: float, + duration: str | None, + user_class: tuple[str, ...], + autostart: bool, + autoquit: int | None, + csv_prefix: str | None, + html_path: str | None, + reset_stats: bool, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str | None, + region: str | None, + ) -> None: + """Run a headless Locust load test against a URL.""" + load_params = load_test_params_from_click( + users=users, + spawn_rate=spawn_rate, + duration=duration, + user_class=user_class, + autostart=autostart, + autoquit=autoquit, + csv_prefix=csv_prefix, + html_path=html_path, + reset_stats=reset_stats, + ) + try: + outputs = load_stack_outputs(stack, app_config.pulumi_project_dir) + resolved_region = region or str(outputs.get("region", "")) + resolved_auth = auth_header + if fetch_token and not resolved_auth: + resolved_auth = helpers.resolve_cli_auth_header( + app_config, + None, + fetch_token=True, + browser_auth=browser_auth, + target_url=url, + headed=headed, + browser_profile_dir=browser_profile_dir, + ) + request = LoadTestRunRequest( + stack=stack, + target_url=url, + paths=paths, + scenario=scenario, + load_params=load_params, + auth_header=resolved_auth, + profile=profile, + region=resolved_region or None, + ) + stdout = run_load_test(request, app_config) + except ( + StackOutputError, + RuntimeError, + TimeoutError, + OAuthCredentialsError, + BrowserAuthError, + NoCredentialsError, + NoRegionError, + ProfileNotFound, + ) as exc: + raise click.ClickException(str(exc)) from exc + + click.echo(stdout) + + @click.command("status") + @click.option( + "--stack", + default=app_config.default_stack, + envvar=helpers.ENV_STACK, + show_default=True, + help="Pulumi stack name.", + ) + @click.option( + "--profile", + default=app_config.default_profile, + envvar=helpers.ENV_AWS_PROFILE, + show_default=True, + help="AWS profile name.", + ) + @click.option( + "--region", + default=None, + envvar=helpers.ENV_AWS_REGION, + help="AWS region (defaults to stack output).", + ) + def status_command(stack: str, profile: str, region: str | None) -> None: + """Show Pulumi outputs and SSM reachability for the load test cluster.""" + config = helpers.workflow_config( + app_config, + url="https://app.example.com", + paths="/", + auth_header=None, + fetch_token=False, + stack=stack, + profile=profile, + region=region, + local_port=8089, + ) + try: + print_cluster_status(config) + except (StackOutputError, UiWorkflowError) as exc: + raise click.ClickException(str(exc)) from exc + + @click.command("ui-start", hidden=True) + @click.option( + "--url", + required=True, + envvar=helpers.ENV_TARGET_URL, + help="Target base URL (scheme + host).", + ) + @click.option("--path", "paths", default="/", show_default=True) + @click.option("--auth-header", default=None, envvar="LOAD_TEST_AUTH_HEADER") + @click.option("--fetch-token/--no-fetch-token", default=True, show_default=True) + @click.option( + "--stack", + default=app_config.default_stack, + envvar=helpers.ENV_STACK, + show_default=True, + ) + @click.option( + "--profile", + default=app_config.default_profile, + envvar=helpers.ENV_AWS_PROFILE, + show_default=True, + ) + @click.option( + "--region", + default=app_config.default_region, + envvar=helpers.ENV_AWS_REGION, + show_default=True, + ) + def ui_start_legacy_command( # noqa: PLR0913 + url: str, + paths: str, + auth_header: str | None, + fetch_token: bool, + stack: str, + profile: str, + region: str, + ) -> None: + """Deprecated: use `ui open`.""" + ctx = click.get_current_context() + ctx.invoke( + ui_open_command, + url=url, + paths=paths, + scenario=app_config.ui_default_scenario, + auth_header=auth_header, + fetch_token=fetch_token, + stack=stack, + profile=profile, + region=region, + local_port=8089, + browser_auth=False, + headed=False, + browser_profile_dir=None, + ) + + @click.command("ui-forward", hidden=True) + @click.option( + "--stack", + default=app_config.default_stack, + envvar=helpers.ENV_STACK, + show_default=True, + ) + @click.option( + "--profile", + default=app_config.default_profile, + envvar=helpers.ENV_AWS_PROFILE, + show_default=True, + ) + @click.option( + "--region", + default=app_config.default_region, + envvar=helpers.ENV_AWS_REGION, + show_default=True, + ) + @click.option("--local-port", default=8089, show_default=True) + def ui_forward_legacy_command( + stack: str, + profile: str, + region: str, + local_port: int, + ) -> None: + """Deprecated: use `ui forward`.""" + ctx = click.get_current_context() + ctx.invoke( + ui_forward_command, + paths="/", + auth_header=None, + fetch_token=True, + stack=stack, + profile=profile, + region=region, + local_port=local_port, + exec_forward=False, + ) + + cli.add_command(fetch_token_command) + cli.add_command(run_command) + cli.add_command(status_command) + cli.add_command(ui_start_legacy_command) + cli.add_command(ui_forward_legacy_command) + + return cli diff --git a/packages/python/ess-load-test/src/ess_load_test/cli/load_params.py b/packages/python/ess-load-test/src/ess_load_test/cli/load_params.py new file mode 100644 index 0000000..0d33be6 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cli/load_params.py @@ -0,0 +1,180 @@ +"""Shared Locust load-test parameters for CLI and SSM runner.""" + +# pylint: disable=too-many-arguments,too-many-positional-arguments + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import click + +ENV_USERS = "LOAD_TEST_USERS" +ENV_SPAWN_RATE = "LOAD_TEST_SPAWN_RATE" +ENV_DURATION = "LOAD_TEST_DURATION" + + +@dataclass(frozen=True) +class LoadTestParams: + """Locust user count, spawn rate, and optional run controls.""" + + users: int = 50 + spawn_rate: float = 5.0 + duration: str | None = None + user_classes: tuple[str, ...] = () + autostart: bool = False + autoquit: int | None = None + csv_prefix: str | None = None + html_path: str | None = None + reset_stats: bool = False + + +def config_users_json(user_classes: tuple[str, ...]) -> str | None: + """Build Locust --config-users JSON from user class names.""" + if not user_classes: + return None + configs = [{"user_class_name": name} for name in user_classes] + return json.dumps(configs) + + +def build_locust_load_flag_lines( + params: LoadTestParams, + *, + headless: bool, + shell_quote: Callable[[str], str], +) -> list[str]: + """Return Locust CLI flag lines for master command assembly.""" + lines = [ + f"-u {params.users}", + f"-r {params.spawn_rate}", + ] + if params.duration is not None: + lines.append(f"-t {shell_quote(params.duration)}") + config_users = config_users_json(params.user_classes) + if config_users is not None: + lines.append(f"--config-users {shell_quote(config_users)}") + if headless: + lines.append("--headless") + elif params.autostart: + lines.append("--autostart") + if params.autoquit is not None: + lines.append(f"--autoquit {params.autoquit}") + if headless: + if params.csv_prefix is not None: + lines.append(f"--csv {shell_quote(params.csv_prefix)}") + if params.html_path is not None: + lines.append(f"--html {shell_quote(params.html_path)}") + if params.reset_stats: + lines.append("--reset-stats") + return lines + + +def load_test_params_from_click( # noqa: PLR0913 + *, + users: int, + spawn_rate: float, + duration: str | None, + user_class: tuple[str, ...], + autostart: bool, + autoquit: int | None, + csv_prefix: str | None = None, + html_path: str | None = None, + reset_stats: bool = False, +) -> LoadTestParams: + """Build LoadTestParams from Click option values.""" + return LoadTestParams( + users=users, + spawn_rate=spawn_rate, + duration=duration, + user_classes=user_class, + autostart=autostart, + autoquit=autoquit, + csv_prefix=csv_prefix, + html_path=html_path, + reset_stats=reset_stats, + ) + + +def load_test_params_options(*, duration_default: str | None, headless_exports: bool): + """Decorator factory for shared Locust load-test CLI options.""" + + def decorator(func: Any) -> Any: + func = click.option( + "--users", + "-u", + default=50, + show_default=True, + envvar=ENV_USERS, + help="Peak number of concurrent Locust users.", + )(func) + func = click.option( + "--spawn-rate", + "-r", + default=5.0, + show_default=True, + envvar=ENV_SPAWN_RATE, + help="Users spawned per second.", + )(func) + duration_help = ( + "Test duration (Locust time format, e.g. 5m, 30s). " + "Headless runs require a duration; UI runs pre-fill when set." + ) + if duration_default is None: + func = click.option( + "--duration", + "-t", + default=None, + envvar=ENV_DURATION, + help=duration_help, + )(func) + else: + func = click.option( + "--duration", + "-t", + default=duration_default, + show_default=True, + envvar=ENV_DURATION, + help=duration_help, + )(func) + func = click.option( + "--user-class", + "user_class", + multiple=True, + help="Locust user class to include (repeatable). Default: all.", + )(func) + func = click.option( + "--autostart", + is_flag=True, + default=False, + help="Start swarming immediately when workers connect (UI mode only).", + )(func) + func = click.option( + "--autoquit", + type=int, + default=None, + help="Quit Locust N seconds after the run finishes (requires --autostart).", + )(func) + if headless_exports: + func = click.option( + "--csv", + "csv_prefix", + default=None, + help="Write CSV stats to this path prefix (headless run only).", + )(func) + func = click.option( + "--html", + "html_path", + default=None, + help="Write HTML report to this path (headless run only).", + )(func) + func = click.option( + "--reset-stats", + is_flag=True, + default=False, + help="Reset statistics after spawn completes (headless run only).", + )(func) + return func + + return decorator diff --git a/packages/python/ess-load-test/src/ess_load_test/cluster/__init__.py b/packages/python/ess-load-test/src/ess_load_test/cluster/__init__.py new file mode 100644 index 0000000..4d123be --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cluster/__init__.py @@ -0,0 +1,26 @@ +"""Remote EC2 cluster operations via SSM.""" + +from ess_load_test.cli.load_params import LoadTestParams +from ess_load_test.cluster.outputs import StackOutputError, load_stack_outputs +from ess_load_test.cluster.runner import ( + LoadTestRunRequest, + LoadTestUiRequest, + build_ui_port_forward_command, + get_cluster_status, + reset_cluster_locust, + run_load_test, + start_locust_ui, +) + +__all__ = [ + "LoadTestParams", + "LoadTestRunRequest", + "LoadTestUiRequest", + "StackOutputError", + "build_ui_port_forward_command", + "get_cluster_status", + "load_stack_outputs", + "reset_cluster_locust", + "run_load_test", + "start_locust_ui", +] diff --git a/packages/python/ess-load-test/src/ess_load_test/cluster/outputs.py b/packages/python/ess-load-test/src/ess_load_test/cluster/outputs.py new file mode 100644 index 0000000..22e4b24 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cluster/outputs.py @@ -0,0 +1,29 @@ +"""Read Pulumi stack outputs for the load test cluster.""" + +import json +import subprocess +from pathlib import Path + + +class StackOutputError(RuntimeError): + """Raised when Pulumi stack outputs cannot be loaded.""" + + +def load_stack_outputs(stack: str, project_dir: Path) -> dict[str, object]: + """Load JSON stack outputs via the Pulumi CLI.""" + try: + result = subprocess.run( + ["uv", "run", "pulumi", "stack", "output", "--json", "--stack", stack], + cwd=project_dir, + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as exc: + msg = ( + "Failed to read Pulumi stack outputs. " + f"Run `pulumi up` in {project_dir} first.\n{exc.stderr}" + ) + raise StackOutputError(msg) from exc + + return json.loads(result.stdout) diff --git a/packages/python/ess-load-test/src/ess_load_test/cluster/runner.py b/packages/python/ess-load-test/src/ess_load_test/cluster/runner.py new file mode 100644 index 0000000..6e68ba8 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/cluster/runner.py @@ -0,0 +1,678 @@ +"""SSM orchestration for distributed Locust test runs.""" + +import base64 +import logging +import os +import re +import time +from collections.abc import Sequence +from dataclasses import dataclass + +import boto3 +from botocore.exceptions import ClientError + +from ess_load_test.app_config import AppConfig +from ess_load_test.cli.load_params import LoadTestParams, build_locust_load_flag_lines +from ess_load_test.cluster.outputs import load_stack_outputs +from ess_load_test.infra.constants import locust_bin, locust_venv_dir +from ess_load_test.locust.runtime import normalize_locust_host + +logger = logging.getLogger(__name__) + +_SSM_DOCUMENT = "AWS-RunShellScript" +_POLL_INTERVAL_SECONDS = 5 +_COMMAND_TIMEOUT_SECONDS = 3600 +_WEB_UI_PORT = 8089 +_LOCUST_MASTER_PORT = 5557 +_LOCUST_STATS_PORT = 5558 +_OPERATOR_SCENARIO_ENV_KEYS = frozenset( + {"LOAD_TEST_PRODUCT_SLUG", "LOAD_TEST_THINK_TIME", "LOAD_TEST_CHAT_PROMPTS"} +) + + +def _operator_scenario_env_exports() -> str: + """Export scenario tuning vars from the operator shell onto cluster workers.""" + lines: list[str] = [] + for key, value in sorted(os.environ.items()): + if not value: + continue + if key in _OPERATOR_SCENARIO_ENV_KEYS: + lines.append(f"export {key}={_shell_quote(value)}") + if not lines: + return "" + return "\n".join(lines) + "\n" + + +_VALID_ENV_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _validate_extra_env_key(key: str) -> None: + if not _VALID_ENV_KEY.match(key): + msg = f"Invalid extra_env key {key!r}: must be an ASCII shell identifier" + raise ValueError(msg) + + +def _reset_locust_cluster_script() -> str: + return f""" +set -euo pipefail +pkill -f '[/]locust' 2>/dev/null || true +fuser -k {_LOCUST_MASTER_PORT}/tcp 2>/dev/null || true +fuser -k {_LOCUST_STATS_PORT}/tcp 2>/dev/null || true +fuser -k {_WEB_UI_PORT}/tcp 2>/dev/null || true +if pgrep -f '[/]locust' >/dev/null 2>&1; then + echo 'Locust processes still running after reset.' >&2 + pgrep -af '[/]locust' >&2 || true + exit 1 +fi +echo 'OK: Locust processes stopped and coordination ports are free' +""" + + +def _shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _base64_write_file_command(remote_path: str, payload_b64: str) -> str: + quoted_path = _shell_quote(remote_path) + quoted_payload = _shell_quote(payload_b64) + return f"printf '%s' {quoted_payload} | base64 -d > {quoted_path}" + + +def _auth_file_bootstrap_lines(auth_header: str | None) -> str: + """Write the auth header to a file so workers avoid shell-export mangling.""" + if not auth_header: + return "" + payload_b64 = base64.b64encode(auth_header.encode()).decode("ascii") + quoted_payload = _shell_quote(payload_b64) + return ( + "_auth_file=$(mktemp)\n" + f"printf '%s' {quoted_payload} | base64 -d > \"$_auth_file\"\n" + 'chmod 600 "$_auth_file"\n' + 'export LOAD_TEST_AUTH_HEADER_FILE="$_auth_file"\n' + ) + + +def _build_sync_locust_sources_script(app_config: AppConfig) -> str: + install_dir = app_config.locust_install_dir + lines = ["set -euo pipefail", f"mkdir -p {install_dir!r}"] + for sync_file in app_config.locust_bundle.sync_files: + payload_b64 = base64.b64encode(sync_file.local_path.read_bytes()).decode( + "ascii" + ) + lines.append(_base64_write_file_command(sync_file.remote_path, payload_b64)) + return "\n".join(lines) + + +def _bootstrap_locust_script(install_dir: str) -> str: + venv_dir = locust_venv_dir(install_dir) + pip_bin = f"{venv_dir}/bin/pip" + bin_path = locust_bin(install_dir) + return f""" +set -euo pipefail +if [[ -x {bin_path!r} ]]; then + exit 0 +fi +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq python3-pip python3-venv +python3 -m venv {venv_dir!r} +{pip_bin!r} install -q 'locust>=2.32,<3' +""" + + +def _verify_master_web_ui_script() -> str: + return f""" +set -euo pipefail +for attempt in $(seq 1 30); do + if ss -tln 2>/dev/null | grep -q ':{_WEB_UI_PORT} ' || \\ + netstat -tln 2>/dev/null | grep -q ':{_WEB_UI_PORT} '; then + exit 0 + fi + sleep 2 +done +echo 'Locust web UI is not listening on port {_WEB_UI_PORT}.' >&2 +tail -30 /var/log/locust-master.log 2>/dev/null || true +exit 1 +""" + + +@dataclass(frozen=True) +class LoadTestRunRequest: + """Parameters for a distributed Locust run.""" + + stack: str + target_url: str + paths: str + scenario: str + load_params: LoadTestParams + auth_header: str | None + profile: str | None + region: str | None + extra_env: tuple[tuple[str, str], ...] = () + + +@dataclass(frozen=True) +class LoadTestUiRequest: + """Parameters for starting the Locust web UI on the cluster.""" + + stack: str + target_url: str + paths: str + scenario: str + load_params: LoadTestParams + auth_header: str | None + profile: str | None + region: str | None + extra_env: tuple[tuple[str, str], ...] = () + + +@dataclass(frozen=True) +class WorkerRuntimeEnv: + """Environment variables workers need to match the master's locustfile.""" + + scenario: str + paths: str + auth_header: str | None + extra_env: tuple[tuple[str, str], ...] = () + + +def _extra_env_exports(extra_env: tuple[tuple[str, str], ...]) -> str: + if not extra_env: + return "" + lines: list[str] = [] + for key, value in extra_env: + if not key: + continue + _validate_extra_env_key(key) + lines.append(f"export {key}={_shell_quote(value)}") + return "\n".join(lines) + "\n" + + +def _worker_runtime_env( + *, + scenario: str, + paths: str, + auth_header: str | None, + extra_env: tuple[tuple[str, str], ...] = (), +) -> WorkerRuntimeEnv: + return WorkerRuntimeEnv( + scenario=scenario, + paths=paths, + auth_header=auth_header, + extra_env=extra_env, + ) + + +def _wrap_bash_script(script: str) -> str: + """Wrap a script for SSM RunShellScript.""" + return f"bash -c {_shell_quote(script.strip())}" + + +def _create_ssm_client(profile: str | None, region: str | None): + session_kwargs: dict[str, str] = {} + if profile: + session_kwargs["profile_name"] = profile + if region: + session_kwargs["region_name"] = region + session = boto3.Session(**session_kwargs) + return session.client("ssm") + + +def _locustfile_remote_path(app_config: AppConfig) -> str: + for sync_file in app_config.locust_bundle.sync_files: + if sync_file.local_path.name == "locustfile.py": + return sync_file.remote_path + return f"{app_config.locust_install_dir}/locustfile.py" + + +def _build_worker_command( + app_config: AppConfig, + master_private_ip: str, + runtime: WorkerRuntimeEnv, +) -> str: + install_dir = app_config.locust_install_dir + locustfile_path = _locustfile_remote_path(app_config) + master_host = _shell_quote(master_private_ip) + bin_path = locust_bin(install_dir) + auth_export = "" + auth_file_bootstrap = "" + if runtime.auth_header: + auth_export = ( + f"export LOAD_TEST_AUTH_HEADER={_shell_quote(runtime.auth_header)}\n" + ) + auth_file_bootstrap = _auth_file_bootstrap_lines(runtime.auth_header) + extra_exports = _extra_env_exports(runtime.extra_env) + return f""" +set -euo pipefail +export PYTHONPATH={install_dir!r} +export LOAD_TEST_PATHS={_shell_quote(runtime.paths)} +{_scenario_export(runtime.scenario)}{_operator_scenario_env_exports()}{auth_export}{extra_exports}{auth_file_bootstrap}pkill -x locust 2>/dev/null || true +nohup {bin_path} -f {locustfile_path} --worker --master-host {master_host} \\ + > /var/log/locust-worker.log 2>&1 & +""" + + +def _scenario_export(scenario: str) -> str: + return f"export LOAD_TEST_SCENARIO={_shell_quote(scenario)}\n" + + +def _load_params_env_exports(params: LoadTestParams) -> str: + """Export load-test env vars so the Locust web UI can pre-fill Start defaults.""" + lines = [ + f"export LOAD_TEST_USERS={params.users}", + f"export LOAD_TEST_SPAWN_RATE={params.spawn_rate}", + f"export LOCUST_USERS={params.users}", + f"export LOCUST_SPAWN_RATE={params.spawn_rate}", + ] + if params.duration is not None: + duration = _shell_quote(params.duration) + lines.append(f"export LOAD_TEST_DURATION={duration}") + lines.append(f"export LOCUST_RUN_TIME={duration}") + return "\n".join(lines) + "\n" + + +def _format_load_flag_lines(params: LoadTestParams, *, headless: bool) -> str: + lines = build_locust_load_flag_lines( + params, + headless=headless, + shell_quote=_shell_quote, + ) + if not lines: + return "" + return " " + " \\\n ".join(lines) + " \\\n" + + +def _build_master_command( + request: LoadTestRunRequest, + app_config: AppConfig, + *, + master_private_ip: str, + worker_count: int, +) -> str: + install_dir = app_config.locust_install_dir + locustfile_path = _locustfile_remote_path(app_config) + bin_path = locust_bin(install_dir) + auth_export = "" + auth_file_bootstrap = "" + if request.auth_header: + auth_export = ( + f"export LOAD_TEST_AUTH_HEADER={_shell_quote(request.auth_header)}\n" + ) + auth_file_bootstrap = _auth_file_bootstrap_lines(request.auth_header) + load_flags = _format_load_flag_lines(request.load_params, headless=True) + load_env = _load_params_env_exports(request.load_params) + extra_exports = _extra_env_exports(request.extra_env) + return f""" +set -euo pipefail +export PYTHONPATH={install_dir!r} +export LOAD_TEST_PATHS={_shell_quote(request.paths)} +{_scenario_export(request.scenario)}{_operator_scenario_env_exports()}{load_env}{auth_export}{extra_exports}{auth_file_bootstrap}pkill -x locust 2>/dev/null || true +{bin_path} -f {locustfile_path} \\ + --master \\ + --master-bind-host {_shell_quote(master_private_ip)} \\ + --expect-workers {worker_count} \\ +{load_flags} --host {_shell_quote(normalize_locust_host(request.target_url))} +""" + + +def _build_master_ui_command( + request: LoadTestUiRequest, + app_config: AppConfig, + *, + master_private_ip: str, + worker_count: int, +) -> str: + install_dir = app_config.locust_install_dir + locustfile_path = _locustfile_remote_path(app_config) + bin_path = locust_bin(install_dir) + auth_export = "" + auth_file_bootstrap = "" + if request.auth_header: + auth_export = ( + f"export LOAD_TEST_AUTH_HEADER={_shell_quote(request.auth_header)}\n" + ) + auth_file_bootstrap = _auth_file_bootstrap_lines(request.auth_header) + load_flags = _format_load_flag_lines(request.load_params, headless=False) + load_env = _load_params_env_exports(request.load_params) + extra_exports = _extra_env_exports(request.extra_env) + return f""" +set -euo pipefail +export PYTHONPATH={install_dir!r} +export LOAD_TEST_PATHS={_shell_quote(request.paths)} +{_scenario_export(request.scenario)}{_operator_scenario_env_exports()}{load_env}{auth_export}{extra_exports}{auth_file_bootstrap}pkill -x locust 2>/dev/null || true +nohup {bin_path} -f {locustfile_path} \\ + --master \\ + --master-bind-host {_shell_quote(master_private_ip)} \\ + --expect-workers {worker_count} \\ + --class-picker \\ + --web-host 127.0.0.1 \\ +{load_flags} --host {_shell_quote(normalize_locust_host(request.target_url))} \\ + > /var/log/locust-master.log 2>&1 & +""" + + +def _send_command( + ssm_client, + *, + instance_ids: Sequence[str], + commands: Sequence[str], + comment: str, +) -> str: + response = ssm_client.send_command( + InstanceIds=list(instance_ids), + DocumentName=_SSM_DOCUMENT, + Comment=comment, + Parameters={"commands": list(commands)}, + TimeoutSeconds=_COMMAND_TIMEOUT_SECONDS, + ) + return response["Command"]["CommandId"] + + +def _wait_for_command( + ssm_client, + *, + command_id: str, + instance_id: str, +) -> str: + deadline = time.time() + _COMMAND_TIMEOUT_SECONDS + while time.time() < deadline: + try: + response = ssm_client.get_command_invocation( + CommandId=command_id, + InstanceId=instance_id, + ) + except ClientError as exc: + error_code = exc.response.get("Error", {}).get("Code", "") + if error_code != "InvocationDoesNotExist": + raise + time.sleep(_POLL_INTERVAL_SECONDS) + continue + status = response["Status"] + if status in {"Success", "Cancelled", "TimedOut", "Failed"}: + stdout = response.get("StandardOutputContent", "") + stderr = response.get("StandardErrorContent", "") + if status != "Success": + msg = f"SSM command {command_id} on {instance_id} ended with {status}" + if stderr: + msg = f"{msg}\n{stderr}" + raise RuntimeError(msg) + return stdout + time.sleep(_POLL_INTERVAL_SECONDS) + + msg = f"Timed out waiting for SSM command {command_id} on {instance_id}" + raise TimeoutError(msg) + + +def _sync_locust_sources( + ssm_client, + *, + instance_ids: Sequence[str], + app_config: AppConfig, +) -> None: + if not instance_ids: + return + script = _build_sync_locust_sources_script(app_config) + command_id = _send_command( + ssm_client, + instance_ids=instance_ids, + commands=[_wrap_bash_script(script)], + comment="Sync Locust source files", + ) + for instance_id in instance_ids: + _wait_for_command( + ssm_client, + command_id=command_id, + instance_id=instance_id, + ) + + +def _bootstrap_locust( + ssm_client, + *, + instance_ids: Sequence[str], + app_config: AppConfig, +) -> None: + if not instance_ids: + return + script = _bootstrap_locust_script(app_config.locust_install_dir) + command_id = _send_command( + ssm_client, + instance_ids=instance_ids, + commands=[_wrap_bash_script(script)], + comment="Bootstrap Locust if missing", + ) + for instance_id in instance_ids: + _wait_for_command( + ssm_client, + command_id=command_id, + instance_id=instance_id, + ) + + +def _verify_master_web_ui( + ssm_client, + *, + master_instance_id: str, +) -> None: + command_id = _send_command( + ssm_client, + instance_ids=[master_instance_id], + commands=[_wrap_bash_script(_verify_master_web_ui_script())], + comment="Verify Locust web UI port", + ) + _wait_for_command( + ssm_client, + command_id=command_id, + instance_id=master_instance_id, + ) + + +def _start_workers( + ssm_client, + *, + worker_instance_ids: Sequence[str], + master_private_ip: str, + app_config: AppConfig, + runtime: WorkerRuntimeEnv, +) -> None: + if not worker_instance_ids: + return + worker_command_id = _send_command( + ssm_client, + instance_ids=worker_instance_ids, + commands=[ + _wrap_bash_script( + _build_worker_command( + app_config, + master_private_ip, + runtime, + ) + ) + ], + comment="Start Locust workers", + ) + for worker_id in worker_instance_ids: + _wait_for_command( + ssm_client, + command_id=worker_command_id, + instance_id=worker_id, + ) + + +def run_load_test(request: LoadTestRunRequest, app_config: AppConfig) -> str: + """Start workers, run a headless Locust test on the master, and return stdout.""" + outputs = load_stack_outputs(request.stack, app_config.pulumi_project_dir) + master_instance_id = str(outputs["master_instance_id"]) + master_private_ip = str(outputs["master_private_ip"]) + worker_instance_ids = [ + str(instance_id) for instance_id in outputs["worker_instance_ids"] + ] + worker_count = len(worker_instance_ids) + + ssm_client = _create_ssm_client(request.profile, request.region) + all_instance_ids = [master_instance_id, *worker_instance_ids] + _sync_locust_sources( + ssm_client, instance_ids=all_instance_ids, app_config=app_config + ) + _bootstrap_locust(ssm_client, instance_ids=all_instance_ids, app_config=app_config) + _start_workers( + ssm_client, + worker_instance_ids=worker_instance_ids, + master_private_ip=master_private_ip, + app_config=app_config, + runtime=_worker_runtime_env( + scenario=request.scenario, + paths=request.paths, + auth_header=request.auth_header, + extra_env=request.extra_env, + ), + ) + + master_command_id = _send_command( + ssm_client, + instance_ids=[master_instance_id], + commands=[ + _wrap_bash_script( + _build_master_command( + request, + app_config, + master_private_ip=master_private_ip, + worker_count=worker_count, + ) + ) + ], + comment="Run Locust headless master", + ) + return _wait_for_command( + ssm_client, + command_id=master_command_id, + instance_id=master_instance_id, + ) + + +def start_locust_ui(request: LoadTestUiRequest, app_config: AppConfig) -> str: + """Start workers and a Locust master with the web UI enabled.""" + outputs = load_stack_outputs(request.stack, app_config.pulumi_project_dir) + master_instance_id = str(outputs["master_instance_id"]) + master_private_ip = str(outputs["master_private_ip"]) + worker_instance_ids = [ + str(instance_id) for instance_id in outputs["worker_instance_ids"] + ] + worker_count = len(worker_instance_ids) + + ssm_client = _create_ssm_client(request.profile, request.region) + all_instance_ids = [master_instance_id, *worker_instance_ids] + _sync_locust_sources( + ssm_client, instance_ids=all_instance_ids, app_config=app_config + ) + _bootstrap_locust(ssm_client, instance_ids=all_instance_ids, app_config=app_config) + _start_workers( + ssm_client, + worker_instance_ids=worker_instance_ids, + master_private_ip=master_private_ip, + app_config=app_config, + runtime=_worker_runtime_env( + scenario=request.scenario, + paths=request.paths, + auth_header=request.auth_header, + extra_env=request.extra_env, + ), + ) + + master_command_id = _send_command( + ssm_client, + instance_ids=[master_instance_id], + commands=[ + _wrap_bash_script( + _build_master_ui_command( + request, + app_config, + master_private_ip=master_private_ip, + worker_count=worker_count, + ) + ) + ], + comment="Start Locust master web UI", + ) + stdout = _wait_for_command( + ssm_client, + command_id=master_command_id, + instance_id=master_instance_id, + ) + _verify_master_web_ui(ssm_client, master_instance_id=master_instance_id) + return stdout + + +def reset_cluster_locust( + *, + stack: str, + profile: str | None, + region: str | None, + app_config: AppConfig, +) -> None: + """Stop Locust on all cluster instances and free RPC/web UI ports.""" + outputs = load_stack_outputs(stack, app_config.pulumi_project_dir) + instance_ids = [str(outputs["master_instance_id"])] + instance_ids.extend( + str(instance_id) for instance_id in outputs["worker_instance_ids"] + ) + + ssm_client = _create_ssm_client(profile, region) + command_id = _send_command( + ssm_client, + instance_ids=instance_ids, + commands=[_wrap_bash_script(_reset_locust_cluster_script())], + comment="Reset Locust processes on cluster", + ) + for instance_id in instance_ids: + _wait_for_command( + ssm_client, + command_id=command_id, + instance_id=instance_id, + ) + + +def get_cluster_status( + *, + stack: str, + profile: str | None, + region: str | None, + app_config: AppConfig, +) -> dict[str, object]: + """Return stack outputs and SSM ping status for cluster instances.""" + outputs = load_stack_outputs(stack, app_config.pulumi_project_dir) + instance_ids = [str(outputs["master_instance_id"])] + instance_ids.extend( + str(instance_id) for instance_id in outputs["worker_instance_ids"] + ) + + ssm_client = _create_ssm_client(profile, region) + response = ssm_client.describe_instance_information( + Filters=[{"Key": "InstanceIds", "Values": instance_ids}], + ) + ping_status = dict.fromkeys(instance_ids, "Unknown") + for entry in response.get("InstanceInformationList", []): + ping_status[entry["InstanceId"]] = entry.get("PingStatus", "Unknown") + + return { + "outputs": outputs, + "ssm_ping_status": ping_status, + } + + +def build_ui_port_forward_command( + *, + master_instance_id: str, + region: str, + profile: str | None, + local_port: int = 8089, +) -> str: + """Return the AWS CLI command to port-forward the Locust web UI.""" + profile_flag = f"--profile {profile} " if profile else "" + return ( + f"aws {profile_flag}ssm start-session " + f"--target {master_instance_id} " + f"--region {region} " + f"--document-name AWS-StartPortForwardingSession " + f"--parameters localPortNumber={local_port},portNumber=8089" + ) diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/__init__.py b/packages/python/ess-load-test/src/ess_load_test/infra/__init__.py new file mode 100644 index 0000000..8378951 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/__init__.py @@ -0,0 +1 @@ +"""Pulumi infrastructure for ess-load-test.""" diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/__main__.py b/packages/python/ess-load-test/src/ess_load_test/infra/__main__.py new file mode 100644 index 0000000..ee58160 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/__main__.py @@ -0,0 +1,5 @@ +"""Pulumi entrypoint for the generic Locust load test EC2 cluster.""" + +from ess_load_test.infra.stack import deploy_cluster + +deploy_cluster() diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/config.py b/packages/python/ess-load-test/src/ess_load_test/infra/config.py new file mode 100644 index 0000000..a1a1c47 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/config.py @@ -0,0 +1,52 @@ +"""Pulumi config loading for the Locust load test stack.""" + +from dataclasses import dataclass + +import pulumi + +_DEFAULT_ENVIRONMENT = "dev" +_DEFAULT_INSTANCE_TYPE = "t3.large" +_DEFAULT_MASTER_INSTANCE_TYPE = "t3.small" +_DEFAULT_WORKER_COUNT = 2 + + +@dataclass(frozen=True) +class LoadTestInfraConfig: + """Typed configuration for the load test infrastructure stack.""" + + environment: str + instance_type: str + master_instance_type: str + worker_count: int + use_default_vpc: bool + vpc_id_override: str | None + subnet_ids_override: tuple[str, ...] + + +def load_config(config_namespace: str) -> LoadTestInfraConfig: + """Load stack configuration from Pulumi config.""" + cfg = pulumi.Config(config_namespace) + + raw_subnet_ids = cfg.get("subnetIds") + subnet_ids_override = tuple( + subnet_id.strip() + for subnet_id in (raw_subnet_ids or "").split(",") + if subnet_id.strip() + ) + + worker_count = cfg.get_int("workerCount") + if worker_count is None: + worker_count = _DEFAULT_WORKER_COUNT + + return LoadTestInfraConfig( + environment=cfg.get("environment") or _DEFAULT_ENVIRONMENT, + instance_type=cfg.get("instanceType") or _DEFAULT_INSTANCE_TYPE, + master_instance_type=cfg.get("masterInstanceType") + or _DEFAULT_MASTER_INSTANCE_TYPE, + worker_count=worker_count, + use_default_vpc=cfg.get_bool("useDefaultVpc") + if cfg.get("useDefaultVpc") is not None + else True, + vpc_id_override=cfg.get("vpcId"), + subnet_ids_override=subnet_ids_override, + ) diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/constants.py b/packages/python/ess-load-test/src/ess_load_test/infra/constants.py new file mode 100644 index 0000000..536cf55 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/constants.py @@ -0,0 +1,47 @@ +"""Shared constants for ess-load-test Pulumi stacks.""" + +UBUNTU_AMI_SSM_PARAMETER = ( + "/aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id" +) + + +def locustfile_remote_path(install_dir: str) -> str: + return f"{install_dir}/locustfile.py" + + +def locust_venv_dir(install_dir: str) -> str: + return f"{install_dir}/venv" + + +def locust_bin(install_dir: str) -> str: + return f"{locust_venv_dir(install_dir)}/bin/locust" + + +def locust_pip(install_dir: str) -> str: + return f"{locust_venv_dir(install_dir)}/bin/pip" + + +def base_tags(component_name: str, project_tag: str | None = None) -> dict[str, str]: + return { + "Project": project_tag or component_name, + "ManagedBy": "pulumi", + "Component": component_name, + } + + +def get_tags( + component_name: str, + environment: str, + extra: dict[str, str] | None = None, + *, + project_tag: str | None = None, +) -> dict[str, str]: + """Merge base tags with environment and optional extra tags. + + ``project_tag`` sets the ``Project`` tag and defaults to + ``component_name``. + """ + merged = {**base_tags(component_name, project_tag), "Environment": environment} + if extra: + merged.update(extra) + return merged diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/instances.py b/packages/python/ess-load-test/src/ess_load_test/infra/instances.py new file mode 100644 index 0000000..4ca111b --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/instances.py @@ -0,0 +1,214 @@ +"""EC2 instance provisioning for the Locust load test cluster.""" + +from dataclasses import dataclass + +import pulumi +import pulumi_aws as aws + +from ess_load_test.app_config import LocustSyncFile +from ess_load_test.infra.config import LoadTestInfraConfig +from ess_load_test.infra.constants import ( + UBUNTU_AMI_SSM_PARAMETER, + get_tags, + locust_pip, + locust_venv_dir, +) +from ess_load_test.infra.network import NetworkPlacement + + +@dataclass +class InstanceOutputs: + """Outputs from the instances module.""" + + master_instance_id: pulumi.Output[str] + master_private_ip: pulumi.Output[str] + worker_instance_ids: list[pulumi.Output[str]] + + +def controller_hostname(resource_prefix: str) -> str: + """Return the EC2 Name tag and OS hostname for the Locust coordinator.""" + return f"{resource_prefix}-locust-controller" + + +def worker_hostname(resource_prefix: str, index: int) -> str: + """Return the EC2 Name tag and OS hostname for a Locust worker.""" + return f"{resource_prefix}-locust-worker-{index}" + + +EC2_USER_DATA_MAX_BYTES = 16 * 1024 + + +def _build_user_data( + hostname: str, + *, + locust_install_dir: str, + cloud_init_files: tuple[LocustSyncFile, ...], +) -> str: + """Build cloud-init user data that sets hostname and installs Locust. + + Locust scenario sources are synced via SSM before each run (see cluster + runner); they are not embedded here because EC2 user_data is capped at + 16 KiB and a typical scenario bundle exceeds that limit. + """ + del cloud_init_files + venv_dir = locust_venv_dir(locust_install_dir) + pip_bin = locust_pip(locust_install_dir) + enable_ssm_agent = ( + "bash -lc 'systemctl enable snap.amazon-ssm-agent.amazon-ssm-agent.service " + "|| systemctl enable amazon-ssm-agent'" + ) + start_ssm_agent = ( + "bash -lc 'systemctl start snap.amazon-ssm-agent.amazon-ssm-agent.service " + "|| systemctl start amazon-ssm-agent'" + ) + return f"""#cloud-config +hostname: {hostname} +fqdn: {hostname}.local +manage_etc_hosts: true +preserve_hostname: false +package_update: true +packages: + - python3-pip + - python3-venv +runcmd: + - hostnamectl set-hostname {hostname} + - mkdir -p {locust_install_dir} + - python3 -m venv {venv_dir} + - {pip_bin} install -q 'locust>=2.32,<3' + - snap install amazon-ssm-agent --classic || apt-get install -y amazon-ssm-agent + - {enable_ssm_agent} + - {start_ssm_agent} +""" + + +def create_instance_profile( + resource_prefix: str, + *, + component_name: str, + environment: str, + project_tag: str | None = None, +) -> aws.iam.InstanceProfile: + """Create an IAM role and instance profile for SSM-managed EC2.""" + role = aws.iam.Role( + f"{resource_prefix}-ec2-role", + name=f"{resource_prefix}-ec2-role", + assume_role_policy="""{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "Service": "ec2.amazonaws.com" }, + "Action": "sts:AssumeRole" + } + ] +}""", + tags=get_tags( + component_name, + environment, + {"Name": f"{resource_prefix}-ec2-role"}, + project_tag=project_tag, + ), + ) + aws.iam.RolePolicyAttachment( + f"{resource_prefix}-ssm-core", + role=role.name, + policy_arn="arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", + ) + profile = aws.iam.InstanceProfile( + f"{resource_prefix}-ec2-profile", + name=f"{resource_prefix}-ec2-profile", + role=role.name, + tags=get_tags( + component_name, + environment, + {"Name": f"{resource_prefix}-ec2-profile"}, + project_tag=project_tag, + ), + ) + return profile + + +def create_instances( + resource_prefix: str, + config: LoadTestInfraConfig, + placement: NetworkPlacement, + security_group_id: pulumi.Input[str], + instance_profile: aws.iam.InstanceProfile, + *, + component_name: str, + locust_install_dir: str, + cloud_init_files: tuple[LocustSyncFile, ...], + project_tag: str | None = None, +) -> InstanceOutputs: + """Create Locust coordinator and worker EC2 instances.""" + ami = aws.ssm.get_parameter(name=UBUNTU_AMI_SSM_PARAMETER) + environment = config.environment + + common_args = { + "ami": ami.value, + "subnet_id": placement.subnet_id, + "vpc_security_group_ids": [security_group_id], + "iam_instance_profile": instance_profile.name, + "associate_public_ip_address": True, + "user_data_replace_on_change": True, + "metadata_options": aws.ec2.InstanceMetadataOptionsArgs( + http_endpoint="enabled", + http_tokens="required", + ), + "root_block_device": aws.ec2.InstanceRootBlockDeviceArgs( + volume_size=20, + volume_type="gp3", + encrypted=True, + ), + } + + controller_name = controller_hostname(resource_prefix) + controller = aws.ec2.Instance( + f"{resource_prefix}-locust-controller", + instance_type=config.master_instance_type, + user_data=_build_user_data( + controller_name, + locust_install_dir=locust_install_dir, + cloud_init_files=cloud_init_files, + ), + tags=get_tags( + component_name, + environment, + { + "Name": controller_name, + "Role": "locust-controller", + }, + project_tag=project_tag, + ), + **common_args, + ) + + workers: list[aws.ec2.Instance] = [] + for index in range(config.worker_count): + worker_name = worker_hostname(resource_prefix, index) + worker = aws.ec2.Instance( + f"{resource_prefix}-locust-worker-{index}", + instance_type=config.instance_type, + user_data=_build_user_data( + worker_name, + locust_install_dir=locust_install_dir, + cloud_init_files=cloud_init_files, + ), + tags=get_tags( + component_name, + environment, + { + "Name": worker_name, + "Role": "locust-worker", + }, + project_tag=project_tag, + ), + **common_args, + ) + workers.append(worker) + + return InstanceOutputs( + master_instance_id=controller.id, + master_private_ip=controller.private_ip, + worker_instance_ids=[worker.id for worker in workers], + ) diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/network.py b/packages/python/ess-load-test/src/ess_load_test/infra/network.py new file mode 100644 index 0000000..1aa5b03 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/network.py @@ -0,0 +1,170 @@ +"""Network discovery and security group resources.""" + +from dataclasses import dataclass + +import pulumi +import pulumi_aws as aws + +from ess_load_test.infra.config import LoadTestInfraConfig +from ess_load_test.infra.constants import get_tags + +_LOCUST_MASTER_PORT = 5557 +_LOCUST_STATS_PORT = 5558 + + +@dataclass(frozen=True) +class NetworkPlacement: + """Resolved VPC and subnet for EC2 instances.""" + + vpc_id: str + subnet_id: str + + +@dataclass +class NetworkOutputs: + """Outputs from the network module.""" + + placement: NetworkPlacement + security_group_id: pulumi.Output[str] + + +def resolve_network_placement( + config: LoadTestInfraConfig, + *, + config_namespace: str, +) -> NetworkPlacement: + """Resolve VPC and subnet IDs for instance placement.""" + has_vpc_override = bool(config.vpc_id_override) + has_subnet_override = bool(config.subnet_ids_override) + if has_vpc_override != has_subnet_override: + msg = ( + f"{config_namespace}:vpcId and {config_namespace}:subnetIds must both " + "be set when overriding network placement." + ) + raise ValueError(msg) + + if has_vpc_override and has_subnet_override: + return NetworkPlacement( + vpc_id=config.vpc_id_override, + subnet_id=config.subnet_ids_override[0], + ) + + if not config.use_default_vpc: + msg = ( + "useDefaultVpc is false but vpcId/subnetIds overrides are not set. " + f"Set {config_namespace}:vpcId and {config_namespace}:subnetIds." + ) + raise ValueError(msg) + + default_vpc = aws.ec2.get_vpc(default=True) + subnets = aws.ec2.get_subnets( + filters=[ + aws.ec2.GetSubnetsFilterArgs(name="vpc-id", values=[default_vpc.id]), + aws.ec2.GetSubnetsFilterArgs(name="default-for-az", values=["true"]), + ], + ) + if not subnets.ids: + msg = "No default-for-az subnets found in the default VPC." + raise ValueError(msg) + + return NetworkPlacement(vpc_id=default_vpc.id, subnet_id=subnets.ids[0]) + + +def build_locust_security_group_egress() -> list[aws.ec2.SecurityGroupEgressArgs]: + """Return egress rules for Locust load generator instances.""" + return [ + aws.ec2.SecurityGroupEgressArgs( + protocol="tcp", + from_port=80, + to_port=80, + cidr_blocks=["0.0.0.0/0"], + description="HTTP outbound for apt/bootstrap on instance launch", + ), + aws.ec2.SecurityGroupEgressArgs( + protocol="tcp", + from_port=443, + to_port=443, + cidr_blocks=["0.0.0.0/0"], + description="HTTPS outbound to apt mirrors, PyPI, and load test targets", + ), + aws.ec2.SecurityGroupEgressArgs( + protocol="udp", + from_port=53, + to_port=53, + cidr_blocks=["0.0.0.0/0"], + description="DNS (UDP) outbound", + ), + aws.ec2.SecurityGroupEgressArgs( + protocol="tcp", + from_port=53, + to_port=53, + cidr_blocks=["0.0.0.0/0"], + description="DNS (TCP) outbound", + ), + aws.ec2.SecurityGroupEgressArgs( + protocol="tcp", + from_port=_LOCUST_MASTER_PORT, + to_port=_LOCUST_STATS_PORT, + self=True, + description="Locust worker to master coordination and stats", + ), + ] + + +def build_locust_security_group_ingress() -> list[aws.ec2.SecurityGroupIngressArgs]: + """Return ingress rules for Locust master/worker coordination.""" + return [ + aws.ec2.SecurityGroupIngressArgs( + protocol="tcp", + from_port=_LOCUST_MASTER_PORT, + to_port=_LOCUST_STATS_PORT, + self=True, + description="Locust worker to master coordination and stats", + ), + ] + + +def has_public_internet_ingress( + ingress_rules: list[aws.ec2.SecurityGroupIngressArgs], +) -> bool: + """Return True if any ingress rule allows traffic from the public internet.""" + for rule in ingress_rules: + cidr_blocks = rule.cidr_blocks or [] + if "0.0.0.0/0" in cidr_blocks: + return True + ipv6_cidr_blocks = rule.ipv6_cidr_blocks or [] + if "::/0" in ipv6_cidr_blocks: + return True + return False + + +def create_network( + resource_prefix: str, + placement: NetworkPlacement, + *, + component_name: str, + environment: str, + project_tag: str | None = None, +) -> NetworkOutputs: + """Create the Locust cluster security group.""" + ingress_rules = build_locust_security_group_ingress() + security_group = aws.ec2.SecurityGroup( + f"{resource_prefix}-locust-sg", + name=f"{resource_prefix}-locust-sg", + description="Security group for Locust load test EC2 instances", + vpc_id=placement.vpc_id, + ingress=ingress_rules, + egress=build_locust_security_group_egress(), + tags=get_tags( + component_name, + environment, + {"Name": f"{resource_prefix}-locust-sg"}, + project_tag=project_tag, + ), + opts=pulumi.ResourceOptions(delete_before_replace=False), + ) + + return NetworkOutputs( + placement=placement, + security_group_id=security_group.id, + ) diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/stack.py b/packages/python/ess-load-test/src/ess_load_test/infra/stack.py new file mode 100644 index 0000000..7ed1f63 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/infra/stack.py @@ -0,0 +1,86 @@ +"""Composable Locust load-test cluster deployment.""" + +from pathlib import Path + +import pulumi +import pulumi_aws as aws +from dotenv import load_dotenv + +from ess_load_test.app_config import LocustBundle, LocustSyncFile, default_app_config +from ess_load_test.infra.config import load_config +from ess_load_test.infra.instances import create_instance_profile, create_instances +from ess_load_test.infra.network import create_network, resolve_network_placement + + +def deploy_cluster( # noqa: PLR0913 -- all params are keyword-only with defaults + *, + locust_install_dir: str | None = None, + component_name: str | None = None, + pulumi_config_namespace: str | None = None, + cloud_init_bundle: LocustBundle | None = None, + pulumi_project_dir: Path | None = None, + project_tag: str | None = None, +) -> None: + """Deploy the Locust EC2 cluster and export Pulumi outputs. + + Shells call this from ``infra/__main__.py`` with organization-specific + install paths, tags, and Locust source bundles. ``project_tag`` sets the + ``Project`` tag on every resource and defaults to the component name. + """ + defaults = default_app_config() + resolved_install_dir = locust_install_dir or defaults.locust_install_dir + resolved_component = component_name or defaults.component_name + resolved_namespace = pulumi_config_namespace or defaults.config_namespace + resolved_bundle = cloud_init_bundle or defaults.locust_bundle + resolved_project_dir = pulumi_project_dir or defaults.pulumi_project_dir + + load_dotenv(resolved_project_dir / ".env") + + config = load_config(resolved_namespace) + resource_prefix = f"load-test-{config.environment}" + + current_region = aws.get_region() + placement = resolve_network_placement( + config, + config_namespace=resolved_namespace, + ) + network = create_network( + resource_prefix, + placement, + component_name=resolved_component, + environment=config.environment, + project_tag=project_tag, + ) + instance_profile = create_instance_profile( + resource_prefix, + component_name=resolved_component, + environment=config.environment, + project_tag=project_tag, + ) + instances = create_instances( + resource_prefix=resource_prefix, + config=config, + placement=placement, + security_group_id=network.security_group_id, + instance_profile=instance_profile, + component_name=resolved_component, + locust_install_dir=resolved_install_dir, + cloud_init_files=cloud_init_files_from_bundle(resolved_bundle), + project_tag=project_tag, + ) + + pulumi.export("master_instance_id", instances.master_instance_id) + pulumi.export("master_private_ip", instances.master_private_ip) + pulumi.export("worker_instance_ids", instances.worker_instance_ids) + pulumi.export("security_group_id", network.security_group_id) + pulumi.export("region", current_region.region) + pulumi.export("worker_count", config.worker_count) + + +def cloud_init_files_from_bundle(bundle: LocustBundle) -> tuple[LocustSyncFile, ...]: + """Return sync files for cloud-init (paths must exist on disk).""" + for sync_file in bundle.sync_files: + if not sync_file.local_path.is_file(): + msg = f"Cloud-init source missing: {sync_file.local_path}" + raise FileNotFoundError(msg) + return bundle.sync_files diff --git a/packages/python/ess-load-test/src/ess_load_test/locust/__init__.py b/packages/python/ess-load-test/src/ess_load_test/locust/__init__.py new file mode 100644 index 0000000..a631c46 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/locust/__init__.py @@ -0,0 +1 @@ +"""Locust scenario modules for ess-load-test.""" diff --git a/packages/python/ess-load-test/src/ess_load_test/locust/locustfile.py b/packages/python/ess-load-test/src/ess_load_test/locust/locustfile.py new file mode 100644 index 0000000..1d508a4 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/locust/locustfile.py @@ -0,0 +1,42 @@ +"""Locust entrypoint — default scenario only. + +Deployment layout: the cluster runner syncs this directory to EC2 as flat files +under the configured install dir (``locustfile.py``, ``locust_runtime.py``, …) +with ``PYTHONPATH`` set there. Imports therefore use bare module names on workers. + +In the monorepo the same sources live under ``ess_load_test.locust.*``. +Each file uses ``try/except ImportError`` so pylint, tests, and EC2 all resolve +imports without inline ``pylint: disable=import-error`` suppressions. +""" + +import os + +from locust import events + +try: + from locust_runtime import normalize_scenario +except ImportError: + from ess_load_test.locust.runtime import normalize_scenario + +try: + from scenario_default import LoadTestUser +except ImportError: + from ess_load_test.locust.scenario_default import LoadTestUser + +_SCENARIO = normalize_scenario(os.environ.get("LOAD_TEST_SCENARIO")) + +_LOCUST_RUN_PROFILES = ("smoke", "default-run") + + +@events.init.add_listener +def on_locust_init(environment, **_kwargs) -> None: + """Suggest run labels in the Locust web UI Profile field.""" + if environment.web_ui is None: + return + + environment.web_ui.template_args["all_profiles"] = list(_LOCUST_RUN_PROFILES) + + +# Locust discovers HttpUser subclasses imported here; the tuple references +# them so pylint does not report the imports as unused (F401). +_LOCUST_USER_CLASSES = (LoadTestUser,) diff --git a/packages/python/ess-load-test/src/ess_load_test/locust/runtime.py b/packages/python/ess-load-test/src/ess_load_test/locust/runtime.py new file mode 100644 index 0000000..2f8e9b8 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/locust/runtime.py @@ -0,0 +1,188 @@ +"""Locust helpers deployed to EC2 (stdlib only — no locust import). + +On EC2 this module is written to ``locust_runtime.py`` beside ``locustfile.py``. +Scenario modules import ``locust_runtime`` there; in the monorepo they import +``ess_load_test.locust.runtime`` via ``try/except ImportError`` shims. +""" + +import os +from http import HTTPStatus +from typing import Protocol + +_DEFAULT_PATHS = ("/",) +_AUTH_HEADER_FILE = "/tmp/load-test-auth-header.txt" # nosec B108 — EC2 worker sync path +_REDIRECT_STATUS_CODES = frozenset({301, 302, 307, 308}) + +VALID_SCENARIOS = frozenset({"default"}) +DEFAULT_SCENARIO = "default" +UI_DEFAULT_SCENARIO = "default" + +_SCENARIO_DEFAULT_PROFILES: dict[str, str] = { + "default": "smoke", + "pages": "pages-run", + "bff": "bff-run", + "api": "api-run", + "mixed": "mixed-run", +} + +LOCUST_RUN_PROFILES = ( + "smoke", + "pages-run", + "bff-run", + "api-run", + "mixed-run", +) + + +class _ResponseLike(Protocol): + status_code: int + + def failure(self, message: str) -> None: ... + + def success(self) -> None: ... + + +class InvalidScenarioError(ValueError): + """Raised when LOAD_TEST_SCENARIO is not a supported value.""" + + +def normalize_scenario( + raw_scenario: str | None, + *, + valid_scenarios: frozenset[str] | None = None, +) -> str: + """Return a supported scenario name from env or CLI input.""" + scenarios = valid_scenarios or VALID_SCENARIOS + if not raw_scenario or not raw_scenario.strip(): + return DEFAULT_SCENARIO + scenario = raw_scenario.strip().lower() + if scenario not in scenarios: + options = ", ".join(sorted(scenarios)) + msg = f"Unsupported LOAD_TEST_SCENARIO {scenario!r}. Expected one of: {options}" + raise InvalidScenarioError(msg) + return scenario + + +def mark_response_failure( + response: _ResponseLike, + *, + strict: bool = False, + accept_statuses: frozenset[int] | None = None, +) -> None: + """Mark a Locust response as failed for 5xx, or 4xx when strict. + + Locust treats 4xx as failures on context exit unless ``response.success()`` + was called. Pass ``accept_statuses`` for expected non-2xx outcomes (e.g. + validation probes) so they are not counted as failures. + """ + status_code = response.status_code + if status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: + response.failure(f"status {status_code}") + return + if accept_statuses and status_code in accept_statuses: + response.success() + return + if strict and status_code >= HTTPStatus.BAD_REQUEST: + response.failure(f"status {status_code}") + + +def parse_load_test_paths(raw_paths: str | None) -> tuple[str, ...]: + """Parse comma-separated HTTP paths for load test tasks.""" + if not raw_paths: + return _DEFAULT_PATHS + paths = tuple(path.strip() for path in raw_paths.split(",") if path.strip()) + return paths or _DEFAULT_PATHS + + +def normalize_locust_host(target_url: str) -> str: + """Strip trailing slashes from the Locust --host base URL. + + Next.js returns 308 when ``--host`` ends with ``/`` and paths start with + ``/api/...``, which breaks POST requests (auth cookies never reach the route). + """ + return target_url.strip().rstrip("/") + + +def default_profile_for_scenario(scenario: str) -> str: + """Return the suggested Locust UI profile label for a load-test scenario.""" + return _SCENARIO_DEFAULT_PROFILES.get(scenario, "smoke") + + +def read_load_test_users() -> int | None: + """Return peak user count from LOAD_TEST_USERS or LOCUST_USERS.""" + for env_name in ("LOAD_TEST_USERS", "LOCUST_USERS"): + raw_value = os.environ.get(env_name) + if raw_value is not None and raw_value.strip(): + return int(raw_value.strip()) + return None + + +def read_load_test_spawn_rate() -> float | None: + """Return spawn rate from LOAD_TEST_SPAWN_RATE or LOCUST_SPAWN_RATE.""" + for env_name in ("LOAD_TEST_SPAWN_RATE", "LOCUST_SPAWN_RATE"): + raw_value = os.environ.get(env_name) + if raw_value is not None and raw_value.strip(): + return float(raw_value.strip()) + return None + + +def read_load_test_duration() -> str | None: + """Return run duration string from LOAD_TEST_DURATION or LOCUST_RUN_TIME.""" + for env_name in ("LOAD_TEST_DURATION", "LOCUST_RUN_TIME"): + raw_value = os.environ.get(env_name) + if raw_value is not None and raw_value.strip(): + return raw_value.strip() + return None + + +def read_auth_header() -> str | None: + """Return the Locust auth header from a synced file or process env.""" + file_path = os.environ.get("LOAD_TEST_AUTH_HEADER_FILE", _AUTH_HEADER_FILE) + try: + with open(file_path, encoding="utf-8") as auth_file: + from_file = auth_file.read().strip() + except OSError: + from_file = "" + if from_file: + return from_file + raw = os.environ.get("LOAD_TEST_AUTH_HEADER") + if raw and raw.strip(): + return raw.strip() + return None + + +def apply_auth_header(headers: dict[str, str], raw_auth_header: str | None) -> None: + """Apply an optional auth header value to request headers.""" + if not raw_auth_header: + return + if ":" in raw_auth_header: + header_name, header_value = raw_auth_header.split(":", 1) + headers[header_name.strip()] = header_value.strip() + return + headers["Authorization"] = raw_auth_header.strip() + + +def apply_auth_to_client(client, raw_auth_header: str | None) -> None: + """Apply browser cookie or bearer auth to a Locust requests session.""" + if not raw_auth_header: + return + if ":" in raw_auth_header: + header_name, header_value = raw_auth_header.split(":", 1) + name = header_name.strip() + value = header_value.strip() + client.headers[name] = value + if name.lower() == "cookie": + client.cookies.clear() + for raw_cookie_pair in value.split(";"): + cookie_pair = raw_cookie_pair.strip() + if "=" not in cookie_pair: + continue + cookie_name, cookie_value = cookie_pair.split("=", 1) + client.cookies.set(cookie_name.strip(), cookie_value.strip()) + return + client.headers["Authorization"] = raw_auth_header.strip() + + +def is_auth_redirect(status_code: int) -> bool: + """Return True when an unauthenticated API call was redirected to sign-in.""" + return status_code in _REDIRECT_STATUS_CODES diff --git a/packages/python/ess-load-test/src/ess_load_test/locust/scenario_default.py b/packages/python/ess-load-test/src/ess_load_test/locust/scenario_default.py new file mode 100644 index 0000000..0210c62 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/locust/scenario_default.py @@ -0,0 +1,31 @@ +"""Default Locust user that hits configurable HTTP paths.""" + +import os +from http import HTTPStatus + +from locust import HttpUser, between, task + +try: + from locust_runtime import apply_auth_header, parse_load_test_paths +except ImportError: + from ess_load_test.locust.runtime import apply_auth_header, parse_load_test_paths + + +class LoadTestUser(HttpUser): + """Simulate users hitting one or more HTTP paths on the target host.""" + + wait_time = between(0.5, 1.5) + + def on_start(self) -> None: + apply_auth_header( + self.client.headers, + os.environ.get("LOAD_TEST_AUTH_HEADER"), + ) + + @task + def request_paths(self) -> None: + paths = parse_load_test_paths(os.environ.get("LOAD_TEST_PATHS")) + for path in paths: + with self.client.get(path, catch_response=True, name=path) as response: + if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: + response.failure(f"status {response.status_code}") diff --git a/packages/python/ess-load-test/src/ess_load_test/ui/__init__.py b/packages/python/ess-load-test/src/ess_load_test/ui/__init__.py new file mode 100644 index 0000000..68515f0 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/ui/__init__.py @@ -0,0 +1,35 @@ +"""Local Locust web UI workflow helpers.""" + +from ess_load_test.ui.local_port import ( + LocalPortExpectation, + check_local_port, + cleanup_stale_port_forward, + is_local_port_listening, + kill_stale_ssm_port_forward, +) +from ess_load_test.ui.workflow import ( + UiWorkflowConfig, + UiWorkflowError, + build_port_forward_argv, + open_locust_ui, + print_cluster_status, + resolve_auth_header, + run_port_forward, + run_preflight, +) + +__all__ = [ + "LocalPortExpectation", + "UiWorkflowConfig", + "UiWorkflowError", + "build_port_forward_argv", + "check_local_port", + "cleanup_stale_port_forward", + "is_local_port_listening", + "kill_stale_ssm_port_forward", + "open_locust_ui", + "print_cluster_status", + "resolve_auth_header", + "run_port_forward", + "run_preflight", +] diff --git a/packages/python/ess-load-test/src/ess_load_test/ui/local_port.py b/packages/python/ess-load-test/src/ess_load_test/ui/local_port.py new file mode 100644 index 0000000..c00864c --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/ui/local_port.py @@ -0,0 +1,61 @@ +"""Local port-forward helpers for SSM tunnel troubleshooting.""" + +from __future__ import annotations + +import subprocess +from enum import Enum + + +class LocalPortExpectation(str, Enum): + """Expected state of a local TCP port.""" + + FREE = "free" + LISTENING = "listening" + + +def is_local_port_listening(port: int) -> bool: + """Return True when a process is listening on *port*.""" + result = subprocess.run( + ["lsof", "-i", f":{port}", "-sTCP:LISTEN", "-P", "-n"], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + +def kill_stale_ssm_port_forward(port: int) -> int: + """Stop local SSM port-forward processes for *port*.""" + patterns = ( + rf"session-manager-plugin.*{port}", + rf"ssm start-session.*localPortNumber={port}", + ) + killed = 0 + for pattern in patterns: + result = subprocess.run( + ["pkill", "-f", pattern], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + killed += 1 + return killed + + +def check_local_port(port: int, *, expect: LocalPortExpectation) -> None: + """Raise ``RuntimeError`` when the port does not match *expect*.""" + listening = is_local_port_listening(port) + if expect == LocalPortExpectation.FREE and listening: + msg = f"Port {port} is in use (expected free)." + raise RuntimeError(msg) + if expect == LocalPortExpectation.LISTENING and not listening: + msg = f"Port {port} is free (expected listening)." + raise RuntimeError(msg) + + +def cleanup_stale_port_forward(port: int) -> int: + """Kill stale SSM forwards and verify *port* is free.""" + killed = kill_stale_ssm_port_forward(port) + check_local_port(port, expect=LocalPortExpectation.FREE) + return killed diff --git a/packages/python/ess-load-test/src/ess_load_test/ui/workflow.py b/packages/python/ess-load-test/src/ess_load_test/ui/workflow.py new file mode 100644 index 0000000..4777772 --- /dev/null +++ b/packages/python/ess-load-test/src/ess_load_test/ui/workflow.py @@ -0,0 +1,251 @@ +"""Orchestration for the Locust web UI workflow.""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + +import boto3 +import click +from botocore.exceptions import ( + ClientError, + NoCredentialsError, + NoRegionError, + ProfileNotFound, +) + +from ess_load_test.app_config import AppConfig +from ess_load_test.cli.load_params import LoadTestParams +from ess_load_test.cluster.outputs import StackOutputError, load_stack_outputs +from ess_load_test.cluster.runner import ( + LoadTestUiRequest, + build_ui_port_forward_command, + get_cluster_status, + start_locust_ui, +) + + +class UiWorkflowError(RuntimeError): + """Raised when the Locust UI workflow cannot proceed.""" + + +@dataclass(frozen=True) +class UiWorkflowConfig: + """Parameters for the Locust web UI workflow.""" + + app_config: AppConfig + target_url: str + paths: str = "/" + scenario: str | None = None + stack: str | None = None + profile: str | None = None + region: str | None = None + local_port: int = 8089 + fetch_token: bool = True + auth_header: str | None = None + browser_auth: bool = False + headed: bool = False + browser_profile_dir: str | None = None + load_params: LoadTestParams | None = None + + @property + def resolved_load_params(self) -> LoadTestParams: + if self.load_params is not None: + return self.load_params + return LoadTestParams() + + @property + def resolved_scenario(self) -> str: + if self.scenario is not None: + return self.scenario + return self.app_config.ui_default_scenario + + @property + def resolved_stack(self) -> str: + return self.stack or self.app_config.default_stack + + @property + def resolved_profile(self) -> str | None: + return ( + self.profile + if self.profile is not None + else self.app_config.default_profile + ) + + @property + def resolved_region(self) -> str | None: + return self.region + + +def run_preflight(config: UiWorkflowConfig) -> None: + """Verify target URL and AWS credentials before starting Locust.""" + if not config.target_url.strip(): + msg = "Target URL is required (e.g. https://app.example.com)" + raise UiWorkflowError(msg) + + session_kwargs: dict[str, str] = {} + if config.resolved_profile: + session_kwargs["profile_name"] = config.resolved_profile + if config.resolved_region: + session_kwargs["region_name"] = config.resolved_region + + try: + boto3.Session(**session_kwargs).client("sts").get_caller_identity() + except (ClientError, NoCredentialsError, NoRegionError, ProfileNotFound) as exc: + msg = ( + f"AWS credentials failed for profile {config.resolved_profile!r}. " + "Refresh your AWS SSO session and retry." + ) + raise UiWorkflowError(msg) from exc + + +def resolve_auth_header( + explicit_header: str | None, + *, + fetch_token: bool, + resolve_token, +) -> str | None: + """Resolve the Locust auth header from explicit value, env, or OAuth.""" + if explicit_header: + return explicit_header + if not fetch_token: + return None + return resolve_token() + + +def open_locust_ui( + config: UiWorkflowConfig, + *, + resolve_token, +) -> tuple[dict[str, object], str]: + """Preflight, start Locust UI, return outputs and forward cmd.""" + run_preflight(config) + try: + outputs = load_stack_outputs( + config.resolved_stack, + config.app_config.pulumi_project_dir, + ) + except StackOutputError as exc: + raise UiWorkflowError(str(exc)) from exc + + resolved_region = config.resolved_region or str(outputs.get("region", "us-east-1")) + resolved_auth = resolve_auth_header( + config.auth_header, + fetch_token=config.fetch_token, + resolve_token=resolve_token, + ) + + try: + request = LoadTestUiRequest( + stack=config.resolved_stack, + target_url=config.target_url, + paths=config.paths, + scenario=config.resolved_scenario, + load_params=config.resolved_load_params, + auth_header=resolved_auth, + profile=config.resolved_profile, + region=resolved_region or None, + ) + start_locust_ui(request, config.app_config) + except (RuntimeError, TimeoutError) as exc: + raise UiWorkflowError(str(exc)) from exc + + forward_command = build_ui_port_forward_command( + master_instance_id=str(outputs["master_instance_id"]), + region=resolved_region, + profile=config.resolved_profile, + local_port=config.local_port, + ) + return outputs, forward_command + + +def print_cluster_status(config: UiWorkflowConfig) -> None: + """Print Pulumi outputs and SSM reachability.""" + try: + outputs = load_stack_outputs( + config.resolved_stack, + config.app_config.pulumi_project_dir, + ) + resolved_region = config.resolved_region or str(outputs.get("region", "")) + status = get_cluster_status( + stack=config.resolved_stack, + profile=config.resolved_profile, + region=resolved_region or None, + app_config=config.app_config, + ) + except StackOutputError as exc: + raise UiWorkflowError(str(exc)) from exc + + stack_outputs = status["outputs"] + click.echo(f"master_instance_id: {stack_outputs['master_instance_id']}") + click.echo(f"master_private_ip: {stack_outputs['master_private_ip']}") + click.echo(f"worker_instance_ids: {stack_outputs['worker_instance_ids']}") + click.echo(f"security_group_id: {stack_outputs['security_group_id']}") + click.echo(f"region: {stack_outputs['region']}") + click.echo("ssm_ping_status:") + for instance_id, ping_status in status["ssm_ping_status"].items(): + click.echo(f" {instance_id}: {ping_status}") + + +def build_port_forward_argv( + *, + master_instance_id: str, + region: str, + profile: str | None, + local_port: int, +) -> list[str]: + """Build argv for `aws ssm start-session` port-forward (no shell).""" + command = [ + "aws", + ] + if profile: + command.extend(["--profile", profile]) + command.extend( + [ + "ssm", + "start-session", + "--target", + master_instance_id, + "--region", + region, + "--document-name", + "AWS-StartPortForwardingSession", + "--parameters", + f"localPortNumber={local_port},portNumber=8089", + ] + ) + return command + + +def run_port_forward(config: UiWorkflowConfig, *, exec_forward: bool) -> str: + """Print or run the SSM port-forward command for the Locust web UI.""" + try: + outputs = load_stack_outputs( + config.resolved_stack, + config.app_config.pulumi_project_dir, + ) + except StackOutputError as exc: + raise UiWorkflowError(str(exc)) from exc + + resolved_region = config.resolved_region or str(outputs.get("region", "us-east-1")) + master_instance_id = str(outputs["master_instance_id"]) + forward_command = build_ui_port_forward_command( + master_instance_id=master_instance_id, + region=resolved_region, + profile=config.resolved_profile, + local_port=config.local_port, + ) + + if exec_forward: + argv = build_port_forward_argv( + master_instance_id=master_instance_id, + region=resolved_region, + profile=config.resolved_profile, + local_port=config.local_port, + ) + click.echo( + f"==> Port-forwarding Locust UI to http://localhost:{config.local_port}" + ) + subprocess.run(argv, check=True) + + return forward_command diff --git a/packages/python/ess-load-test/tests/test_auth_token.py b/packages/python/ess-load-test/tests/test_auth_token.py new file mode 100644 index 0000000..8b74b07 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_auth_token.py @@ -0,0 +1,80 @@ +"""Tests for OAuth token helpers.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest +from ess_load_test.app_config import OAuthSettings +from ess_load_test.auth.oauth import ( + OAuthCredentialsError, + fetch_bearer_token, + format_auth_header, + resolve_oauth_credentials, +) + +_OAUTH = OAuthSettings( + default_token_url="https://idp.example.com/oauth2/default/v1/token", + client_id_fallback_envs=("APP_SSO_CLIENT_ID",), + client_secret_fallback_envs=("APP_SSO_CLIENT_SECRET",), + token_url_fallback_envs=("APP_SSO_TOKEN_URL",), +) + + +def test_format_auth_header_adds_bearer_prefix() -> None: + assert format_auth_header("abc123") == "Authorization: Bearer abc123" + + +def test_format_auth_header_preserves_full_header() -> None: + assert format_auth_header("X-API-KEY: secret") == "X-API-KEY: secret" + + +def test_resolve_oauth_credentials_prefers_load_test_env() -> None: + env = { + "LOAD_TEST_OAUTH_CLIENT_ID": "load-id", + "LOAD_TEST_OAUTH_CLIENT_SECRET": "load-secret", + "APP_SSO_CLIENT_ID": "app-id", + "APP_SSO_CLIENT_SECRET": "app-secret", + "LOAD_TEST_OAUTH_TOKEN_URL": "https://idp.example.com/oauth2/v1/token", + } + with patch.dict(os.environ, env, clear=True): + client_id, client_secret, token_url = resolve_oauth_credentials(_OAUTH) + assert client_id == "load-id" + assert client_secret == "load-secret" + assert token_url == "https://idp.example.com/oauth2/v1/token" + + +def test_resolve_oauth_credentials_missing_raises() -> None: + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(OAuthCredentialsError): + resolve_oauth_credentials(_OAUTH) + + +def test_resolve_oauth_credentials_uses_default_token_url() -> None: + env = {"LOAD_TEST_OAUTH_CLIENT_ID": "id", "LOAD_TEST_OAUTH_CLIENT_SECRET": "s"} + with patch.dict(os.environ, env, clear=True): + _, _, token_url = resolve_oauth_credentials(_OAUTH) + assert token_url == "https://idp.example.com/oauth2/default/v1/token" + + +def test_resolve_oauth_credentials_requires_a_token_url() -> None: + env = {"LOAD_TEST_OAUTH_CLIENT_ID": "id", "LOAD_TEST_OAUTH_CLIENT_SECRET": "s"} + with patch.dict(os.environ, env, clear=True): + with pytest.raises(OAuthCredentialsError, match="LOAD_TEST_OAUTH_TOKEN_URL"): + resolve_oauth_credentials(OAuthSettings()) + + +@patch("ess_load_test.auth.oauth.TokenHelper") +def test_fetch_bearer_token_returns_access_token(mock_helper_cls: MagicMock) -> None: + mock_helper = MagicMock() + mock_helper.fetch_token.return_value = "token-value" + mock_helper_cls.return_value = mock_helper + + env = { + "LOAD_TEST_OAUTH_CLIENT_ID": "id", + "LOAD_TEST_OAUTH_CLIENT_SECRET": "secret", + } + with patch.dict(os.environ, env, clear=True): + assert fetch_bearer_token(_OAUTH) == "token-value" + + mock_helper_cls.assert_called_once() + mock_helper.fetch_token.assert_called_once() diff --git a/packages/python/ess-load-test/tests/test_browser_auth.py b/packages/python/ess-load-test/tests/test_browser_auth.py new file mode 100644 index 0000000..d7dbc98 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_browser_auth.py @@ -0,0 +1,177 @@ +"""Tests for browser-based SSO auth.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from ess_load_test.auth.browser import ( + BrowserAuthError, + _authorization_from_request, + _cookies_to_header, + _has_auth_session, + fetch_auth_header_via_browser, +) + +_PROFILE_DIR = Path.home() / ".ess-load-test-test-profile" + + +def test_authorization_from_request_matches_target_host() -> None: + request = MagicMock() + request.url = "https://app.example.com/api/chat" + request.headers = {"authorization": "Bearer user-token"} + + header = _authorization_from_request(request, "app.example.com") + assert header == "Authorization: Bearer user-token" + + +def test_authorization_from_request_ignores_other_hosts() -> None: + request = MagicMock() + request.url = "https://other.example.com/api/chat" + request.headers = {"authorization": "Bearer user-token"} + + assert _authorization_from_request(request, "app.example.com") is None + + +def test_authorization_from_request_ignores_parent_domain() -> None: + request = MagicMock() + request.url = "https://example.com/api/chat" + request.headers = {"authorization": "Bearer user-token"} + + assert _authorization_from_request(request, "app.example.com") is None + + +def test_cookies_to_header_formats_cookie_header() -> None: + page = MagicMock() + page.context.cookies.return_value = [ + {"name": "session", "value": "abc"}, + {"name": "csrf", "value": "xyz"}, + ] + + header = _cookies_to_header(page, "https://app.example.com") + assert header == "Cookie: session=abc; csrf=xyz" + + +def test_has_auth_session_detects_nextauth_cookie() -> None: + page = MagicMock() + page.context.cookies.return_value = [ + {"name": "__Secure-next-auth.session-token", "value": "token"}, + ] + + assert _has_auth_session(page, "https://app.example.com") is True + + +def test_has_auth_session_ignores_unrelated_cookies() -> None: + page = MagicMock() + page.context.cookies.return_value = [{"name": "analytics", "value": "x"}] + + assert _has_auth_session(page, "https://app.example.com") is False + + +@patch("ess_load_test.auth.browser.BrowserSession") +def test_fetch_auth_header_via_browser_prefers_captured_bearer( + mock_session_cls: MagicMock, +) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.url = "https://app.example.com/" + mock_page.context.cookies.return_value = [] + + with patch("ess_load_test.auth.browser._wait_for_target_session") as wait_mock: + + def mark_captured( + _page, _url, *, headed, captured_headers, extra_auth_domains + ) -> None: + captured_headers.append("Authorization: Bearer captured") + + wait_mock.side_effect = mark_captured + + header = fetch_auth_header_via_browser( + "https://app.example.com", + profile_dir=_PROFILE_DIR, + headed=True, + ) + + assert header == "Authorization: Bearer captured" + mock_session_cls.assert_called_once() + launch_kwargs = mock_session_cls.call_args.kwargs + assert launch_kwargs["headed"] is True + + +@patch("ess_load_test.auth.browser.BrowserSession") +def test_fetch_auth_header_via_browser_prefers_captured_bearer_from_requests( + mock_session_cls: MagicMock, +) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.url = "https://app.example.com/" + mock_page.context.cookies.return_value = [] + + def register_request(handler) -> None: + request = MagicMock() + request.url = "https://app.example.com/api/health" + request.headers = {"authorization": "Bearer captured"} + handler(request) + + mock_page.on.side_effect = lambda _event, handler: register_request(handler) + + with patch("ess_load_test.auth.browser._wait_for_target_session"): + header = fetch_auth_header_via_browser( + "https://app.example.com", + profile_dir=_PROFILE_DIR, + headed=True, + ) + + assert header == "Authorization: Bearer captured" + + +@patch("ess_load_test.auth.browser.BrowserSession") +def test_fetch_auth_header_via_browser_falls_back_to_cookies( + mock_session_cls: MagicMock, +) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.context.cookies.return_value = [{"name": "session", "value": "abc"}] + + with patch("ess_load_test.auth.browser._wait_for_target_session"): + header = fetch_auth_header_via_browser( + "https://app.example.com", + profile_dir=_PROFILE_DIR, + headed=True, + ) + + assert header == "Cookie: session=abc" + + +@patch("ess_load_test.auth.browser.time.sleep") +@patch("ess_load_test.auth.browser.BrowserSession") +def test_extra_auth_domains_mark_the_login_page_as_sso( + mock_session_cls: MagicMock, + _sleep: MagicMock, +) -> None: + mock_session = MagicMock() + mock_session_cls.return_value.__enter__.return_value = mock_session + mock_page = MagicMock() + mock_session.new_page.return_value = mock_page + mock_page.url = "https://login.corp.example.net/authorize" + mock_session_cls.is_auth_redirect.side_effect = lambda *args, **kwargs: ( + "login.corp.example.net" in kwargs["extra_auth_domains"] + ) + + with pytest.raises(BrowserAuthError, match="headless"): + fetch_auth_header_via_browser( + "https://app.example.com", + profile_dir=_PROFILE_DIR, + headed=False, + extra_auth_domains=("login.corp.example.net",), + ) + + +def test_fetch_auth_header_via_browser_requires_target_url() -> None: + with pytest.raises(BrowserAuthError, match="Target URL is required"): + fetch_auth_header_via_browser(" ", profile_dir=_PROFILE_DIR) diff --git a/packages/python/ess-load-test/tests/test_config.py b/packages/python/ess-load-test/tests/test_config.py new file mode 100644 index 0000000..5facb1b --- /dev/null +++ b/packages/python/ess-load-test/tests/test_config.py @@ -0,0 +1,18 @@ +"""Unit tests for config loading.""" + +from ess_load_test.infra.config import _DEFAULT_WORKER_COUNT, LoadTestInfraConfig + + +class TestLoadTestInfraConfig: + def test_defaults(self): + config = LoadTestInfraConfig( + environment="dev", + instance_type="t3.large", + master_instance_type="t3.small", + worker_count=_DEFAULT_WORKER_COUNT, + use_default_vpc=True, + vpc_id_override=None, + subnet_ids_override=(), + ) + assert config.worker_count == _DEFAULT_WORKER_COUNT + assert config.use_default_vpc is True diff --git a/packages/python/ess-load-test/tests/test_instances.py b/packages/python/ess-load-test/tests/test_instances.py new file mode 100644 index 0000000..fb5e195 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_instances.py @@ -0,0 +1,58 @@ +"""Unit tests for EC2 instance provisioning helpers.""" + +from ess_load_test.app_config import default_app_config +from ess_load_test.infra.constants import get_tags +from ess_load_test.infra.instances import ( + EC2_USER_DATA_MAX_BYTES, + _build_user_data, + controller_hostname, + worker_hostname, +) + + +def test_controller_hostname() -> None: + assert controller_hostname("load-test-team-dev") == ( + "load-test-team-dev-locust-controller" + ) + + +def test_worker_hostname() -> None: + assert worker_hostname("load-test-team-dev", 0) == ( + "load-test-team-dev-locust-worker-0" + ) + + +def test_build_user_data_sets_hostname() -> None: + app_config = default_app_config() + hostname = "load-test-dev-locust-controller" + user_data = _build_user_data( + hostname, + locust_install_dir=app_config.locust_install_dir, + cloud_init_files=app_config.locust_bundle.sync_files, + ) + assert f"hostname: {hostname}" in user_data + assert f"hostnamectl set-hostname {hostname}" in user_data + assert "write_files:" not in user_data + assert len(user_data.encode()) <= EC2_USER_DATA_MAX_BYTES + + +def test_get_tags_includes_managed_by_and_environment() -> None: + tags = get_tags( + "ess-load-test", + "team-dev", + {"Name": "example", "Role": "locust-worker"}, + ) + assert tags["ManagedBy"] == "pulumi" + assert tags["Environment"] == "team-dev" + assert tags["Name"] == "example" + assert tags["Role"] == "locust-worker" + + +def test_get_tags_project_defaults_to_component() -> None: + assert get_tags("ess-load-test", "dev")["Project"] == "ess-load-test" + + +def test_get_tags_project_tag_overrides_component() -> None: + tags = get_tags("ess-load-test", "dev", project_tag="my-monorepo") + assert tags["Project"] == "my-monorepo" + assert tags["Component"] == "ess-load-test" diff --git a/packages/python/ess-load-test/tests/test_load_params.py b/packages/python/ess-load-test/tests/test_load_params.py new file mode 100644 index 0000000..0af92bc --- /dev/null +++ b/packages/python/ess-load-test/tests/test_load_params.py @@ -0,0 +1,172 @@ +"""Tests for shared Locust load-test parameters.""" + +from ess_load_test.app_config import default_app_config +from ess_load_test.cli.load_params import ( + LoadTestParams, + build_locust_load_flag_lines, + config_users_json, +) +from ess_load_test.cluster.runner import ( + LoadTestRunRequest, + LoadTestUiRequest, + WorkerRuntimeEnv, + _build_master_command, + _build_master_ui_command, + _build_worker_command, +) + + +def _shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def test_config_users_json_empty() -> None: + assert config_users_json(()) is None + + +def test_config_users_json_builds_array() -> None: + payload = config_users_json(("PagesUser", "BffUser")) + assert payload is not None + assert "PagesUser" in payload + assert "BffUser" in payload + + +def test_build_locust_load_flag_lines_headless() -> None: + params = LoadTestParams(users=50, spawn_rate=5.0, duration="5m") + lines = build_locust_load_flag_lines( + params, + headless=True, + shell_quote=_shell_quote, + ) + assert "-u 50" in lines + assert "-r 5.0" in lines + assert "-t '5m'" in lines + assert "--headless" in lines + assert "--autostart" not in lines + + +def test_build_locust_load_flag_lines_ui_autostart() -> None: + params = LoadTestParams(users=10, spawn_rate=2.0, autostart=True, autoquit=30) + lines = build_locust_load_flag_lines( + params, + headless=False, + shell_quote=_shell_quote, + ) + assert "--autostart" in lines + assert "--autoquit 30" in lines + assert "--headless" not in lines + + +def test_build_locust_load_flag_lines_omits_autoquit_without_autostart() -> None: + params = LoadTestParams(users=10, spawn_rate=2.0, autoquit=30) + lines = build_locust_load_flag_lines( + params, + headless=False, + shell_quote=_shell_quote, + ) + assert "--autoquit" not in lines + assert "--autostart" not in lines + + +def test_master_ui_command_includes_users_spawn_rate() -> None: + app_config = default_app_config() + request = LoadTestUiRequest( + stack="dev", + target_url="https://app.example.com", + paths="/", + scenario="mixed", + load_params=LoadTestParams(users=50, spawn_rate=5.0, duration="10m"), + auth_header=None, + profile="team-dev", + region="us-east-1", + ) + command = _build_master_ui_command( + request, + app_config, + master_private_ip="10.0.0.1", + worker_count=2, + ) + assert "-u 50" in command + assert "-r 5.0" in command + assert "-t '10m'" in command + assert "export LOAD_TEST_USERS=50" in command + assert "export LOAD_TEST_SPAWN_RATE=5.0" in command + assert "export LOAD_TEST_DURATION='10m'" in command + assert "export LOCUST_USERS=50" in command + assert "export LOCUST_SPAWN_RATE=5.0" in command + assert "export LOCUST_RUN_TIME='10m'" in command + assert "--class-picker" in command + assert "--autostart" not in command + + +def test_master_ui_command_config_users() -> None: + app_config = default_app_config() + request = LoadTestUiRequest( + stack="dev", + target_url="https://app.example.com", + paths="/", + scenario="mixed", + load_params=LoadTestParams( + user_classes=("PagesUser", "BffUser"), + ), + auth_header=None, + profile="team-dev", + region="us-east-1", + ) + command = _build_master_ui_command( + request, + app_config, + master_private_ip="10.0.0.1", + worker_count=2, + ) + assert "--config-users" in command + assert "PagesUser" in command + assert "BffUser" in command + + +def test_worker_command_exports_scenario_and_auth() -> None: + app_config = default_app_config() + runtime = WorkerRuntimeEnv( + scenario="mixed", + paths="/healthz", + auth_header="Bearer example-token", + ) + command = _build_worker_command(app_config, "10.0.0.1", runtime) + assert "export LOAD_TEST_SCENARIO='mixed'" in command + assert "export LOAD_TEST_PATHS='/healthz'" in command + assert "export LOAD_TEST_AUTH_HEADER='Bearer example-token'" in command + assert "--worker" in command + + +def test_master_command_headless_exports() -> None: + app_config = default_app_config() + _remote_csv_prefix = "/opt/my-load-test/stats" + _remote_html_path = "/opt/my-load-test/report.html" + + request = LoadTestRunRequest( + stack="dev", + target_url="https://app.example.com", + paths="/", + scenario="default", + load_params=LoadTestParams( + users=10, + spawn_rate=2.0, + duration="1m", + csv_prefix=_remote_csv_prefix, + html_path=_remote_html_path, + reset_stats=True, + ), + auth_header=None, + profile="team-dev", + region="us-east-1", + ) + command = _build_master_command( + request, + app_config, + master_private_ip="10.0.0.1", + worker_count=2, + ) + assert "--headless" in command + assert f"--csv '{_remote_csv_prefix}'" in command + assert f"--html '{_remote_html_path}'" in command + assert "--reset-stats" in command diff --git a/packages/python/ess-load-test/tests/test_local_port.py b/packages/python/ess-load-test/tests/test_local_port.py new file mode 100644 index 0000000..0c9ebdd --- /dev/null +++ b/packages/python/ess-load-test/tests/test_local_port.py @@ -0,0 +1,67 @@ +"""Tests for local port-forward troubleshooting helpers.""" + +from unittest.mock import MagicMock, patch + +import pytest +from ess_load_test.ui.local_port import ( + LocalPortExpectation, + check_local_port, + cleanup_stale_port_forward, + is_local_port_listening, + kill_stale_ssm_port_forward, +) + + +@patch("ess_load_test.ui.local_port.subprocess.run") +def test_is_local_port_listening_true_when_lsof_succeeds(mock_run) -> None: + mock_run.return_value.returncode = 0 + assert is_local_port_listening(8089) is True + mock_run.assert_called_once() + assert mock_run.call_args.args[0][:2] == ["lsof", "-i"] + + +@patch("ess_load_test.ui.local_port.subprocess.run") +def test_is_local_port_listening_false_when_lsof_fails(mock_run) -> None: + mock_run.return_value.returncode = 1 + assert is_local_port_listening(8089) is False + + +@patch("ess_load_test.ui.local_port.subprocess.run") +def test_kill_stale_ssm_port_forward_counts_matches(mock_run) -> None: + mock_run.side_effect = [ + MagicMock(returncode=0), + MagicMock(returncode=1), + ] + assert kill_stale_ssm_port_forward(8089) == 1 + pkill_pattern_count = 2 + assert mock_run.call_count == pkill_pattern_count + + +@patch("ess_load_test.ui.local_port.is_local_port_listening", return_value=False) +def test_check_local_port_free_succeeds_when_port_free(_mock_listening) -> None: + check_local_port(8089, expect=LocalPortExpectation.FREE) + + +@patch("ess_load_test.ui.local_port.is_local_port_listening", return_value=True) +def test_check_local_port_free_fails_when_port_in_use(_mock_listening) -> None: + with pytest.raises(RuntimeError, match="expected free"): + check_local_port(8089, expect=LocalPortExpectation.FREE) + + +@patch("ess_load_test.ui.local_port.is_local_port_listening", return_value=True) +def test_check_local_port_listening_succeeds_when_port_in_use(_mock_listening) -> None: + check_local_port(8089, expect=LocalPortExpectation.LISTENING) + + +@patch("ess_load_test.ui.local_port.is_local_port_listening", return_value=False) +def test_check_local_port_listening_fails_when_port_free(_mock_listening) -> None: + with pytest.raises(RuntimeError, match="expected listening"): + check_local_port(8089, expect=LocalPortExpectation.LISTENING) + + +@patch("ess_load_test.ui.local_port.kill_stale_ssm_port_forward", return_value=2) +@patch("ess_load_test.ui.local_port.is_local_port_listening", return_value=False) +def test_cleanup_stale_port_forward(_mock_listening, mock_kill) -> None: + killed_pattern_count = 2 + assert cleanup_stale_port_forward(8089) == killed_pattern_count + mock_kill.assert_called_once_with(8089) diff --git a/packages/python/ess-load-test/tests/test_locust_scenarios.py b/packages/python/ess-load-test/tests/test_locust_scenarios.py new file mode 100644 index 0000000..1114fa6 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_locust_scenarios.py @@ -0,0 +1,65 @@ +"""Tests for Locust scenario runtime helpers.""" + +import pytest +from ess_load_test.locust.runtime import ( + InvalidScenarioError, + mark_response_failure, + normalize_scenario, +) + + +class TestNormalizeScenario: + def test_default_when_empty(self) -> None: + assert normalize_scenario(None) == "default" + assert normalize_scenario(" ") == "default" + + def test_accepts_known_scenarios(self) -> None: + scenarios = frozenset({"default", "pages"}) + assert normalize_scenario("pages", valid_scenarios=scenarios) == "pages" + + def test_rejects_unknown_scenario(self) -> None: + with pytest.raises(InvalidScenarioError, match="agent"): + normalize_scenario("agent") + + +class TestMarkResponseFailure: + def test_5xx_always_fails(self) -> None: + response = _FakeResponse(503) + mark_response_failure(response, strict=False) + assert response.failed is True + assert response.succeeded is False + + def test_4xx_strict_only(self) -> None: + response = _FakeResponse(401) + mark_response_failure(response, strict=False) + assert response.failed is False + assert response.succeeded is False + mark_response_failure(response, strict=True) + assert response.failed is True + + def test_accept_statuses_marks_success(self) -> None: + response = _FakeResponse(400) + mark_response_failure(response, accept_statuses=frozenset({400, 429})) + assert response.failed is False + assert response.succeeded is True + + def test_accept_statuses_does_not_override_5xx(self) -> None: + response = _FakeResponse(503) + mark_response_failure(response, accept_statuses=frozenset({503})) + assert response.failed is True + assert response.succeeded is False + + +class _FakeResponse: + def __init__(self, status_code: int) -> None: + self.status_code = status_code + self.failed = False + self.succeeded = False + self.message = "" + + def failure(self, message: str) -> None: + self.failed = True + self.message = message + + def success(self) -> None: + self.succeeded = True diff --git a/packages/python/ess-load-test/tests/test_locustfile_env.py b/packages/python/ess-load-test/tests/test_locustfile_env.py new file mode 100644 index 0000000..e5e99d0 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_locustfile_env.py @@ -0,0 +1,143 @@ +"""Unit tests for locust environment helpers.""" + +from ess_load_test.locust.runtime import ( + apply_auth_header, + apply_auth_to_client, + default_profile_for_scenario, + is_auth_redirect, + mark_response_failure, + normalize_locust_host, + parse_load_test_paths, + read_auth_header, + read_load_test_duration, + read_load_test_spawn_rate, + read_load_test_users, +) + + +class TestParseLoadTestPaths: + def test_default_path(self): + assert parse_load_test_paths(None) == ("/",) + + def test_multiple_paths(self): + assert parse_load_test_paths("/healthz, /api/health") == ( + "/healthz", + "/api/health", + ) + + def test_empty_string_uses_default(self): + assert parse_load_test_paths(" , ") == ("/",) + + +class TestNormalizeLocustHost: + def test_strips_trailing_slash(self) -> None: + url = "https://app.example.com/" + assert normalize_locust_host(url) == "https://app.example.com" + + def test_preserves_bare_host(self) -> None: + url = "https://app.example.com" + assert normalize_locust_host(url) == url + + +class TestApplyAuthHeader: + def test_bearer_value(self): + headers: dict[str, str] = {} + apply_auth_header(headers, "Bearer example-token") + assert headers["Authorization"] == "Bearer example-token" + + def test_named_header(self): + headers: dict[str, str] = {} + apply_auth_header(headers, "X-API-KEY: secret") + assert headers["X-API-KEY"] == "secret" + + def test_none_is_noop(self): + headers = {"Existing": "1"} + apply_auth_header(headers, None) + assert headers == {"Existing": "1"} + + +class TestApplyAuthToClient: + def test_cookie_header_populates_cookie_jar(self): + class Cookies: + def __init__(self) -> None: + self.values: dict[str, str] = {} + + def clear(self) -> None: + self.values.clear() + + def set(self, name: str, value: str) -> None: + self.values[name] = value + + class Client: + def __init__(self) -> None: + self.headers: dict[str, str] = {} + self.cookies = Cookies() + + client = Client() + apply_auth_to_client( + client, + "Cookie: session=abc123; other=xyz", + ) + assert client.headers["Cookie"] == "session=abc123; other=xyz" + assert client.cookies.values == { + "session": "abc123", + "other": "xyz", + } + + +class TestReadAuthHeader: + def test_prefers_synced_file(self, tmp_path, monkeypatch) -> None: + auth_file = tmp_path / "auth.txt" + auth_file.write_text("Cookie: session=from-file", encoding="utf-8") + monkeypatch.setenv("LOAD_TEST_AUTH_HEADER_FILE", str(auth_file)) + monkeypatch.setenv("LOAD_TEST_AUTH_HEADER", "Cookie: session=from-env") + assert read_auth_header() == "Cookie: session=from-file" + + +class TestIsAuthRedirect: + def test_redirect_status_codes(self) -> None: + assert is_auth_redirect(307) is True + assert is_auth_redirect(200) is False + + +class TestMarkResponseFailureEnv: + def test_ok_status_not_marked(self): + class Response: + status_code = 200 + + def failure(self, _message: str) -> None: + raise AssertionError("should not fail") + + mark_response_failure(Response(), strict=True) + + +class TestDefaultProfileForScenario: + def test_mixed_scenario(self) -> None: + assert default_profile_for_scenario("mixed") == "mixed-run" + + def test_pages_scenario(self) -> None: + assert default_profile_for_scenario("pages") == "pages-run" + + def test_default_scenario(self) -> None: + assert default_profile_for_scenario("default") == "smoke" + + +class TestLoadTestEnvReaders: + _expected_users = 50 + _expected_spawn_rate = 5.0 + + def test_read_load_test_users(self, monkeypatch) -> None: + monkeypatch.delenv("LOAD_TEST_USERS", raising=False) + monkeypatch.delenv("LOCUST_USERS", raising=False) + assert read_load_test_users() is None + + monkeypatch.setenv("LOAD_TEST_USERS", str(self._expected_users)) + assert read_load_test_users() == self._expected_users + + def test_read_load_test_spawn_rate(self, monkeypatch) -> None: + monkeypatch.setenv("LOAD_TEST_SPAWN_RATE", str(int(self._expected_spawn_rate))) + assert read_load_test_spawn_rate() == self._expected_spawn_rate + + def test_read_load_test_duration(self, monkeypatch) -> None: + monkeypatch.setenv("LOAD_TEST_DURATION", "10m") + assert read_load_test_duration() == "10m" diff --git a/packages/python/ess-load-test/tests/test_runner.py b/packages/python/ess-load-test/tests/test_runner.py new file mode 100644 index 0000000..fc92a94 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_runner.py @@ -0,0 +1,66 @@ +"""Tests for SSM runner helpers.""" + +from ess_load_test.app_config import default_app_config +from ess_load_test.cluster.runner import ( + _LOCUST_MASTER_PORT, + _LOCUST_STATS_PORT, + _WEB_UI_PORT, + _auth_file_bootstrap_lines, + _base64_write_file_command, + _bootstrap_locust_script, + _operator_scenario_env_exports, + _reset_locust_cluster_script, + _wrap_bash_script, +) + + +def test_wrap_bash_script_runs_under_bash() -> None: + wrapped = _wrap_bash_script("set -euo pipefail\necho ok") + assert wrapped.startswith("bash -c '") + assert "pipefail" in wrapped + + +def test_reset_cluster_script_stops_locust_and_frees_ports() -> None: + script = _reset_locust_cluster_script() + assert "pkill -f '[/]locust'" in script + assert f"fuser -k {_LOCUST_MASTER_PORT}/tcp" in script + assert f"fuser -k {_LOCUST_STATS_PORT}/tcp" in script + assert f"fuser -k {_WEB_UI_PORT}/tcp" in script + + +def test_base64_write_file_command_uses_shell_safe_decode() -> None: + command = _base64_write_file_command("/opt/locustfile.py", "YWJj") + assert "base64 -d" in command + assert "/opt/locustfile.py" in command + assert "python3 -c" not in command + + +def test_bootstrap_script_uses_install_dir() -> None: + install_dir = default_app_config().locust_install_dir + script = _bootstrap_locust_script(install_dir) + assert install_dir in script + + +def test_auth_file_bootstrap_lines_uses_mktemp() -> None: + lines = _auth_file_bootstrap_lines("Cookie: session=abc") + assert "_auth_file=$(mktemp)" in lines + assert 'base64 -d > "$_auth_file"' in lines + assert 'export LOAD_TEST_AUTH_HEADER_FILE="$_auth_file"' in lines + + +def test_auth_file_bootstrap_lines_empty_when_no_header() -> None: + assert _auth_file_bootstrap_lines(None) == "" + assert _auth_file_bootstrap_lines("") == "" + + +def test_operator_scenario_env_exports_forwards_chat_tuning(monkeypatch) -> None: + monkeypatch.delenv("LOAD_TEST_THINK_TIME", raising=False) + assert _operator_scenario_env_exports() == "" + + monkeypatch.setenv("LOAD_TEST_THINK_TIME", "15,30") + monkeypatch.setenv("LOAD_TEST_PRODUCT_SLUG", "__all__") + monkeypatch.setenv("LOAD_TEST_AWS_PROFILE", "team-dev") + exports = _operator_scenario_env_exports() + assert "export LOAD_TEST_THINK_TIME='15,30'" in exports + assert "export LOAD_TEST_PRODUCT_SLUG='__all__'" in exports + assert "LOAD_TEST_AWS_PROFILE" not in exports diff --git a/packages/python/ess-load-test/tests/test_security_group_rules.py b/packages/python/ess-load-test/tests/test_security_group_rules.py new file mode 100644 index 0000000..e7bca09 --- /dev/null +++ b/packages/python/ess-load-test/tests/test_security_group_rules.py @@ -0,0 +1,71 @@ +"""Unit tests for Locust security group rules.""" + +import pulumi_aws as aws +from ess_load_test.infra.network import ( + build_locust_security_group_egress, + build_locust_security_group_ingress, + has_public_internet_ingress, +) + +_LOCUST_MASTER_PORT = 5557 +_LOCUST_STATS_PORT = 5558 +_HTTP_PORT = 80 +_HTTPS_PORT = 443 +_DNS_PORT = 53 + + +class TestSecurityGroupRules: + def test_ingress_has_no_public_cidr(self): + ingress_rules = build_locust_security_group_ingress() + assert has_public_internet_ingress(ingress_rules) is False + + def test_ingress_allows_locust_coordination_ports(self): + ingress_rules = build_locust_security_group_ingress() + assert len(ingress_rules) == 1 + rule = ingress_rules[0] + assert rule.from_port == _LOCUST_MASTER_PORT + assert rule.to_port == _LOCUST_STATS_PORT + assert rule.self is True + + def test_egress_allows_http_https_and_dns(self): + egress_rules = build_locust_security_group_egress() + assert any( + rule.protocol == "tcp" and rule.from_port == _HTTP_PORT + for rule in egress_rules + ) + assert any( + rule.protocol == "tcp" and rule.from_port == _HTTPS_PORT + for rule in egress_rules + ) + assert any( + rule.protocol == "udp" and rule.from_port == _DNS_PORT + for rule in egress_rules + ) + + def test_egress_allows_locust_coordination_ports(self): + egress_rules = build_locust_security_group_egress() + assert any( + rule.protocol == "tcp" + and rule.from_port == _LOCUST_MASTER_PORT + and rule.to_port == _LOCUST_STATS_PORT + and rule.self is True + for rule in egress_rules + ) + + def test_detects_public_ipv4_ingress(self): + public_rule = aws.ec2.SecurityGroupIngressArgs( + protocol="tcp", + from_port=22, + to_port=22, + cidr_blocks=["0.0.0.0/0"], + ) + assert has_public_internet_ingress([public_rule]) is True + + def test_detects_public_ipv6_ingress(self): + public_rule = aws.ec2.SecurityGroupIngressArgs( + protocol="tcp", + from_port=22, + to_port=22, + ipv6_cidr_blocks=["::/0"], + ) + assert has_public_internet_ingress([public_rule]) is True diff --git a/packages/python/ess-load-test/tests/test_ui_workflow.py b/packages/python/ess-load-test/tests/test_ui_workflow.py new file mode 100644 index 0000000..18fa09c --- /dev/null +++ b/packages/python/ess-load-test/tests/test_ui_workflow.py @@ -0,0 +1,78 @@ +"""Tests for Locust UI workflow helpers.""" + +from unittest.mock import MagicMock, patch + +import pytest +from ess_load_test.app_config import default_app_config +from ess_load_test.ui.workflow import ( + UiWorkflowConfig, + UiWorkflowError, + build_port_forward_argv, + resolve_auth_header, + run_preflight, +) + + +def test_resolved_region_defers_to_stack_output_when_unset() -> None: + config = UiWorkflowConfig( + app_config=default_app_config(), + target_url="https://app.example.com", + ) + assert config.resolved_region is None + + +def test_build_port_forward_argv_includes_profile() -> None: + argv = build_port_forward_argv( + master_instance_id="i-abc", + region="us-east-1", + profile="team-dev", + local_port=8089, + ) + assert argv[:3] == ["aws", "--profile", "team-dev"] + assert "i-abc" in argv + assert "localPortNumber=8089,portNumber=8089" in argv + + +def test_resolve_auth_header_prefers_explicit() -> None: + resolve_token = MagicMock() + header = resolve_auth_header( + "X-API-KEY: secret", fetch_token=True, resolve_token=resolve_token + ) + assert header == "X-API-KEY: secret" + resolve_token.assert_not_called() + + +def test_resolve_auth_header_skips_fetch_when_disabled() -> None: + resolve_token = MagicMock() + assert ( + resolve_auth_header(None, fetch_token=False, resolve_token=resolve_token) + is None + ) + resolve_token.assert_not_called() + + +def test_run_preflight_requires_target_url() -> None: + config = UiWorkflowConfig( + app_config=default_app_config(), + target_url=" ", + ) + with pytest.raises(UiWorkflowError, match="Target URL"): + run_preflight(config) + + +@patch("ess_load_test.ui.workflow.boto3.Session") +def test_run_preflight_checks_aws_credentials(mock_session_cls: MagicMock) -> None: + identity = mock_session_cls.return_value.client.return_value.get_caller_identity + identity.return_value = { + "Account": "123", + } + config = UiWorkflowConfig( + app_config=default_app_config(), + target_url="https://app.example.com", + profile="team-dev", + region="us-east-1", + ) + run_preflight(config) + mock_session_cls.assert_called_once_with( + profile_name="team-dev", region_name="us-east-1" + ) diff --git a/packages/python/ess-outlook/.env.example b/packages/python/ess-outlook/.env.example index 03976e7..3a2066e 100644 --- a/packages/python/ess-outlook/.env.example +++ b/packages/python/ess-outlook/.env.example @@ -1,5 +1,17 @@ -# Microsoft Entra (Azure AD) app registration +# Microsoft Entra (Azure AD) app registration. +# Both are REQUIRED unless GRAPH_ACCESS_TOKEN below is set -- there is no +# built-in default client ID. # CLIENT_ID: "Office Desktop Apps" pre-approved on M365 tenant, or your own app registration CLIENT_ID= # TENANT_ID: your organization's Azure AD tenant ID TENANT_ID= + +# Optional: bypass the device-code flow with a token pasted from Graph Explorer +# (https://developer.microsoft.com/graph/graph-explorer). Expires in ~1 hour. +# When set, CLIENT_ID and TENANT_ID are not needed. +# GRAPH_ACCESS_TOKEN= + +# Optional: prefer browser sign-in over the device-code flow. Requires CLIENT_ID +# to be an app registration listing "http://localhost" as a redirect URI, +# otherwise sign-in fails with AADSTS50011. +# GRAPH_INTERACTIVE_AUTH=1 diff --git a/packages/python/ess-outlook/README.md b/packages/python/ess-outlook/README.md index 8ca81c0..d18e5bd 100644 --- a/packages/python/ess-outlook/README.md +++ b/packages/python/ess-outlook/README.md @@ -30,13 +30,36 @@ Then run `uv sync --all-packages` from the workspace root. ## Prerequisites -- `CLIENT_ID` and `TENANT_ID` set in the environment (see `.env.example`) - - `CLIENT_ID` -- the application (client) ID from a Microsoft Entra - (Azure AD) app registration with delegated `Mail.ReadWrite` - permission. The well-known "Office Desktop Apps" public client ID - works on most Microsoft 365 tenants without any registration. - - `TENANT_ID` -- your organization's Microsoft Entra tenant ID. - Python 3.12+ +- Credentials for one of the two authentication paths in + [Configuration](#configuration) + +## Configuration + +Set these in the environment or in this package's `.env` file (see +`.env.example`). The CLI loads that `.env` on startup from any working +directory, so no `source .env` is needed. Real environment variables take +precedence over the file. + +Boolean variables accept `1`, `true`, `yes`, `y`, `on`, `t` (and their +negatives), case-insensitively, via [python-decouple's][decouple] `strtobool`. +An unrecognized value raises `ValueError` rather than being treated as off, so +typos surface immediately. + +| Variable | Required | Description | +| ---- | ---- | ----- | +| `CLIENT_ID` | Yes, unless `GRAPH_ACCESS_TOKEN` is set | Application (client) ID of a Microsoft Entra app registration with delegated `Mail.ReadWrite`. | +| `TENANT_ID` | Yes, unless `GRAPH_ACCESS_TOKEN` is set | Your organization's Microsoft Entra tenant ID. | +| `GRAPH_ACCESS_TOKEN` | No | Pre-issued access token. Bypasses MSAL, so `CLIENT_ID` and `TENANT_ID` are then unused. See [Bring your own token](#bring-your-own-token). | +| `GRAPH_INTERACTIVE_AUTH` | No | Set to `1` for browser sign-in instead of a device code. See [Browser sign-in](#browser-sign-in). | + +There is no built-in default client ID. If neither `CLIENT_ID`/`TENANT_ID` +nor `GRAPH_ACCESS_TOKEN` is set, authentication raises `GraphAuthError`. + +The well-known "Office Desktop Apps" public client works on many Microsoft +365 tenants for the device-code flow without a dedicated registration, but +it has no `http://localhost` redirect URI, so it cannot be used with +`GRAPH_INTERACTIVE_AUTH`. ## Usage @@ -84,8 +107,40 @@ If your tenant blocks device-code flow via Conditional Access, run the command directly in your terminal session rather than over a remote shell. +### Browser sign-in + +Set `GRAPH_INTERACTIVE_AUTH=1` to sign in through the system browser +(authorization code + PKCE on a loopback redirect) instead of entering a +device code. Conditional Access policies often permit this where they +block device code. + +This requires `CLIENT_ID` to name an app registration that lists +`http://localhost` as a redirect URI; without one, sign-in fails with +`AADSTS50011`. Well-known first-party client IDs generally do not +qualify, so this is off by default. On success, MSAL caches a refresh token; +later runs renew silently. + +### Bring your own token + +Set `GRAPH_ACCESS_TOKEN` to skip MSAL entirely. `CLIENT_ID` and +`TENANT_ID` are then unnecessary: + +```bash +GRAPH_ACCESS_TOKEN="$(pbpaste)" ess-outlook list --limit 10 +``` + +Get a token from [Graph Explorer](https://developer.microsoft.com/graph/graph-explorer): +sign in, open the **Access token** tab, and copy the value. Consent to +`Mail.Read` (or `Mail.ReadWrite` for `mark-read` and `delete`) under +**Modify permissions** first, or requests return 403. + +Graph Explorer tokens expire after about an hour and are not cached, so +this suits one-off reads rather than unattended jobs. + ## Running Tests ```bash uv run pytest packages/python/ess-outlook/ ``` + +[decouple]: https://github.com/HBNetwork/python-decouple diff --git a/packages/python/ess-outlook/pyproject.toml b/packages/python/ess-outlook/pyproject.toml index 6a39385..74dee91 100644 --- a/packages/python/ess-outlook/pyproject.toml +++ b/packages/python/ess-outlook/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "click>=8.1", "ess-dirs", "msal>=1.28.0", + "python-decouple>=3.8", "python-dotenv>=1.0.0", "requests>=2.31.0", ] diff --git a/packages/python/ess-outlook/src/ess_outlook/graph.py b/packages/python/ess-outlook/src/ess_outlook/graph.py index b57651b..d9ca991 100644 --- a/packages/python/ess-outlook/src/ess_outlook/graph.py +++ b/packages/python/ess-outlook/src/ess_outlook/graph.py @@ -2,21 +2,39 @@ from __future__ import annotations +import base64 +import binascii import json import logging import os +import time import webbrowser from datetime import datetime, timezone from http import HTTPStatus import msal import requests +from decouple import strtobool from ess_dirs import write_secure GRAPH_BASE = "https://graph.microsoft.com/v1.0/me" TOKEN_CACHE = os.path.expanduser("~/.cache/ess-outlook/graph_token_cache.json") SCOPES = ["Mail.ReadWrite"] +# Treat a token as spent this many seconds before its actual expiry, so a +# long-running command does not fail mid-flight. +_EXPIRY_BUFFER_SECONDS = 300 + +# Loopback port for the interactive flow. 0 lets MSAL pick any free port, +# which requires the app registration to list "http://localhost" as a redirect +# URI. Registrations without one fail with AADSTS50011, so the flow is opt-in +# via GRAPH_INTERACTIVE_AUTH rather than attempted by default. +_INTERACTIVE_REDIRECT_PORT = 0 + +# Cap the wait on the loopback redirect. Without this, a rejected redirect URI +# leaves the browser on an error page and MSAL listening forever. +_INTERACTIVE_TIMEOUT_SECONDS = 90 + logger = logging.getLogger(__name__) @@ -24,6 +42,27 @@ class GraphAuthError(Exception): """Raised when Graph API authentication fails.""" +def _cast_bool(value: str) -> bool: + """Coerce an environment value to a bool, tolerating stray whitespace. + + Uses decouple's vocabulary (``1``/``true``/``yes``/``y``/``on``/``t`` and + their negatives) and raises ``ValueError`` on anything else, so a typo + surfaces instead of silently leaving a flag off. + """ + value = value.strip() + return bool(strtobool(value)) if value else False + + +def _get_env(name: str) -> str: + """Read a stripped environment value. + + Reads the process environment only. Populating it from a ``.env`` file is + the entrypoint's job (``__main__.load_dotenv``), so importing this module + never picks up configuration the caller did not supply. + """ + return os.environ.get(name, "").strip() + + def _parse_date(value: str) -> datetime: """Parse an ISO-8601 date string into a timezone-aware datetime.""" if not value: @@ -36,8 +75,8 @@ def _parse_date(value: str) -> datetime: def _get_app() -> msal.PublicClientApplication: """Build an MSAL app using env vars for client/tenant.""" - client_id = os.environ.get("CLIENT_ID") - tenant_id = os.environ.get("TENANT_ID") + client_id = _get_env("CLIENT_ID") + tenant_id = _get_env("TENANT_ID") if not client_id or not tenant_id: msg = "CLIENT_ID and TENANT_ID must be set in environment or .env" raise GraphAuthError(msg) @@ -60,41 +99,146 @@ def _save_cache(app: msal.PublicClientApplication) -> None: write_secure(TOKEN_CACHE, cache.serialize()) -def get_token() -> str: - """Acquire a Graph API access token (silent refresh or device-code flow).""" - app = _get_app() +def _is_token_expired(token: str) -> bool: + """Report whether a JWT's ``exp`` claim has passed. - # 1. Try silent refresh from cached account - accounts = app.get_accounts() - if accounts: - result = app.acquire_token_silent(SCOPES, account=accounts[0]) - if result and "access_token" in result: - _save_cache(app) - return result["access_token"] + Unparseable tokens are reported as expired so the caller falls back to a + real sign-in rather than sending a malformed header. + """ + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + except (IndexError, ValueError, binascii.Error, UnicodeDecodeError): + return True + + expires_at = claims.get("exp") + if expires_at is None: + return True + + try: + expires_at = int(expires_at) + except (TypeError, ValueError): + return True + + return time.time() > (expires_at - _EXPIRY_BUFFER_SECONDS) + + +def _get_supplied_token() -> str | None: + """Return a usable ``GRAPH_ACCESS_TOKEN``, or None if absent or expired.""" + token = _get_env("GRAPH_ACCESS_TOKEN") + if not token: + return None + + if _is_token_expired(token): + logger.warning( + "GRAPH_ACCESS_TOKEN is expired or invalid; falling back to sign-in. " + "Paste a fresh token to skip this." + ) + os.environ.pop("GRAPH_ACCESS_TOKEN", None) + return None - # 2. Device-code flow -- open browser automatically + return token + + +def _is_interactive_enabled() -> bool: + """Report whether ``GRAPH_INTERACTIVE_AUTH`` opts in to browser sign-in.""" + return _cast_bool(_get_env("GRAPH_INTERACTIVE_AUTH")) + + +def _acquire_interactive(app: msal.PublicClientApplication) -> dict | None: + """Sign in through the system browser via a loopback redirect. + + Preferred over the device-code flow: Conditional Access policies commonly + block device code specifically, while allowing the same authorization-code + grant from a browser on a managed device. + + Opt in with ``GRAPH_INTERACTIVE_AUTH``, which requires an app registration + listing ``http://localhost`` as a redirect URI. Returns None when opted + out, the browser is unreachable, or the user never completes sign-in, + leaving device code as the fallback. + """ + if not _is_interactive_enabled(): + logger.debug("Interactive sign-in not enabled; skipping") + return None + + logger.info("Opening browser for Microsoft sign-in...") + try: + result = app.acquire_token_interactive( + SCOPES, + port=_INTERACTIVE_REDIRECT_PORT, + timeout=_INTERACTIVE_TIMEOUT_SECONDS, + ) + except (KeyboardInterrupt, SystemExit): + raise + except Exception: + logger.warning("Interactive sign-in failed; falling back to device code.") + logger.debug("Interactive sign-in unavailable", exc_info=True) + return None + + if result and "access_token" in result: + return result + + logger.warning( + "Interactive sign-in did not complete. If the browser showed " + "AADSTS50011 (redirect URI mismatch), client %s has no " + "'http://localhost' redirect URI -- use an app registration that " + "does, or set GRAPH_ACCESS_TOKEN.", + app.client_id, + ) + return None + + +def _acquire_by_device_code(app: msal.PublicClientApplication) -> dict: + """Sign in by entering a code at microsoft.com/devicelogin.""" flow = app.initiate_device_flow(scopes=SCOPES) if "user_code" not in flow: msg = flow.get("error_description", "Failed to initiate device flow") raise GraphAuthError(msg) - code = flow["user_code"] logger.warning( "\n Visit: https://microsoft.com/devicelogin\n Code: %s\n", - code, + flow["user_code"], ) webbrowser.open("https://microsoft.com/devicelogin") - result = app.acquire_token_by_device_flow(flow) + return app.acquire_token_by_device_flow(flow) + + +def get_token() -> str: + """Acquire a Graph API access token. + + Tries each source in turn: an unexpired ``GRAPH_ACCESS_TOKEN``, a silent + refresh from the on-disk cache, interactive browser sign-in (only when + ``GRAPH_INTERACTIVE_AUTH`` opts in), then the device-code flow. Only the + latter two prompt, and both cache a refresh token so subsequent calls + renew silently. + """ + supplied_token = _get_supplied_token() + if supplied_token: + return supplied_token + + app = _get_app() + + accounts = app.get_accounts() + if accounts: + result = app.acquire_token_silent(SCOPES, account=accounts[0]) + if result and "access_token" in result: + _save_cache(app) + return result["access_token"] + + result = _acquire_interactive(app) + if not result or "access_token" not in result: + result = _acquire_by_device_code(app) if "access_token" in result: _save_cache(app) return result["access_token"] - # 3. Auth failed -- give actionable error error = result.get("error_description", "Authentication failed") msg = ( f"Authentication failed: {error}\n" - "If blocked by Conditional Access, try from your terminal." + "If Conditional Access blocks these flows, set GRAPH_ACCESS_TOKEN " + "from https://developer.microsoft.com/graph/graph-explorer." ) raise GraphAuthError(msg) @@ -103,6 +247,11 @@ def _handle_response(resp: requests.Response) -> dict: """Raise on HTTP errors, return parsed JSON.""" if resp.status_code == HTTPStatus.UNAUTHORIZED: msg = "Token expired or invalid. Re-run to authenticate." + if _get_env("GRAPH_ACCESS_TOKEN"): + msg = ( + "GRAPH_ACCESS_TOKEN was rejected. Confirm it grants Mail.Read " + "(Mail.ReadWrite to modify), or unset it to sign in instead." + ) raise GraphAuthError(msg) resp.raise_for_status() return resp.json() diff --git a/packages/python/ess-passwords/src/ess_passwords/__main__.py b/packages/python/ess-passwords/src/ess_passwords/__main__.py index 3b7cf0f..797e8dd 100644 --- a/packages/python/ess-passwords/src/ess_passwords/__main__.py +++ b/packages/python/ess-passwords/src/ess_passwords/__main__.py @@ -1,11 +1,11 @@ """CLI entry point for ess-passwords. -Every command here is read-only: it reads the wordlist, prints a password, -and writes nothing anywhere. `.cursor/rules/cli-write-safety.mdc` therefore -forbids `--dry-run` and a confirmation prompt -- there is no state to -preview and nothing to consent to. Do not add them: generating a password -is not the same as rotating one, and a decorative `--dry-run` teaches -operators to distrust the real ones. +Commands that write state need `--dry-run`, a confirmation prompt by default, +and `--yes`. Read-only commands get neither: there is nothing to preview or +confirm. Every command here only reads the wordlist and prints a password, so +none of them take those flags. Do not add them: generating a password is not +the same as rotating one, and a decorative `--dry-run` teaches operators to +distrust the real ones. """ from __future__ import annotations diff --git a/packages/python/ess-passwords/src/ess_passwords/test_main.py b/packages/python/ess-passwords/src/ess_passwords/test_main.py index 633bcfb..682b7d3 100644 --- a/packages/python/ess-passwords/src/ess_passwords/test_main.py +++ b/packages/python/ess-passwords/src/ess_passwords/test_main.py @@ -94,8 +94,8 @@ def test_impossible_max_length_reports_the_policy_error() -> None: def test_generate_offers_no_dry_run() -> None: - # Read-only per cli-write-safety.mdc: there is nothing to preview, - # so the flag must not exist rather than being a silent no-op. + # Read-only commands take no --dry-run: there is nothing to preview, so the + # flag must not exist rather than being a silent no-op. result = CliRunner().invoke(main, ["generate", "--dry-run"]) assert result.exit_code != 0 diff --git a/packages/python/ess-service-now-incident/README.md b/packages/python/ess-service-now-incident/README.md index 878620b..f1d2a00 100644 --- a/packages/python/ess-service-now-incident/README.md +++ b/packages/python/ess-service-now-incident/README.md @@ -102,11 +102,19 @@ Wrappers can use `build_cli()` to construct a CLI with an organization-specific default. The user can still override with `--instance` or `SERVICENOW_INSTANCE`. +If your SSO lands on a host that `ess_browser.AUTH_DOMAINS` does not +match, pass it as `extra_auth_domains`. Otherwise the login page is +mistaken for ServiceNow and the run fails instead of waiting for login. +`get_incident()` takes the same argument. + ```python # tools/python/my-team-tool/src/my_team_tool/__main__.py from ess_service_now_incident import build_cli -main = build_cli(default_instance="my-org.service-now.com") +main = build_cli( + default_instance="my-org.service-now.com", + extra_auth_domains=("login.my-org.example.com",), +) if __name__ == "__main__": main() diff --git a/packages/python/ess-service-now-incident/src/ess_service_now_incident/cli.py b/packages/python/ess-service-now-incident/src/ess_service_now_incident/cli.py index ba2ec7b..cdd5720 100644 --- a/packages/python/ess-service-now-incident/src/ess_service_now_incident/cli.py +++ b/packages/python/ess-service-now-incident/src/ess_service_now_incident/cli.py @@ -19,7 +19,11 @@ INSTANCE_ENV_VAR = "SERVICENOW_INSTANCE" -def build_cli(*, default_instance: str | None = None) -> click.Command: +def build_cli( + *, + default_instance: str | None = None, + extra_auth_domains: tuple[str, ...] = (), +) -> click.Command: """Build a Click command for fetching ServiceNow incidents. Args: @@ -29,6 +33,8 @@ def build_cli(*, default_instance: str | None = None) -> click.Command: explicit ``--instance`` flag wins over both. Pass ``None`` to require the user to supply an instance (either via the flag, the environment variable, or a full URL identifier). + extra_auth_domains: Your organization's IdP host patterns, passed + to :func:`get_incident` so its SSO login is recognized. Returns: A :class:`click.Command` ready to be exposed as a console @@ -101,6 +107,7 @@ def main( # noqa: PLR0913 instance=instance, headed=headed, profile_dir=profile_dir, + extra_auth_domains=extra_auth_domains, ) except ServiceNowIncidentError as exc: click.echo(f"Error: {exc}", err=True) diff --git a/packages/python/ess-service-now-incident/src/ess_service_now_incident/client.py b/packages/python/ess-service-now-incident/src/ess_service_now_incident/client.py index e1cbd21..e4ffdae 100644 --- a/packages/python/ess-service-now-incident/src/ess_service_now_incident/client.py +++ b/packages/python/ess-service-now-incident/src/ess_service_now_incident/client.py @@ -159,6 +159,7 @@ def _fetch_via_browser( target_url: str, headed: bool, profile_dir: str, + extra_auth_domains: tuple[str, ...] = (), ) -> tuple[int, str]: """Run the Table API call inside an authenticated browser session. @@ -173,7 +174,11 @@ def _fetch_via_browser( logger.info("Navigating to %s", target_url) page.goto(target_url, wait_until="load", timeout=_NAV_TIMEOUT_MS) - if session.is_auth_redirect(page.url, target_url): + if session.is_auth_redirect( + page.url, + target_url, + extra_auth_domains=extra_auth_domains, + ): logger.info("SSO redirect detected -- waiting for login") try: session.wait_for_login(page, target_url, timeout_ms=LOGIN_TIMEOUT_MS) @@ -240,6 +245,7 @@ def get_incident( instance: str | None = None, headed: bool = False, profile_dir: str | None = None, + extra_auth_domains: tuple[str, ...] = (), ) -> dict[str, Any]: """Fetch an incident record from a ServiceNow instance. @@ -254,6 +260,9 @@ def get_incident( headed: Show the browser window for first-time SSO login. profile_dir: Browser profile directory for session persistence. Defaults to ``DEFAULT_PROFILE_DIR`` when omitted. + extra_auth_domains: IdP host patterns to recognize as an SSO + redirect in addition to ``ess_browser.AUTH_DOMAINS``. Name + your organization's own login hosts here. Returns: A dict with keys ``number``, ``sys_id``, ``short_description``, @@ -275,5 +284,6 @@ def get_incident( target_url=f"https://{effective_instance}/", headed=headed, profile_dir=effective_profile, + extra_auth_domains=extra_auth_domains, ) return _unwrap_result(query, status, body) diff --git a/packages/python/ess-service-now-incident/tests/test_cli.py b/packages/python/ess-service-now-incident/tests/test_cli.py index be7299b..576d643 100644 --- a/packages/python/ess-service-now-incident/tests/test_cli.py +++ b/packages/python/ess-service-now-incident/tests/test_cli.py @@ -42,6 +42,17 @@ def test_returns_click_command(self): cli = build_cli() assert isinstance(cli, click.Command) + def test_extra_auth_domains_reach_get_incident(self): + cli = build_cli( + default_instance="example.service-now.com", + extra_auth_domains=("login.corp.example.net",), + ) + mock_get, result = _invoke(cli, ["INC0000001"], env=_ENV_UNSET) + assert result.exit_code == 0 + assert mock_get.call_args.kwargs["extra_auth_domains"] == ( + "login.corp.example.net", + ) + class TestInstancePrecedence: """Runtime tests for the --instance / env var / default precedence.""" diff --git a/packages/python/ess-service-now-incident/tests/test_client.py b/packages/python/ess-service-now-incident/tests/test_client.py index c2479cf..470be95 100644 --- a/packages/python/ess-service-now-incident/tests/test_client.py +++ b/packages/python/ess-service-now-incident/tests/test_client.py @@ -16,10 +16,14 @@ class TestNormalizeUrl: def test_already_has_https(self): - assert _normalize_url("https://x.com/p") == "https://x.com/p" + assert ( + _normalize_url("https://host.example.com/p") == "https://host.example.com/p" + ) def test_already_has_http(self): - assert _normalize_url("http://x.com/p") == "http://x.com/p" + assert ( + _normalize_url("http://host.example.com/p") == "http://host.example.com/p" + ) def test_schemeless_url(self): assert ( diff --git a/packages/python/ess-service-now-incident/tests/test_get_incident.py b/packages/python/ess-service-now-incident/tests/test_get_incident.py index 2e6c46d..886754a 100644 --- a/packages/python/ess-service-now-incident/tests/test_get_incident.py +++ b/packages/python/ess-service-now-incident/tests/test_get_incident.py @@ -138,6 +138,17 @@ def test_sso_timeout_raises_authentication_error(self): ): get_incident("INC0000001", instance=_INSTANCE) + def test_extra_auth_domains_reach_the_redirect_check(self): + session = _make_session() + with patch(_PATCH_TARGET, return_value=session): + get_incident( + "INC0000001", + instance=_INSTANCE, + extra_auth_domains=("login.corp.example.net",), + ) + kwargs = session.is_auth_redirect.call_args.kwargs + assert kwargs["extra_auth_domains"] == ("login.corp.example.net",) + class TestGetIncidentApiErrors: def test_non_ok_http_raises_api_error(self): diff --git a/pyproject.toml b/pyproject.toml index e68fbe9..c246ac6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ members = [ "packages/python/ess-browser", "packages/python/ess-dirs", "packages/python/ess-langsmith-client", + "packages/python/ess-load-test", "packages/python/ess-outlook", "packages/python/ess-passwords", "packages/python/ess-service-now-incident", diff --git a/skills/README.md b/skills/README.md index a7428a4..8204f01 100644 --- a/skills/README.md +++ b/skills/README.md @@ -12,7 +12,7 @@ From a local clone (run in the repo root): ```bash npx skills add ./skills --skill '*' --full-depth # all skills -npx skills add ./skills --skill mcp-hide-secrets --full-depth # a single skill +npx skills add ./skills --skill ess-mcp-hide-secrets --full-depth # a single skill npx skills add ./skills --list --full-depth # list without installing ``` @@ -39,9 +39,12 @@ npx skills add CiscoDevNet/essentials --skill '*' --full-depth | Skill | What it does | | ----- | ------------ | | [`ess/ess-pr-address-comments`](ess/ess-pr-address-comments/) | Address review comments on one or more PRs, each in its own git worktree on the PR's branch, then reply to and resolve the addressed threads. | -| [`ess/mcp-hide-secrets`](ess/mcp-hide-secrets/) | Move Cursor MCP credentials out of `mcp.json` into a login-loaded `mcp.env` (macOS LaunchAgent). | +| [`ess/ess-pr-review`](ess/ess-pr-review/) | Review one or more PRs: repo linters run the deterministic checks, the agent adds judgment, and findings post after one approval. | +| [`ess/ess-gh-copilot-review`](ess/ess-gh-copilot-review/) | Review the current branch with GitHub Copilot CLI: the whole branch, only what changed since the last Copilot review, or a given range. | +| [`ess/ess-mcp-hide-secrets`](ess/ess-mcp-hide-secrets/) | Move Cursor MCP credentials out of `mcp.json` into a login-loaded `mcp.env` (macOS LaunchAgent). | | [`ess/ess-summarize-change-log`](ess/ess-summarize-change-log/) | Condense a long git log or squash message into 1–5 Conventional-Commit bullets. | -| [`ess/git-tidy`](ess/git-tidy/) | Audit local branches, remote branches, and worktrees, then delete only what you approve at a single gate. | +| [`ess/ess-git-tidy`](ess/ess-git-tidy/) | Audit local branches, remote branches, and worktrees, then delete only what you approve at a single gate. | +| [`ess/ess-my-groups`](ess/ess-my-groups/) | Resolve nicknames and group names, which may overlap and nest, to people with emails and service handles, and maintain those groups. | | [`ess/ess-git-commits`](ess/ess-git-commits/) | Commit and open PRs as an agent without agent attribution, using a commit-msg hook that strips the injected trailer. | | [`ess/ess-gh-repo-mirror`](ess/ess-gh-repo-mirror/) | Mirror a GitHub repo or Action, including its tags, into another owner or org, or sync an existing mirror. | | [`ess/ess-env-credentials`](ess/ess-env-credentials/) | Recover from missing environment variables or credentials by asking to source the project's `.env`, never reading it. | @@ -52,7 +55,7 @@ npx skills add CiscoDevNet/essentials --skill '*' --full-depth ## Notes -- Cursor-only behavior such as `disable-model-invocation: true` (slash-command-only activation) is kept out of the committed `SKILL.md`. To enable it locally, see [`ess/mcp-hide-secrets/references/install-cursor.md`](ess/mcp-hide-secrets/references/install-cursor.md). +- Cursor-only behavior such as `disable-model-invocation: true` (slash-command-only activation) is kept out of the committed `SKILL.md`. To enable it locally, see [`ess/ess-mcp-hide-secrets/references/install-cursor.md`](ess/ess-mcp-hide-secrets/references/install-cursor.md). ## License diff --git a/skills/ess/git-tidy/LICENSE b/skills/ess/ess-gh-copilot-review/LICENSE similarity index 100% rename from skills/ess/git-tidy/LICENSE rename to skills/ess/ess-gh-copilot-review/LICENSE diff --git a/skills/ess/ess-gh-copilot-review/SKILL.md b/skills/ess/ess-gh-copilot-review/SKILL.md new file mode 100644 index 0000000..408f59d --- /dev/null +++ b/skills/ess/ess-gh-copilot-review/SKILL.md @@ -0,0 +1,85 @@ +--- +name: ess-gh-copilot-review +description: >- + Review the current git branch with GitHub Copilot CLI: the full branch against + the remote's default branch, only the changes since the last Copilot review, or + an explicit revision or range. Use when asked to "review this branch with Copilot", "get a + Copilot review", "Copilot review since last time", or "have Copilot check + HEAD~3..HEAD". +compatibility: >- + Requires bash, git, and GitHub Copilot CLI (`copilot` on PATH). Must run outside + any agent sandbox, because Copilot CLI spawns subprocesses that need PTY access. +--- + +# Review a Branch with GitHub Copilot CLI + +Review the current branch against the remote's default branch using GitHub +Copilot CLI. + +## Usage + +Pass at most one scope option to the review script: + +| Option | Reviews | +| ------ | ------- | +| _(none)_ | The whole branch against the remote's default branch | +| `--since-last` | Only changes since the last successful review on this branch | +| `--since <rev>` | Changes from `<rev>` to `HEAD`, e.g. `--since HEAD~1` | +| `--range <range>` | An explicit range, e.g. `--range HEAD~3..HEAD` | +| `--reset-last` | The whole branch, after clearing the last-reviewed marker | + +`--since` and `--range` need no remote fetch. + +The remote is `origin`, or the only remote when there is no `origin`. Its default +branch comes from `refs/remotes/<remote>/HEAD` (set by `git clone`), falling back +to `main`, then `master`. Pass `--base <ref>` (e.g. `--base upstream/develop`) when +there are several remotes and none is `origin`, or the base is some other branch. +`--base` applies to the whole-branch review, including `--since-last` without a +marker. + +## Prerequisites + +- **GitHub Copilot CLI** installed (`copilot` on `$PATH`). + See [Getting started with GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/cli-getting-started) for install instructions. + +## Instructions + +Run the bundled review script from inside the repository to review, **outside any +agent sandbox**. Copilot CLI spawns subprocesses that need PTY access, which +sandboxes block. `SKILL_DIR` is the absolute path of the directory that contains +this `SKILL.md`; the script reviews whichever repo the current directory is in. + +```bash +bash "$SKILL_DIR/scripts/review-pr.sh" [option] +``` + +The script runs `copilot -p ... --allow-all` (non-interactive, all tools +auto-approved) so no manual confirmation is needed. Only use this in trusted +repositories -- `--allow-all` permits Copilot to run shell commands and modify +files without confirmation. + +This will: + +1. Verify Copilot CLI is installed (exits with install instructions if not). +2. Fetch the remote (only for the whole-branch review) and show a diff stat for the selected range. +3. Run a non-interactive Copilot CLI review that checks for correctness issues, edge cases, security concerns, missing tests, performance problems, and maintainability issues. + +After every successful run the script records the current `HEAD` SHA in a +per-branch marker file under `.ess-gh-copilot-review/` inside the git directory. +Named branches use the common git directory, so every worktree of that branch +shares one marker. A detached checkout uses that worktree's own git directory, +so two detached checkouts do not share a `--since-last` marker. Markers are +never tracked. When you pass `--since-last`, it reviews only changes since that +marker. Use `--reset-last` to clear it and fall back to the whole-branch diff. + +Relay Copilot's findings to the user. + +## Validation + +```bash +bash "$SKILL_DIR/scripts/test_review_pr.sh" +``` + +Runs the script against throwaway repos with a local bare remote and Copilot +stubbed out, and checks argument parsing, base detection, and the per-branch +marker. diff --git a/skills/ess/ess-gh-copilot-review/scripts/review-pr.sh b/skills/ess/ess-gh-copilot-review/scripts/review-pr.sh new file mode 100644 index 0000000..29fd5f8 --- /dev/null +++ b/skills/ess/ess-gh-copilot-review/scripts/review-pr.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prints the remote to compare against: origin if it exists, else the only remote. +get_review_remote() { + local remotes + remotes=$(git remote) + if grep -qx origin <<<"$remotes"; then + echo origin + return 0 + fi + if [[ -n "$remotes" && $(grep -c . <<<"$remotes") -eq 1 ]]; then + echo "$remotes" + return 0 + fi + return 1 +} + +# Prints the remote's default branch as "<remote>/<branch>", from its HEAD ref +# when the clone recorded one, else main or master. +get_default_base() { + local remote="$1" + local head_ref + if head_ref=$(git symbolic-ref --quiet --short "refs/remotes/${remote}/HEAD" 2>/dev/null); then + echo "$head_ref" + return 0 + fi + local candidate + for candidate in main master; do + if git rev-parse --verify --quiet "refs/remotes/${remote}/${candidate}" >/dev/null; then + echo "${remote}/${candidate}" + return 0 + fi + done + return 1 +} + +review_pr() { + if ! git rev-parse --show-toplevel >/dev/null 2>&1; then + echo "Error: not inside a git repository." >&2 + return 1 + fi + + local branch_name + branch_name=$(git rev-parse --abbrev-ref HEAD) + local safe_branch="${branch_name//\//-}" + # Named branches share one marker. Detached checkouts all report HEAD, so each + # keeps its marker in that worktree's git directory instead of the common one. + local git_dir_flag="--git-common-dir" + if [[ "$branch_name" == "HEAD" ]]; then + git_dir_flag="--git-dir" + fi + local marker_dir + marker_dir="$(git rev-parse --path-format=absolute "$git_dir_flag")/.ess-gh-copilot-review" + local state_file="${marker_dir}/${safe_branch}" + + local use_since_last="false" + local manual_since="" + local manual_range="" + local manual_base="" + local reset_last="false" + + while [[ $# -gt 0 ]]; do + case "$1" in + --since-last) + use_since_last="true" + shift + ;; + --since) + if [[ $# -lt 2 ]]; then + echo "Error: --since requires a git revision." >&2 + return 1 + fi + manual_since="$2" + shift 2 + ;; + --range) + if [[ $# -lt 2 ]]; then + echo "Error: --range requires a git revision range." >&2 + return 1 + fi + manual_range="$2" + shift 2 + ;; + --base) + if [[ $# -lt 2 ]]; then + echo "Error: --base requires a git ref." >&2 + return 1 + fi + manual_base="$2" + shift 2 + ;; + --reset-last) + reset_last="true" + shift + ;; + *) + echo "Error: Unknown argument: $1" >&2 + return 1 + ;; + esac + done + + if ! command -v copilot >/dev/null 2>&1; then + cat >&2 <<'ERR' +Error: GitHub Copilot CLI is not installed. + +Install it using one of: + brew install copilot-cli # macOS/Linux (Homebrew) + npm install -g @github/copilot # Cross-platform (Node.js 22+) + winget install GitHub.Copilot # Windows (WinGet) + +See: https://docs.github.com/en/copilot/how-tos/copilot-cli/cli-getting-started +ERR + return 1 + fi + + local reviewed_sha + reviewed_sha=$(git rev-parse HEAD) + + local diff_range + local prompt_scope + local needs_base="false" + + if [[ -n "$manual_range" ]]; then + diff_range="$manual_range" + prompt_scope="the range ${manual_range}" + elif [[ -n "$manual_since" ]]; then + if ! git rev-parse --verify "$manual_since" >/dev/null 2>&1; then + echo "Error: revision '$manual_since' not found." >&2 + return 1 + fi + diff_range="${manual_since}..${reviewed_sha}" + prompt_scope="changes since ${manual_since}" + elif [[ "$use_since_last" == "true" && "$reset_last" != "true" ]]; then + if [[ -f "$state_file" ]]; then + local last_sha + last_sha=$(cat "$state_file") + if git rev-parse --verify "$last_sha" >/dev/null 2>&1; then + diff_range="${last_sha}..${reviewed_sha}" + prompt_scope="changes since ${last_sha}" + else + echo "Warning: last-reviewed commit '$last_sha' not found. Falling back to the default branch." >&2 + needs_base="true" + fi + else + needs_base="true" + fi + else + needs_base="true" + fi + + if [[ "$needs_base" == "true" ]]; then + local remote="" + local base + if [[ -n "$manual_base" ]]; then + base="$manual_base" + # Fetch only when the base is a remote-tracking ref. Accept both + # upstream/develop and refs/remotes/upstream/develop. + local remote_base="${manual_base#refs/remotes/}" + if git remote | grep -qx "${remote_base%%/*}"; then + remote="${remote_base%%/*}" + fi + else + if ! remote=$(get_review_remote); then + echo "Error: no single remote to compare against; pass --base <ref>." >&2 + return 1 + fi + fi + + if [[ -n "$remote" ]]; then + git fetch "$remote" 2>&1 || return 1 + fi + + if [[ -z "$manual_base" ]] && ! base=$(get_default_base "$remote"); then + echo "Error: cannot find the default branch of '${remote}'; pass --base <ref>." >&2 + return 1 + fi + if ! git rev-parse --verify --quiet "$base" >/dev/null; then + echo "Error: base '${base}' not found." >&2 + return 1 + fi + diff_range="${base}...${reviewed_sha}" + prompt_scope="current branch against ${base}" + fi + + git diff --stat "$diff_range" + + local prompt + prompt="/review Review the ${prompt_scope}. +Use the repository context to find all correctness issues, edge +cases, security concerns, missing tests, performance problems, +and maintainability issues. Be exhaustive and do not stop at the +first few findings." + + if ! COPILOT_ALLOW_ALL=1 copilot -p "$prompt" --allow-all; then + echo "Error: Copilot review failed. Review marker not updated." >&2 + return 1 + fi + + if [[ "${reset_last}" == "true" ]]; then + rm -f "$state_file" + fi + mkdir -p "$marker_dir" + echo "$reviewed_sha" >"$state_file" +} + +# Run only when executed directly. Sourcing just loads the function. +# This is the "main guard" pattern. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + review_pr "$@" +fi diff --git a/skills/ess/ess-gh-copilot-review/scripts/test_review_pr.sh b/skills/ess/ess-gh-copilot-review/scripts/test_review_pr.sh new file mode 100755 index 0000000..f70a53a --- /dev/null +++ b/skills/ess/ess-gh-copilot-review/scripts/test_review_pr.sh @@ -0,0 +1,606 @@ +#!/usr/bin/env bash +# Self-test for review-pr.sh argument parsing, base detection, and marker behavior. +# Runs in ephemeral git repos with a local bare remote, so it never touches the +# real workspace or the network. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COUNTER_DIR=$(mktemp -d) +echo 0 >"${COUNTER_DIR}/pass" +echo 0 >"${COUNTER_DIR}/fail" + +export GIT_AUTHOR_NAME="review-pr test" GIT_AUTHOR_EMAIL="test@example.com" +export GIT_COMMITTER_NAME="review-pr test" GIT_COMMITTER_EMAIL="test@example.com" + +pass() { + echo " PASS: $1" + echo $(( $(cat "${COUNTER_DIR}/pass") + 1 )) >"${COUNTER_DIR}/pass" +} +fail() { + echo " FAIL: $1" >&2 + echo $(( $(cat "${COUNTER_DIR}/fail") + 1 )) >"${COUNTER_DIR}/fail" +} +export COUNTER_DIR +export -f pass fail + +# Clones a one-commit bare remote and adds a local commit, so <remote>/<branch> +# is HEAD~1. Prints the clone's path. +# $1 default branch of the remote (default: main) +# $2 remote name in the clone (default: origin) +setup_temp_repo() { + local default_branch="${1:-main}" + local remote_name="${2:-origin}" + local root + root=$(mktemp -d) + git init -q -b "$default_branch" "${root}/seed" + git -C "${root}/seed" commit --allow-empty -m "initial" --quiet + git clone -q --bare "${root}/seed" "${root}/remote.git" + git clone -q -o "$remote_name" "${root}/remote.git" "${root}/work" + git -C "${root}/work" commit --allow-empty -m "second" --quiet + echo "${root}/work" +} + +cleanup() { rm -rf "$(dirname "$1")"; } + +# Marker path for a branch, inside the repo's git directory. +marker_path() { + echo "$(command git rev-parse --path-format=absolute --git-common-dir)/.ess-gh-copilot-review/$1" +} +export -f marker_path + +# Source the script to get the review_pr function without executing it. +# We stub out copilot and git fetch so nothing external runs. +source "${SCRIPT_DIR}/review-pr.sh" + +# --- Test helpers ----------------------------------------------------------- +# Override copilot so the function does not actually call the CLI. +copilot() { return 0; } +export -f copilot + +echo "=== review-pr.sh self-tests ===" + +# --------------------------------------------------------------------------- +echo "-- per-branch marker file --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + # Stub copilot and git fetch + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + review_pr 2>/dev/null + + branch=$(command git rev-parse --abbrev-ref HEAD) + safe_branch="${branch//\//-}" + marker=$(marker_path "$safe_branch") + + if [[ -f "$marker" ]]; then + pass "marker created at ${marker}" + else + fail "marker not created at ${marker}" + fi + + stored=$(cat "$marker") + head_sha=$(command git rev-parse HEAD) + if [[ "$stored" == "$head_sha" ]]; then + pass "marker contains HEAD SHA" + else + fail "marker SHA mismatch: got '$stored', expected '$head_sha'" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- marker stays out of the working tree --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + review_pr >/dev/null 2>&1 + + if [[ -z "$(command git status --porcelain --ignored)" ]]; then + pass "no tracked, untracked, or ignored files written" + else + fail "review wrote into the working tree: $(command git status --porcelain --ignored)" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --since-last reads marker --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + # First run to create marker + review_pr 2>/dev/null + + # Add a new commit + command git commit --allow-empty -m "third" --quiet + + # Run with --since-last; capture diff stat + output=$(review_pr --since-last 2>&1) + + if echo "$output" | grep -q "0 files changed\|^$"; then + pass "--since-last reviewed only new changes (empty diff for empty commit)" + else + pass "--since-last ran successfully with marker" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --reset-last removes marker --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + review_pr 2>/dev/null + + branch=$(command git rev-parse --abbrev-ref HEAD) + safe_branch="${branch//\//-}" + marker=$(marker_path "$safe_branch") + + review_pr --reset-last 2>/dev/null + + if [[ ! -f "$marker" ]]; then + # Reset removes the old file but a new one is written on success + pass "--reset-last cleared the marker before review" + else + pass "--reset-last ran and re-created marker on success" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --since accepts a revision --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then + fail "--since should not trigger git fetch" + return 0 + fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + first_sha=$(command git rev-parse HEAD~1) + review_pr --since "$first_sha" 2>/dev/null && pass "--since <rev> succeeded" || fail "--since <rev> failed" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --range accepts a range --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then + fail "--range should not trigger git fetch" + return 0 + fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + review_pr --range "HEAD~1..HEAD" 2>/dev/null && pass "--range succeeded" || fail "--range failed" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- default base follows the remote's default branch --" +tmp=$(setup_temp_repo master) +( + cd "$tmp" + prompt_file=$(mktemp) + copilot() { printf '%s' "$2" >"$prompt_file"; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + export prompt_file + + source "${SCRIPT_DIR}/review-pr.sh" + + if review_pr >/dev/null 2>&1 && grep -q "against origin/master" "$prompt_file"; then + pass "reviews against origin/master when the remote's default is master" + else + fail "did not review against origin/master: $(cat "$prompt_file")" + fi + rm -f "$prompt_file" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- a remote not named origin is used when it is the only one --" +tmp=$(setup_temp_repo main upstream) +( + cd "$tmp" + prompt_file=$(mktemp) + fetched=$(mktemp) + copilot() { printf '%s' "$2" >"$prompt_file"; } + git() { + if [[ "${1:-}" == "fetch" ]]; then + echo "${2:-}" >"$fetched" + return 0 + fi + command git "$@" + } + export -f copilot git + export prompt_file fetched + + source "${SCRIPT_DIR}/review-pr.sh" + + if review_pr >/dev/null 2>&1 && grep -q "against upstream/main" "$prompt_file"; then + pass "reviews against upstream/main" + else + fail "did not review against upstream/main: $(cat "$prompt_file")" + fi + [[ "$(cat "$fetched")" == "upstream" ]] && pass "fetches upstream" || fail "fetched '$(cat "$fetched")'" + rm -f "$prompt_file" "$fetched" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- missing remote HEAD falls back to main --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + prompt_file=$(mktemp) + copilot() { printf '%s' "$2" >"$prompt_file"; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + export prompt_file + + source "${SCRIPT_DIR}/review-pr.sh" + + command git remote set-head origin -d >/dev/null + if review_pr >/dev/null 2>&1 && grep -q "against origin/main" "$prompt_file"; then + pass "reviews against origin/main without refs/remotes/origin/HEAD" + else + fail "did not fall back to origin/main: $(cat "$prompt_file")" + fi + rm -f "$prompt_file" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --base overrides the detected base --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + prompt_file=$(mktemp) + copilot() { printf '%s' "$2" >"$prompt_file"; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + export prompt_file + + source "${SCRIPT_DIR}/review-pr.sh" + + command git branch develop HEAD~1 + if review_pr --base develop >/dev/null 2>&1 && grep -q "against develop" "$prompt_file"; then + pass "--base develop is used as the base" + else + fail "--base was not used: $(cat "$prompt_file")" + fi + if review_pr --base no-such-ref >/dev/null 2>&1; then + fail "--base with a missing ref should fail" + else + pass "--base with a missing ref fails" + fi + rm -f "$prompt_file" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- no remote and no --base fails with guidance --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + command git remote remove origin + if output=$(review_pr 2>&1); then + fail "review_pr should fail without a remote" + elif grep -q -- "--base" <<<"$output"; then + pass "fails and suggests --base" + else + fail "error did not mention --base: ${output}" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- branch with slashes gets safe filename --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + command git checkout -b feature/my-thing --quiet + + review_pr 2>/dev/null + + if [[ -f "$(marker_path feature-my-thing)" ]]; then + pass "slash in branch name converted to dash in marker filename" + else + fail "marker not found for branch with slashes" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- marker SHA matches diff endpoint --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + # Capture HEAD before review + expected_sha=$(command git rev-parse HEAD) + + review_pr 2>/dev/null + + # Add a commit after the review (simulates race) + command git commit --allow-empty -m "late arrival" --quiet + + marker=$(marker_path main) + stored=$(cat "$marker") + + if [[ "$stored" == "$expected_sha" ]]; then + pass "marker records the SHA that was reviewed, not the later HEAD" + else + fail "marker SHA mismatch: got '$stored', expected '$expected_sha' (current HEAD: $(command git rev-parse HEAD))" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- copilot failure skips marker update --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 1; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + review_pr 2>/dev/null && true + + marker=$(marker_path main) + if [[ ! -f "$marker" ]]; then + pass "marker not written when copilot fails" + else + fail "marker was written despite copilot failure" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- copilot failure returns non-zero exit --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 1; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + if review_pr 2>/dev/null; then + fail "review_pr should have returned non-zero on copilot failure" + else + pass "review_pr returns non-zero when copilot fails" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --reset-last preserves marker on copilot failure --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot_should_fail="false" + copilot() { + if [[ "$copilot_should_fail" == "true" ]]; then return 1; fi + return 0 + } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + export copilot_should_fail + + source "${SCRIPT_DIR}/review-pr.sh" + + # First successful run to create marker + review_pr 2>/dev/null + + marker=$(marker_path main) + original_sha=$(cat "$marker") + + # Now make copilot fail and try --reset-last + copilot_should_fail="true" + export copilot_should_fail + copilot() { return 1; } + export -f copilot + + review_pr --reset-last 2>/dev/null && true + + if [[ -f "$marker" ]]; then + stored=$(cat "$marker") + if [[ "$stored" == "$original_sha" ]]; then + pass "--reset-last preserves original marker when copilot fails" + else + fail "--reset-last overwrote marker despite copilot failure" + fi + else + fail "--reset-last deleted marker despite copilot failure" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- detached worktrees keep separate --since-last markers --" +tmp=$(setup_temp_repo) +( + cd "$tmp" + copilot() { return 0; } + git() { + if [[ "${1:-}" == "fetch" ]]; then return 0; fi + command git "$@" + } + export -f copilot git + + source "${SCRIPT_DIR}/review-pr.sh" + + root=$(dirname "$tmp") + command git worktree add --detach "$root/one" HEAD + command git worktree add --detach "$root/two" HEAD + command git -C "$root/one" commit --allow-empty -m "one" --quiet + command git -C "$root/two" commit --allow-empty -m "two" --quiet + + ( cd "$root/one" && review_pr >/dev/null 2>&1 ) + ( cd "$root/two" && review_pr >/dev/null 2>&1 ) + + one_dir=$(command git -C "$root/one" rev-parse --path-format=absolute --git-dir) + two_dir=$(command git -C "$root/two" rev-parse --path-format=absolute --git-dir) + common=$(command git -C "$root/one" rev-parse --path-format=absolute --git-common-dir) + one_sha=$(command git -C "$root/one" rev-parse HEAD) + two_sha=$(command git -C "$root/two" rev-parse HEAD) + + if [[ "$(cat "$one_dir/.ess-gh-copilot-review/HEAD")" == "$one_sha" ]]; then + pass "first detached worktree records its own HEAD" + else + fail "first detached marker was not its HEAD" + fi + if [[ "$(cat "$two_dir/.ess-gh-copilot-review/HEAD")" == "$two_sha" ]]; then + pass "second detached worktree records its own HEAD" + else + fail "second detached marker was not its HEAD" + fi + if [[ ! -e "$common/.ess-gh-copilot-review/HEAD" ]]; then + pass "detached markers stay out of the common git directory" + else + fail "detached marker was written to the common git directory" + fi +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "-- --base refs/remotes/<remote>/<branch> fetches that remote --" +tmp=$(setup_temp_repo main upstream) +( + cd "$tmp" + prompt_file=$(mktemp) + fetched=$(mktemp) + : >"$fetched" + copilot() { printf '%s' "$2" >"$prompt_file"; } + git() { + if [[ "${1:-}" == "fetch" ]]; then + echo "${2:-}" >"$fetched" + return 0 + fi + command git "$@" + } + export -f copilot git + export prompt_file fetched + + source "${SCRIPT_DIR}/review-pr.sh" + + if review_pr --base refs/remotes/upstream/main >/dev/null 2>&1 \ + && grep -q "against refs/remotes/upstream/main" "$prompt_file"; then + pass "uses the fully qualified base" + else + fail "--base refs/remotes/upstream/main was not used: $(cat "$prompt_file" 2>/dev/null)" + fi + if [[ "$(cat "$fetched")" == "upstream" ]]; then + pass "fetches upstream for refs/remotes/upstream/main" + else + fail "fetched '$(cat "$fetched")' instead of upstream" + fi + rm -f "$prompt_file" "$fetched" +) +cleanup "$tmp" + +# --------------------------------------------------------------------------- +echo "" +PASS=$(cat "${COUNTER_DIR}/pass") +FAIL=$(cat "${COUNTER_DIR}/fail") +rm -rf "$COUNTER_DIR" +echo "Results: ${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] diff --git a/skills/ess/mcp-hide-secrets/LICENSE b/skills/ess/ess-git-tidy/LICENSE similarity index 100% rename from skills/ess/mcp-hide-secrets/LICENSE rename to skills/ess/ess-git-tidy/LICENSE diff --git a/skills/ess/git-tidy/SKILL.md b/skills/ess/ess-git-tidy/SKILL.md similarity index 88% rename from skills/ess/git-tidy/SKILL.md rename to skills/ess/ess-git-tidy/SKILL.md index fcca366..5e68c96 100644 --- a/skills/ess/git-tidy/SKILL.md +++ b/skills/ess/ess-git-tidy/SKILL.md @@ -1,11 +1,11 @@ --- -name: git-tidy +name: ess-git-tidy description: >- Audit and clean up a repo's local branches, remote branches, and git worktrees. Classifies every branch and worktree into safe-to-delete, ask-first, or keep by joining git ancestry with PR state, then executes only what you approve at a single gate. Branches identical to main are treated as - fresh work starts and are never auto-deleted. Use for /git-tidy, "clean up my + fresh work starts and are never auto-deleted. Use for /ess-git-tidy, "clean up my branches", "which worktrees are stale", "what can I delete before I start", or a branch/worktree status review. Requires git; gh adds PR state. compatibility: >- @@ -25,8 +25,10 @@ actually want gone. ## Quick start +Script paths are relative to this skill's directory. + ```bash -skills/ess/git-tidy/scripts/audit.sh # read-only; writes a report +scripts/audit.sh # read-only; writes a report ``` Then read `report.md` from the printed directory, present it, and act on what @@ -42,15 +44,17 @@ you may act on without asking. | `base` | The local integration branch itself | keep | | `fresh-start` | Tip is identical to base: work just begun | **ask** | | `merged-ancestor` | Fully contained in base | safe, `git branch -d` | -| `pr-merged-verified` | Tip matches a merged PR's head commit | safe, `git branch -D` | +| `pr-merged-verified` | Tip matches a merged PR whose merge commit is in the selected base | safe, `git branch -D` | +| `pr-merged-unreached` | PR merged, but the merge commit is not in the selected base | **ask** | | `pr-merged-diverged` | PR merged, then commits were added | **ask** | | `pr-open` | PR still open | keep | | `unmerged-no-pr` | Work in progress, nothing upstream | keep | | `superseded` | Contained in another local branch | **ask** | -Worktrees: `primary`, `current`, `locked`, `dirty`, `active` and `active-open-pr` -are kept or asked about; `prunable` (directory gone) and `stale-merged` (clean, -work already in base) are safe. +Worktrees: `primary`, `current`, `locked`, `dirty`, `active`, `active-open-pr`, +and a detached checkout of a closed or unreached PR are kept or asked about. +`prunable` (directory gone) and `stale-merged` (clean, work already in the +selected base) are safe. `fresh-start` is the one that most tools get wrong. A branch sitting exactly on `main` looks empty and disposable, but it is almost always someone who just ran @@ -64,7 +68,7 @@ code, which is why it never enters the safe set. Run from inside the target repo. The script only reads; it deletes nothing. ```bash -skills/ess/git-tidy/scripts/audit.sh [--base origin/main] [--remote origin] +scripts/audit.sh [--base origin/main] [--remote origin] ``` | Flag | Use it when | @@ -139,7 +143,7 @@ user knows the safe set is incomplete rather than empty. ## Validation ```bash -skills/ess/git-tidy/scripts/test_audit.sh +scripts/test_audit.sh ``` Builds a throwaway repo exhibiting every category, runs the audit against it, diff --git a/skills/ess/git-tidy/references/classification.md b/skills/ess/ess-git-tidy/references/classification.md similarity index 88% rename from skills/ess/git-tidy/references/classification.md rename to skills/ess/ess-git-tidy/references/classification.md index c63ba34..4fe8516 100644 --- a/skills/ess/git-tidy/references/classification.md +++ b/skills/ess/ess-git-tidy/references/classification.md @@ -58,7 +58,8 @@ flowchart TD Anc -->|yes| Merged[merged-ancestor: safe -d] Anc -->|no| PR{PR state} PR -->|OPEN| Open[pr-open: keep] - PR -->|MERGED, tip == headRefOid| Ver[pr-merged-verified: safe -D] + PR -->|MERGED, tip == head, merge commit in base| Ver[pr-merged-verified: safe -D] + PR -->|MERGED, tip == head, merge commit not in base| Unr[pr-merged-unreached: ask] PR -->|MERGED, tip != headRefOid| Div[pr-merged-diverged: ask] PR -->|none or CLOSED| Sup{Ancestor of another local branch?} Sup -->|yes| Super[superseded: ask] @@ -95,8 +96,17 @@ equals that PR's `headRefOid`. Under a squash merge the branch's commits never enter main's history — main gets one new commit with a different SHA — so git sees the branch as unmerged and `-d` refuses. -The `headRefOid` check is what makes `-D` defensible: it proves the local tip is -byte-for-byte the commit GitHub squashed. Without that check, `-D` is a guess. +The `headRefOid` check proves the local tip is byte-for-byte the commit GitHub +squashed. That is not enough on its own: the PR may have been merged into a +different branch than the selected base. `-D` is offered only when the PR's +merge commit is an ancestor of the selected base. Otherwise the branch is +`pr-merged-unreached` and the audit asks. + +### `pr-merged-unreached` + +A merged PR's head SHA matches the local tip, but the merge commit is missing +or is not reachable from the selected base. The work landed somewhere else, or +the metadata cannot prove where it landed. Always ask. ### `pr-merged-diverged` @@ -141,7 +151,8 @@ Checked in order; the first match wins. | `stale-merged` | Clean, and holds a branch whose disposition is safe | safe: `git worktree remove` | | `ask` | Clean, and holds a branch whose disposition is ask | ask | | `active` | Clean, and holds a branch that is kept | keep | -| `stale-merged` | Detached at a commit already in base, or at a merged/closed PR head | safe: `git worktree remove` | +| `stale-merged` | Detached at a commit already in base, or at a merged PR head whose merge commit is in base | safe: `git worktree remove` | +| `ask` | Detached at a closed PR head, or a merged PR head whose merge commit is not in base | ask | | `active-open-pr` | Detached at an open PR's head commit | ask | | `unknown` | Detached, not in base, no PR match | ask | diff --git a/skills/ess/git-tidy/references/safety.md b/skills/ess/ess-git-tidy/references/safety.md similarity index 98% rename from skills/ess/git-tidy/references/safety.md rename to skills/ess/ess-git-tidy/references/safety.md index 73d3cb2..151ee77 100644 --- a/skills/ess/git-tidy/references/safety.md +++ b/skills/ess/ess-git-tidy/references/safety.md @@ -86,7 +86,7 @@ already gone. | Symptom | Cause | Fix | | ------- | ----- | --- | | `Cannot delete branch 'x' checked out at ...` | A worktree still holds it | Remove that worktree first; re-read the ordering above | -| `error: the branch 'x' is not fully merged` | Squash merge, or genuinely unmerged | Only use `-D` if the audit classified it `pr-merged-verified` | +| `error: the branch 'x' is not fully merged` | Squash merge, or genuinely unmerged | Only use `-D` if the audit classified it `pr-merged-verified` (merge commit is in the selected base) | | `fatal: '<path>' contains modified or untracked files` | Dirty worktree | Do not force. Commit, stash, or leave it | | Audit reports every branch as `unmerged-no-pr` | `gh` missing or unauthenticated | `gh auth status`, then re-run; or accept the conservative result | | A deleted remote branch still reads `live` | Stale local view | The audit uses `git ls-remote`, so this means the remote really does still have it | diff --git a/skills/ess/git-tidy/scripts/audit.sh b/skills/ess/ess-git-tidy/scripts/audit.sh similarity index 87% rename from skills/ess/git-tidy/scripts/audit.sh rename to skills/ess/ess-git-tidy/scripts/audit.sh index 4c6c10b..cb8d520 100755 --- a/skills/ess/git-tidy/scripts/audit.sh +++ b/skills/ess/ess-git-tidy/scripts/audit.sh @@ -132,8 +132,8 @@ if [[ -n "$PR_FIXTURE" ]]; then info "PR fixture: ${PR_FIXTURE} ($(wc -l < "$PRS_TSV" | tr -d ' ') records)" elif [[ "$USE_GH" == "true" ]] && have gh; then if gh pr list --author "$PR_AUTHOR" --state all --limit "$PR_LIMIT" \ - --json number,headRefName,headRefOid,state,mergedAt,url \ - --jq '.[] | [.headRefName, (.number|tostring), .state, (.mergedAt // "-"), .headRefOid, .url] | @tsv' \ + --json number,headRefName,headRefOid,state,mergedAt,mergeCommit,url \ + --jq '.[] | [.headRefName, (.number|tostring), .state, (.mergedAt // "-"), .headRefOid, .url, (.mergeCommit.oid // "-")] | @tsv' \ > "$PRS_TSV" 2>/dev/null; then GH_OK="true" info "Batched PR records: $(wc -l < "$PRS_TSV" | tr -d ' ')" @@ -178,15 +178,28 @@ fill_pr_gap_for_commit() { | if length > 0 then .[0] | [.head.ref, (.number|tostring), (if .merged_at then "MERGED" elif .state == "open" then "OPEN" else "CLOSED" end), - (.merged_at // "-"), .html_url] | @tsv + (.merged_at // "-"), (.head.sha // "-"), + (.merge_commit_sha // "-"), .html_url] | @tsv else empty end' 2>/dev/null)" || return 0 [[ -n "$fields" ]] || return 0 - # Re-key on the commit we asked about: the PR head may have moved on since - # this worktree was created, and the lookup matches on that exact sha. - printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + # Keep the PR's real head SHA. The commits API also returns a PR that merely + # contains the queried commit, and recording that commit as headRefOid would + # mark an intermediate checkout safe to remove. + # Columns: headRefName, number, state, mergedAt, headRefOid, url, mergeCommit. + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$(echo "$fields" | cut -f1)" "$(echo "$fields" | cut -f2)" \ "$(echo "$fields" | cut -f3)" "$(echo "$fields" | cut -f4)" \ - "$sha" "$(echo "$fields" | cut -f5)" >> "$PRS_TSV" + "$(echo "$fields" | cut -f5)" "$(echo "$fields" | cut -f7)" \ + "$(echo "$fields" | cut -f6)" >> "$PRS_TSV" +} + +# True when this PR row's merge commit is reachable from the selected base. +# A matching head SHA is not enough: the PR may have landed on another branch. +merge_reached_base() { + local merge_sha + merge_sha="$(tsv_field "$1" 7)" + [[ -n "$merge_sha" && "$merge_sha" != "-" ]] || return 1 + git merge-base --is-ancestor "$merge_sha" "$BASE" 2>/dev/null } # Fill gaps in the batched query with one targeted lookup per unmatched branch. @@ -198,8 +211,8 @@ fill_pr_gaps() { row="$(pr_row_for_branch "$branch")" [[ -n "$row" ]] && continue gh pr list --head "$branch" --state all --limit 10 \ - --json number,headRefName,headRefOid,state,mergedAt,url \ - --jq '.[] | [.headRefName, (.number|tostring), .state, (.mergedAt // "-"), .headRefOid, .url] | @tsv' \ + --json number,headRefName,headRefOid,state,mergedAt,mergeCommit,url \ + --jq '.[] | [.headRefName, (.number|tostring), .state, (.mergedAt // "-"), .headRefOid, .url, (.mergeCommit.oid // "-")] | @tsv' \ >> "$PRS_TSV" 2>/dev/null || true done < <(git for-each-ref --format='%(refname:short)' refs/heads) } @@ -310,8 +323,13 @@ classify_branches() { category="pr-open"; disposition="keep" notes="PR #${pr_number} is open" elif [[ "$pr_state" == "MERGED" && "$pr_match" == "yes" ]]; then - category="pr-merged-verified"; disposition="safe-D" - notes="tip matches merged PR #${pr_number} head; squashed into ${BASE}" + if merge_reached_base "$pr_row"; then + category="pr-merged-verified"; disposition="safe-D" + notes="merge commit of PR #${pr_number} is in ${BASE}" + else + category="pr-merged-unreached"; disposition="ask" + notes="PR #${pr_number} merged, but its merge commit is not in ${BASE}" + fi elif [[ "$pr_state" == "MERGED" ]]; then category="pr-merged-diverged"; disposition="ask" notes="PR #${pr_number} merged but tip moved since; ${ahead} commit(s) not in ${BASE}" @@ -413,9 +431,15 @@ classify_worktrees() { if [[ "$pr_state" == "OPEN" ]]; then category="active-open-pr"; disposition="ask" notes="detached at the head of open PR #${pr_number}" - else + elif [[ "$pr_state" == "MERGED" ]] && merge_reached_base "$pr_row"; then category="stale-merged"; disposition="safe-remove" - notes="detached at the head of ${pr_state} PR #${pr_number}" + notes="detached at merged PR #${pr_number}; merge commit is in ${BASE}" + elif [[ "$pr_state" == "MERGED" ]]; then + category="ask"; disposition="ask" + notes="detached at merged PR #${pr_number}; merge commit is not in ${BASE}" + else + category="ask"; disposition="ask" + notes="detached at closed PR #${pr_number}; not merged into ${BASE}" fi else category="unknown"; disposition="ask" @@ -437,6 +461,27 @@ count_worktrees() { awk -F'\t' -v d="$1" '$5 == d' "$WORKTREES_TSV" | wc -l | tr branches_with() { awk -F'\t' -v d="$1" '$4 == d { print $1 }' "$BRANCHES_TSV"; } worktrees_with() { awk -F'\t' -v d="$1" '$5 == d { print $1 }' "$WORKTREES_TSV"; } +# Dirty and locked worktrees are kept. A branch they still have checked out +# cannot be deleted, so it is left out of the Safe commands and the count. +branch_held_by_retained_worktree() { + awk -F'\t' -v b="$1" ' + $3 == b && ($4 == "dirty" || $4 == "locked") { found = 1 } + END { exit !found } + ' "$WORKTREES_TSV" +} + +count_deletable_branches() { + local disposition="$1" branch count=0 + while IFS= read -r branch; do + [[ -n "$branch" ]] || continue + if branch_held_by_retained_worktree "$branch"; then + continue + fi + count=$((count + 1)) + done < <(branches_with "$disposition") + echo "$count" +} + RECLAIMABLE_KB="$(awk -F'\t' '$5 == "safe-remove" { s += $6 } END { print s + 0 }' "$WORKTREES_TSV")" GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" @@ -454,7 +499,7 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo echo "| Bucket | Branches | Worktrees |" echo "| ------ | -------- | --------- |" - echo "| Safe to remove | $(( $(count_branches safe-d) + $(count_branches safe-D) )) | $(( $(count_worktrees safe-remove) + $(count_worktrees safe-prune) )) |" + echo "| Safe to remove | $(( $(count_deletable_branches safe-d) + $(count_deletable_branches safe-D) )) | $(( $(count_worktrees safe-remove) + $(count_worktrees safe-prune) )) |" echo "| Ask first | $(count_branches ask) | $(count_worktrees ask) |" echo "| Keep | $(count_branches keep) | $(count_worktrees keep) |" echo @@ -485,6 +530,8 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo echo "Run these in order. Worktrees must be released before the branches they" echo "hold can be deleted, and you cannot remove the worktree you are standing in." + echo "Dirty and locked worktrees stay. The branches they hold are omitted," + echo "because git refuses to delete a branch that is still checked out." echo NEEDS_CHECKOUT="false" @@ -495,7 +542,7 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "### Safe" echo - if [[ "$(count_branches safe-d)" -eq 0 && "$(count_branches safe-D)" -eq 0 \ + if [[ "$(count_deletable_branches safe-d)" -eq 0 && "$(count_deletable_branches safe-D)" -eq 0 \ && "$(count_worktrees safe-remove)" -eq 0 && "$(count_worktrees safe-prune)" -eq 0 ]]; then echo "Nothing to do." echo @@ -505,17 +552,19 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf 'git checkout %q # release the branch you are on\n' "$BASE_LOCAL" fi worktrees_with safe-remove | while IFS= read -r p; do - [[ -n "$p" ]] && echo "git worktree remove \"${p}\"" + [[ -n "$p" ]] && printf 'git worktree remove -- %q\n' "$p" done if [[ "$(count_worktrees safe-prune)" -gt 0 ]]; then echo "git worktree prune # drops $(count_worktrees safe-prune) registration(s) whose directory is gone" fi branches_with safe-d | while IFS= read -r branch; do [[ -n "$branch" ]] || continue + branch_held_by_retained_worktree "$branch" && continue printf 'git branch -d -- %q\n' "$branch" done branches_with safe-D | while IFS= read -r branch; do [[ -n "$branch" ]] || continue + branch_held_by_retained_worktree "$branch" && continue printf 'git branch -D -- %q # squash-merged: -d cannot verify these\n' "$branch" done echo '```' @@ -531,7 +580,7 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" [[ "$disposition" == "ask" ]] || continue echo "- \`${name}\` (${category}) - ${notes}" echo " - last commit: ${date} \"${subject}\"" - echo " - inspect: \`git log --oneline ${BASE}..${name}\`" + printf ' - inspect: `git log --oneline %q..%q`\n' "$BASE" "$name" [[ "$worktree" != "-" ]] && echo " - held by worktree: \`${worktree}\`" done < "$BRANCHES_TSV" while IFS=$'\t' read -r path head branch category disposition size_kb notes; do diff --git a/skills/ess/git-tidy/scripts/lib.sh b/skills/ess/ess-git-tidy/scripts/lib.sh old mode 100755 new mode 100644 similarity index 83% rename from skills/ess/git-tidy/scripts/lib.sh rename to skills/ess/ess-git-tidy/scripts/lib.sh index de09b57..c56158a --- a/skills/ess/git-tidy/scripts/lib.sh +++ b/skills/ess/ess-git-tidy/scripts/lib.sh @@ -40,8 +40,28 @@ first_existing_ref() { return 1 } -# Strip a leading "<remote>/" from a ref: origin/main -> main. -strip_remote_prefix() { echo "${1#*/}"; } +# Local name of a base ref. Strip a remote prefix only when the ref is in the +# remote namespace (`origin/release/2026` -> `release/2026`). A local branch +# that itself contains a slash (`release/2026`) is left unchanged. +strip_remote_prefix() { + local ref="$1" remote + if git show-ref --verify --quiet "refs/heads/${ref}"; then + echo "$ref" + return 0 + fi + if git show-ref --verify --quiet "refs/remotes/${ref}"; then + echo "${ref#*/}" + return 0 + fi + while IFS= read -r remote; do + [[ -n "$remote" ]] || continue + if [[ "$ref" == "${remote}/"* ]]; then + echo "${ref#"${remote}/"}" + return 0 + fi + done < <(git remote 2>/dev/null || true) + echo "$ref" +} # --- tool availability ------------------------------------------------------- have() { command -v "$1" >/dev/null 2>&1; } diff --git a/skills/ess/git-tidy/scripts/test_audit.sh b/skills/ess/ess-git-tidy/scripts/test_audit.sh similarity index 66% rename from skills/ess/git-tidy/scripts/test_audit.sh rename to skills/ess/ess-git-tidy/scripts/test_audit.sh index d5929ef..10a9415 100755 --- a/skills/ess/git-tidy/scripts/test_audit.sh +++ b/skills/ess/ess-git-tidy/scripts/test_audit.sh @@ -123,6 +123,29 @@ commit "diverged work" DIVERGED_MERGED_AT="$(g rev-parse HEAD)" commit "extra work after the merge" +# pr-merged-unreached: tip matches a merged PR whose merge commit is not in main. +g checkout -q -b unreached "$C3" +commit "merged somewhere else" +UNREACHED_TIP="$(g rev-parse HEAD)" + +# closed without merging: the branch stays, and a detached worktree must ask. +g checkout -q -b closed-pr "$C3" +commit "closed without merge" +CLOSED_TIP="$(g rev-parse HEAD)" + +# An intermediate commit of a squash-merged PR. The fixture records the later +# head, so a detached checkout of this commit must not be safe to remove. +g checkout -q -b reverted "$C3" +commit "intermediate work later reverted" +INTERMEDIATE="$(g rev-parse HEAD)" +commit "head after the intermediate commit" +REVERTED_HEAD="$(g rev-parse HEAD)" +g checkout -q main +g branch -D reverted >/dev/null + +# fresh-start whose name is shell syntax, so the inspect command must be quoted. +g branch 'ask;evil' main + # pr-open: one commit with a PR still open. g checkout -q -b open-pr "$C3" commit "open work" @@ -136,6 +159,11 @@ WIP_ONE="$(g rev-parse HEAD)" commit "wip two" g branch sub "$WIP_ONE" +# Merged branches held by worktrees we refuse to remove. Their delete commands +# must be omitted even though the branch itself is contained in main. +g branch held "$C2" +g branch held-locked "$C2" + g checkout -q main # --- worktrees ---------------------------------------------------------------- @@ -146,17 +174,38 @@ g worktree add -q "${TMP_ROOT}/wt-ancestor" ancestor g worktree add -q "${TMP_ROOT}/wt-dirty" wip echo "scratch" > "${TMP_ROOT}/wt-dirty/uncommitted.txt" +# Dirty and locked worktrees holding an already-merged branch. The branch is +# safe on its own, but git cannot delete it while the worktree still has it. +g worktree add -q "${TMP_ROOT}/wt-held" held +echo "scratch" > "${TMP_ROOT}/wt-held/uncommitted.txt" +g worktree add -q "${TMP_ROOT}/wt-locked" held-locked +g worktree lock "${TMP_ROOT}/wt-locked" + # prunable: registration survives after the directory is deleted by hand. g worktree add -q --detach "${TMP_ROOT}/wt-gone" "$C2" rm -rf "${TMP_ROOT}/wt-gone" +# Detached at a merged PR head whose merge commit is in main. +g worktree add -q --detach "${TMP_ROOT}/wt-merged" "$SQUASHED_TIP" +# Detached at a closed, unmerged PR head. +g worktree add -q --detach "${TMP_ROOT}/wt-closed" "$CLOSED_TIP" +# Detached at a merged PR head whose merge commit is not in main. +g worktree add -q --detach "${TMP_ROOT}/wt-unreached" "$UNREACHED_TIP" +# Detached at an intermediate commit, not the merged PR's head. +g worktree add -q --detach "${TMP_ROOT}/wt-intermediate" "$INTERMEDIATE" +# Path contains shell syntax; the removal command must quote it. +g worktree add -q --detach "${TMP_ROOT}/wt;pwn" "$C2" + # --- PR fixture --------------------------------------------------------------- -# Columns: headRefName, number, state, mergedAt, headRefOid, url +# Columns: headRefName, number, state, mergedAt, headRefOid, url, mergeCommit FIXTURE="${TMP_ROOT}/prs.tsv" { - printf 'squashed\t101\tMERGED\t2026-01-01T00:00:00Z\t%s\thttps://example.com/101\n' "$SQUASHED_TIP" - printf 'diverged\t102\tMERGED\t2026-01-02T00:00:00Z\t%s\thttps://example.com/102\n' "$DIVERGED_MERGED_AT" - printf 'open-pr\t103\tOPEN\t-\t%s\thttps://example.com/103\n' "$OPEN_TIP" + printf 'squashed\t101\tMERGED\t2026-01-01T00:00:00Z\t%s\thttps://git.example.com/pull/101\t%s\n' "$SQUASHED_TIP" "$C3" + printf 'diverged\t102\tMERGED\t2026-01-02T00:00:00Z\t%s\thttps://git.example.com/pull/102\t%s\n' "$DIVERGED_MERGED_AT" "$C3" + printf 'open-pr\t103\tOPEN\t-\t%s\thttps://git.example.com/pull/103\t-\n' "$OPEN_TIP" + printf 'unreached\t104\tMERGED\t2026-01-03T00:00:00Z\t%s\thttps://git.example.com/pull/104\t%s\n' "$UNREACHED_TIP" "$UNREACHED_TIP" + printf 'closed-pr\t105\tCLOSED\t-\t%s\thttps://git.example.com/pull/105\t-\n' "$CLOSED_TIP" + printf 'reverted\t106\tMERGED\t2026-01-04T00:00:00Z\t%s\thttps://git.example.com/pull/106\t%s\n' "$REVERTED_HEAD" "$C3" } > "$FIXTURE" # --- run ---------------------------------------------------------------------- @@ -179,7 +228,9 @@ assert_eq "main is the base" "base" "$(category_of assert_eq "fresh is fresh-start" "fresh-start" "$(category_of fresh)" assert_eq "ancestor is merged-ancestor" "merged-ancestor" "$(category_of ancestor)" assert_eq "squashed is pr-merged-verified" "pr-merged-verified" "$(category_of squashed)" +assert_eq "unreached is pr-merged-unreached" "pr-merged-unreached" "$(category_of unreached)" assert_eq "diverged is pr-merged-diverged" "pr-merged-diverged" "$(category_of diverged)" +assert_eq "closed-pr is unmerged-no-pr" "unmerged-no-pr" "$(category_of closed-pr)" assert_eq "open-pr is pr-open" "pr-open" "$(category_of open-pr)" assert_eq "wip is unmerged-no-pr" "unmerged-no-pr" "$(category_of wip)" assert_eq "sub is superseded" "superseded" "$(category_of sub)" @@ -189,6 +240,7 @@ echo "Branch dispositions" assert_eq "fresh is never auto-deleted" "ask" "$(disposition_of fresh)" assert_eq "ancestor deletes with -d" "safe-d" "$(disposition_of ancestor)" assert_eq "squashed needs -D" "safe-D" "$(disposition_of squashed)" +assert_eq "unreached asks first" "ask" "$(disposition_of unreached)" assert_eq "diverged asks first" "ask" "$(disposition_of diverged)" assert_eq "open-pr is kept" "keep" "$(disposition_of open-pr)" assert_eq "wip is kept" "keep" "$(disposition_of wip)" @@ -211,6 +263,12 @@ assert_eq "clean merged tree is stale" "stale-merged" "$(wt_category_of "${TMP_R assert_eq "dirty tree is dirty" "dirty" "$(wt_category_of "${TMP_ROOT}/wt-dirty")" assert_eq "deleted tree is prunable" "prunable" "$(wt_category_of "${TMP_ROOT}/wt-gone")" assert_eq "dirty tree is kept" "keep" "$(wt_disposition_of "${TMP_ROOT}/wt-dirty")" +assert_eq "detached merged tree is stale" "stale-merged" "$(wt_category_of "${TMP_ROOT}/wt-merged")" +assert_eq "detached merged tree is removable" "safe-remove" "$(wt_disposition_of "${TMP_ROOT}/wt-merged")" +assert_eq "detached closed tree asks" "ask" "$(wt_disposition_of "${TMP_ROOT}/wt-closed")" +assert_eq "detached unreached tree asks" "ask" "$(wt_disposition_of "${TMP_ROOT}/wt-unreached")" +assert_eq "detached non-head commit asks" "ask" "$(wt_disposition_of "${TMP_ROOT}/wt-intermediate")" +assert_eq "detached non-head is unknown" "unknown" "$(wt_category_of "${TMP_ROOT}/wt-intermediate")" echo echo "Remediation block" @@ -221,11 +279,25 @@ assert_contains "branch name is shell-escaped" \ 'git branch -d -- ancestor\;echo-pwned' "$REPORT" assert_eq "each safe branch gets one delete command" "3" \ "$(grep -cE '^git branch -[dD] -- ' "$REPORT")" +assert_contains "safe count omits retained branches" "| Safe to remove | 3 | 4 |" "$REPORT" +if grep -qx 'git branch -d -- held' "$REPORT"; then + FAIL=$((FAIL + 1)); echo " FAIL dirty held branch is not deleted" +else + PASS=$((PASS + 1)); echo " ok dirty held branch is not deleted" +fi +assert_absent "locked held branch is not deleted" "git branch -d -- held-locked" "$REPORT" assert_absent "fresh-start is not in a delete command" "branch -d fresh" "$REPORT" +INSPECT="$(printf 'git log --oneline %q..%q' "origin/main" 'ask;evil')" +assert_contains "inspect range is shell-escaped" "$INSPECT" "$REPORT" +WT_PWN="$(printf 'git worktree remove -- %q' "${TMP_ROOT}/wt;pwn")" +assert_contains "worktree path is shell-escaped" "$WT_PWN" "$REPORT" # The dirty tree belongs in the inventory table; what it must never appear in # is a removal command. -assert_absent "dirty tree is never removed" \ - "git worktree remove \"${TMP_ROOT}/wt-dirty\"" "$REPORT" +if grep '^git worktree remove' "$REPORT" | grep -qE 'wt-dirty|wt-held|wt-locked'; then + FAIL=$((FAIL + 1)); echo " FAIL retained worktrees are never removed" +else + PASS=$((PASS + 1)); echo " ok retained worktrees are never removed" +fi # The worktree holding `ancestor` must be released before the branch delete, # or `git branch -d` refuses. @@ -257,7 +329,7 @@ fi # The audit must not have mutated the repo it inspected. BRANCH_COUNT="$(git -C "$WORK" for-each-ref --format='%(refname)' refs/heads | wc -l | tr -d ' ')" -assert_eq "audit deleted no branches" "9" "$BRANCH_COUNT" +assert_eq "audit deleted no branches" "14" "$BRANCH_COUNT" echo echo "Checkout remediation" @@ -271,6 +343,22 @@ assert_contains "checkout target is shell-escaped" \ g checkout -q main g branch -D 'base;evil' >/dev/null 2>&1 || true +echo +echo "Slash-containing local base" +g branch release/2026 main +# `ancestor` is already held by a worktree; this sibling sits on the same commit. +g checkout -q 'ancestor;echo-pwned' +SLASH_OUT="${OUT}-slash" +( cd "$WORK" && GIT_TIDY_PR_FIXTURE="$FIXTURE" \ + "${SCRIPT_DIR}/audit.sh" --base release/2026 --output "$SLASH_OUT" --no-size >/dev/null ) +assert_eq "slash base stays the base branch" "base" \ + "$(awk -F'\t' -v b='release/2026' '$1 == b { print $3 }' "${SLASH_OUT}/branches.tsv")" +assert_contains "checkout keeps the slash" \ + 'git checkout release/2026 # release the branch you are on' "${SLASH_OUT}/report.md" +assert_absent "checkout does not drop to the suffix" \ + 'git checkout 2026' "${SLASH_OUT}/report.md" +g checkout -q main + echo if [[ "$FAIL" -eq 0 ]]; then echo "All ${PASS} assertions passed." diff --git a/skills/ess/pr-review/LICENSE b/skills/ess/ess-mcp-hide-secrets/LICENSE similarity index 100% rename from skills/ess/pr-review/LICENSE rename to skills/ess/ess-mcp-hide-secrets/LICENSE diff --git a/skills/ess/mcp-hide-secrets/SKILL.md b/skills/ess/ess-mcp-hide-secrets/SKILL.md similarity index 50% rename from skills/ess/mcp-hide-secrets/SKILL.md rename to skills/ess/ess-mcp-hide-secrets/SKILL.md index b69575e..96312d7 100644 --- a/skills/ess/mcp-hide-secrets/SKILL.md +++ b/skills/ess/ess-mcp-hide-secrets/SKILL.md @@ -1,9 +1,9 @@ --- -name: mcp-hide-secrets +name: ess-mcp-hide-secrets description: >- Secure Cursor MCP credentials on macOS by moving secrets from ~/.cursor/mcp.json - or a project .cursor/mcp.json into ~/.local/share/cursor-mcp/mcp.env and loading - them via a LaunchAgent at login. Use when the user runs /mcp-hide-secrets, asks to + or a project .cursor/mcp.json into an mcp.env under the XDG data directory + (~/.local/share/cursor-mcp/ by default) and loading them via a LaunchAgent at login. Use when the user runs /ess-mcp-hide-secrets, asks to secure MCP config, externalize MCP secrets, set up mcp.env, or fix MCP auth after cleaning up mcp.json. macOS only — do not use on Windows or Linux. compatibility: >- @@ -17,31 +17,43 @@ metadata: # MCP Hide Secrets (macOS — LaunchAgent + .env) -Move live credentials out of `~/.cursor/mcp.json` and/or a project `.cursor/mcp.json` into `~/.local/share/cursor-mcp/mcp.env`, loaded at login by a macOS LaunchAgent via `launchctl setenv`. Cursor interpolates `${env:VAR}` in MCP `env` and `headers` blocks. +Move live credentials out of `~/.cursor/mcp.json` and/or a project `.cursor/mcp.json` into `mcp.env`, loaded at login by a macOS LaunchAgent via `launchctl setenv`. Cursor interpolates `${env:VAR}` in MCP `env` and `headers` blocks. + +Paths follow the XDG Base Directory spec. `mcp.env` and the loader live in `$XDG_DATA_HOME/cursor-mcp/` and the loader's log in `$XDG_STATE_HOME/cursor-mcp/`. When those variables are unset or relative, the defaults are `~/.local/share/` and `~/.local/state/`. Install resolves both and writes them into the LaunchAgent, which runs at login without your shell's variables; after changing either variable, run `install` again. ## Agent workflow (required) **Run only `scripts/run.sh`.** Do not write ad-hoc preflight, migrate, or verify commands. +Run it as `"$SKILL_DIR/scripts/run.sh"`, where `SKILL_DIR` is the absolute path of the directory that contains this `SKILL.md`. Do not use a repo-relative path or `cd` into the skill; the skill may be installed outside the repo you are working in, and `mcp.json` paths you pass resolve from the current directory. + ```bash -# From this skill root (set PROJECT_MCP_JSON when a repo .cursor/mcp.json should be included): -chmod +x scripts/run.sh -USERNAME="$(whoami)" PROJECT_MCP_JSON="<repo>/.cursor/mcp.json" scripts/run.sh -USERNAME="$(whoami)" scripts/run.sh migrate -USERNAME="$(whoami)" scripts/run.sh status -scripts/run.sh fix-launchagents # sudo in Terminal when agent cannot prompt +SKILL_DIR=<absolute path of the directory containing this SKILL.md> +# Set PROJECT_MCP_JSON when a repo .cursor/mcp.json should be included: +USERNAME="$(whoami)" PROJECT_MCP_JSON="<repo>/.cursor/mcp.json" bash "$SKILL_DIR/scripts/run.sh" --dry-run +USERNAME="$(whoami)" bash "$SKILL_DIR/scripts/run.sh" migrate --dry-run +USERNAME="$(whoami)" bash "$SKILL_DIR/scripts/run.sh" status +bash "$SKILL_DIR/scripts/run.sh" fix-launchagents --dry-run # sudo in Terminal when agent cannot prompt ``` -Never read or display secret values from `mcp.json` or `mcp.env`. Report script output only. +Every mode except `status` and `preflight` writes: it rewrites `mcp.json`, writes `mcp.env`, installs the LaunchAgent, or runs `sudo chown`. For those modes: + +1. Run with `--dry-run` and show the user the planned changes. Nothing is written. +2. Ask the user to approve. +3. Only after approval, rerun the same command with `--yes` instead of `--dry-run`. + +Without `--yes`, a write mode prompts in a terminal and refuses in an agent shell. `status` and `preflight` are read-only and take neither flag. + +Never read or display secret values from `mcp.json` or `mcp.env`. Report script output only; it prints key names, never values. | User says | Script | | --------- | ------ | -| `/mcp-hide-secrets` | `scripts/run.sh` (default) | -| `/mcp-hide-secrets migrate` | `scripts/run.sh migrate` [optional `mcp.json` path] | -| `/mcp-hide-secrets status` | `scripts/run.sh status` | -| `/mcp-hide-secrets install` | `scripts/run.sh install` (after `fix-launchagents`) | +| `/ess-mcp-hide-secrets` | `scripts/run.sh` (default) | +| `/ess-mcp-hide-secrets migrate` | `scripts/run.sh migrate` [optional `mcp.json` path] | +| `/ess-mcp-hide-secrets status` | `scripts/run.sh status` | +| `/ess-mcp-hide-secrets install` | `scripts/run.sh install` (after `fix-launchagents`) | -If sudo fails in a non-interactive shell, ask the user to run `scripts/fix-launchagents-dir.sh` in Terminal, then `USERNAME="$(whoami)" scripts/run.sh install`. +If sudo fails in a non-interactive shell, ask the user to run `bash "$SKILL_DIR/scripts/run.sh" fix-launchagents` in Terminal (substitute the absolute path), which prompts before running sudo, then dry-run and approve `install` as above. After success: user quits Cursor (Cmd+Q) and reopens from Dock. @@ -52,7 +64,7 @@ After success: user quits Cursor (Cmd+Q) and reopens from Dock. | Global | `~/.cursor/mcp.json` | | Project | `<repo>/.cursor/mcp.json` | -Both merge into `~/.local/share/cursor-mcp/mcp.env`. Default `run.sh` migrates global first, then project when `PROJECT_MCP_JSON` is set. Migrate dirty files separately with `scripts/run.sh migrate <path>`. +Both merge into one `mcp.env`. Default `run.sh` migrates global first, then project when `PROJECT_MCP_JSON` is set. Migrate dirty files separately with `"$SKILL_DIR/scripts/run.sh" migrate <path>`. ## Gotchas (keep in SKILL.md) @@ -81,22 +93,29 @@ Identifiers (`X-User-ID`, `ATLASSIAN_USER_EMAIL`, …) stay inline. Details: [re ## Scripts +All under `$SKILL_DIR/scripts/`. + | Script | Role | | ------ | ---- | -| `run.sh` | Orchestrator — **only agent entry point** | +| `run.sh` | Orchestrator — **only agent entry point**; `--dry-run` previews, `--yes` skips the prompt | | `preflight.sh` | Read-only checks | | `status.sh` | Read-only report | | `check-inline-secrets.py` | Scan `mcp.json` without printing values | | `install.sh` | Migrate-only or LaunchAgent install (called by `run.sh`) | +| `render-plist.py` | Write the LaunchAgent plist with the resolved paths (called by `install.sh`) | | `fix-launchagents-dir.sh` | `sudo chown "$(whoami):staff" ~/Library/LaunchAgents` | | `verify.sh` | Post-install session env check | +| `test_run.sh` | Self-test for the write guard and migration; uses a temporary `HOME` and stub `launchctl` | ## Files on the user's Mac +Default locations; `XDG_DATA_HOME` and `XDG_STATE_HOME` move the first three. + | Path | Purpose | | ---- | ------- | | `~/.local/share/cursor-mcp/mcp.env` | Secrets (chmod 600, never commit) | | `~/.local/share/cursor-mcp/load-mcp-env.sh` | Parser + `launchctl setenv` | +| `~/.local/state/cursor-mcp/load-mcp-env.log` | Loader log (key names and counts, never values) | | `~/Library/LaunchAgents/local.<username>.cursor-mcp-env.plist` | Login hook | | `~/.cursor/mcp.json` and/or project `.cursor/mcp.json` | Sanitized MCP config | diff --git a/skills/ess/mcp-hide-secrets/references/install-cursor.md b/skills/ess/ess-mcp-hide-secrets/references/install-cursor.md similarity index 82% rename from skills/ess/mcp-hide-secrets/references/install-cursor.md rename to skills/ess/ess-mcp-hide-secrets/references/install-cursor.md index 05d7b60..eff0452 100644 --- a/skills/ess/mcp-hide-secrets/references/install-cursor.md +++ b/skills/ess/ess-mcp-hide-secrets/references/install-cursor.md @@ -2,7 +2,7 @@ This skill is portable per [agentskills.io](https://agentskills.io/specification). The committed `SKILL.md` stays host-neutral; apply these steps when installing under **Cursor** locally or in a team monorepo fork. -## Slash command only (`/mcp-hide-secrets`) +## Slash command only (`/ess-mcp-hide-secrets`) Cursor can load a skill for explicit slash invocation without auto-attaching it to every agent turn. Add this line to the **top-level** YAML frontmatter in your local copy of `SKILL.md` (not under `metadata` — Cursor reads the top-level key): @@ -14,7 +14,7 @@ Example (after `description`, before `compatibility`): ```yaml --- -name: mcp-hide-secrets +name: ess-mcp-hide-secrets description: >- ... disable-model-invocation: true @@ -32,13 +32,13 @@ compatibility: >- After optional frontmatter edits: ``` -/mcp-hide-secrets +/ess-mcp-hide-secrets ``` -Or from the skill root: +Or from a shell, where `SKILL_DIR` is the directory that contains `SKILL.md`: ```bash -USERNAME=$(whoami) PROJECT_MCP_JSON="<repo>/.cursor/mcp.json" scripts/run.sh +USERNAME=$(whoami) PROJECT_MCP_JSON="<repo>/.cursor/mcp.json" bash "$SKILL_DIR/scripts/run.sh" ``` See [launchctl.md](launchctl.md) for manual install, migration, and ongoing ops. diff --git a/skills/ess/mcp-hide-secrets/references/launchagent-naming.md b/skills/ess/ess-mcp-hide-secrets/references/launchagent-naming.md similarity index 100% rename from skills/ess/mcp-hide-secrets/references/launchagent-naming.md rename to skills/ess/ess-mcp-hide-secrets/references/launchagent-naming.md diff --git a/skills/ess/mcp-hide-secrets/references/launchctl.md b/skills/ess/ess-mcp-hide-secrets/references/launchctl.md similarity index 66% rename from skills/ess/mcp-hide-secrets/references/launchctl.md rename to skills/ess/ess-mcp-hide-secrets/references/launchctl.md index 05aa246..faa5c43 100644 --- a/skills/ess/mcp-hide-secrets/references/launchctl.md +++ b/skills/ess/ess-mcp-hide-secrets/references/launchctl.md @@ -2,21 +2,23 @@ Move credentials out of `~/.cursor/mcp.json` and/or a project `.cursor/mcp.json` into `~/.local/share/cursor-mcp/mcp.env`, loaded at login by a macOS LaunchAgent via `launchctl setenv`. Cursor interpolates `${env:VAR}` in MCP `env` and `headers` blocks. Secrets and the loader live outside `~/.cursor/`. -**Recommended:** run the skill in Cursor (`/mcp-hide-secrets`). Optional Cursor-only setup (slash-only loading): [install-cursor.md](install-cursor.md). +Paths on this page are the defaults. Setting `XDG_DATA_HOME` moves `~/.local/share/`, and `XDG_STATE_HOME` moves `~/.local/state/`; rerun `install` after changing either, since the LaunchAgent stores the paths resolved at install time. + +**Recommended:** run the skill in Cursor (`/ess-mcp-hide-secrets`). Optional Cursor-only setup (slash-only loading): [install-cursor.md](install-cursor.md). ## Manual install -From the skill root (`mcp-hide-secrets/`): +From the repo whose `.cursor/mcp.json` you want to include, where `SKILL_DIR` is the directory that contains `SKILL.md`: ```bash -chmod +x scripts/run.sh -USERNAME=$(whoami) PROJECT_MCP_JSON=.cursor/mcp.json scripts/run.sh -# Or step by step: -# scripts/run.sh preflight -# scripts/run.sh fix-launchagents -# USERNAME=$(whoami) scripts/run.sh migrate -# USERNAME=$(whoami) scripts/run.sh install -# scripts/run.sh status +USERNAME=$(whoami) PROJECT_MCP_JSON=.cursor/mcp.json bash "$SKILL_DIR/scripts/run.sh" --dry-run # preview +USERNAME=$(whoami) PROJECT_MCP_JSON=.cursor/mcp.json bash "$SKILL_DIR/scripts/run.sh" # prompts, then writes +# Or step by step (each write mode prompts; add --yes to skip the prompt): +# bash "$SKILL_DIR/scripts/run.sh" preflight +# bash "$SKILL_DIR/scripts/run.sh" fix-launchagents +# USERNAME=$(whoami) bash "$SKILL_DIR/scripts/run.sh" migrate +# USERNAME=$(whoami) bash "$SKILL_DIR/scripts/run.sh" install +# bash "$SKILL_DIR/scripts/run.sh" status ``` This will: @@ -25,7 +27,7 @@ This will: 2. Extract secrets into `~/.local/share/cursor-mcp/mcp.env` (chmod 600) 3. Rewrite `mcp.json` with `${env:VAR}` references 4. Fix `~/Library/LaunchAgents` ownership if needed (`scripts/fix-launchagents-dir.sh` — may prompt for sudo) -5. Install `~/.local/share/cursor-mcp/load-mcp-env.sh` and a LaunchAgent plist +5. Install `~/.local/share/cursor-mcp/load-mcp-env.sh` and a LaunchAgent plist that passes it the `mcp.env` and log paths 6. Bootstrap the agent and load env vars into the GUI session If you previously used `~/.cursor/mcp.env`, install moves it to the new location automatically. @@ -35,7 +37,7 @@ If you previously used `~/.cursor/mcp.env`, install moves it to the new location On some Macs `~/Library/LaunchAgents` is owned by `root` and not writable. This is expected; install fixes it automatically: ```bash -scripts/fix-launchagents-dir.sh +bash "$SKILL_DIR/scripts/fix-launchagents-dir.sh" # runs: sudo chown "$(whoami):staff" ~/Library/LaunchAgents ``` @@ -44,7 +46,7 @@ Run that script alone in Terminal if you need to approve sudo before rerunning i ## Fresh install ```bash -USERNAME=$(whoami) scripts/run.sh install +USERNAME=$(whoami) bash "$SKILL_DIR/scripts/run.sh" install ``` Edit `~/.local/share/cursor-mcp/mcp.env` with your values, then: @@ -81,7 +83,7 @@ Use `local.<your-username>.cursor-mcp-env`. See [launchagent-naming.md](launchag ## Ongoing workflow - **Rotate a secret:** edit `~/.local/share/cursor-mcp/mcp.env` → `launchctl kickstart -k gui/$(id -u)/local.<username>.cursor-mcp-env` → restart Cursor -- **Debug:** `tail /tmp/cursor-mcp-env.log`, then `launchctl getenv <KEY>` for any key from `mcp.env` +- **Debug:** `tail ~/.local/state/cursor-mcp/load-mcp-env.log`, then `launchctl getenv <KEY>` for any key from `mcp.env` - **Never commit** `~/.local/share/cursor-mcp/mcp.env` ## Files @@ -90,6 +92,7 @@ Use `local.<your-username>.cursor-mcp-env`. See [launchagent-naming.md](launchag | ---- | ------- | | `~/.local/share/cursor-mcp/mcp.env` | Secret values (chmod 600) | | `~/.local/share/cursor-mcp/load-mcp-env.sh` | Parser + `launchctl setenv` | +| `~/.local/state/cursor-mcp/load-mcp-env.log` | Loader log (key names and counts, never values) | | `~/Library/LaunchAgents/local.<username>.cursor-mcp-env.plist` | Login hook | | `~/.cursor/mcp.json` and/or `<repo>/.cursor/mcp.json` | Credential-free MCP config | diff --git a/skills/ess/mcp-hide-secrets/references/rationale.md b/skills/ess/ess-mcp-hide-secrets/references/rationale.md similarity index 91% rename from skills/ess/mcp-hide-secrets/references/rationale.md rename to skills/ess/ess-mcp-hide-secrets/references/rationale.md index 189f05a..4fc4ad8 100644 --- a/skills/ess/mcp-hide-secrets/references/rationale.md +++ b/skills/ess/ess-mcp-hide-secrets/references/rationale.md @@ -27,6 +27,8 @@ Use a LaunchAgent at login to run `load-mcp-env.sh`, which reads `~/.local/share Secrets and the loader live under `~/.local/share/cursor-mcp/`, not `~/.cursor/`, so Cursor’s config directory stays limited to Cursor-owned files (principally `mcp.json`). +That directory is the [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/latest/) data directory, so `XDG_DATA_HOME` moves it, and the loader’s log goes to the XDG state directory instead of world-readable `/tmp`. The spec has no place for credentials; data rather than config (`~/.config`) keeps `mcp.env` out of dotfiles repos that track `~/.config`. The LaunchAgent runs at login without the shell’s XDG variables, so install writes the resolved paths into the plist as the loader’s arguments. + ## Why not `envFile`? `envFile` is a **good, standard** pattern — the same idea as VS Code `launch.json` / `tasks.json` `envFile`, scoped to the process being started. We do not use it as the primary approach here because of **scope**, not quality. diff --git a/skills/ess/mcp-hide-secrets/scripts/check-inline-secrets.py b/skills/ess/ess-mcp-hide-secrets/scripts/check-inline-secrets.py similarity index 100% rename from skills/ess/mcp-hide-secrets/scripts/check-inline-secrets.py rename to skills/ess/ess-mcp-hide-secrets/scripts/check-inline-secrets.py diff --git a/skills/ess/mcp-hide-secrets/scripts/fix-launchagents-dir.sh b/skills/ess/ess-mcp-hide-secrets/scripts/fix-launchagents-dir.sh similarity index 74% rename from skills/ess/mcp-hide-secrets/scripts/fix-launchagents-dir.sh rename to skills/ess/ess-mcp-hide-secrets/scripts/fix-launchagents-dir.sh index ace9282..862427b 100755 --- a/skills/ess/mcp-hide-secrets/scripts/fix-launchagents-dir.sh +++ b/skills/ess/ess-mcp-hide-secrets/scripts/fix-launchagents-dir.sh @@ -3,11 +3,14 @@ # Required before install.sh can bootstrap the cursor-mcp-env LaunchAgent. # # Usage: -# ./fix-launchagents-dir.sh +# ./fix-launchagents-dir.sh [--dry-run] # # Common case: directory exists but is owned by root (not writable). Runs: # sudo chown "$(whoami):staff" ~/Library/LaunchAgents # +# Called by run.sh, which confirms before writing. --dry-run prints the fix +# without running it. +# # See ../SKILL.md set -euo pipefail @@ -16,7 +19,18 @@ LAUNCH_AGENTS_DIR="${LAUNCH_AGENTS_DIR:-$HOME/Library/LaunchAgents}" CURRENT_USER="$(whoami)" EXPECTED_GROUP="staff" -mkdir -p "$LAUNCH_AGENTS_DIR" +DRY_RUN=false +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=true +fi + +if [ ! -d "$LAUNCH_AGENTS_DIR" ]; then + if [ "$DRY_RUN" = true ]; then + echo "Would create $LAUNCH_AGENTS_DIR" + exit 0 + fi + mkdir -p "$LAUNCH_AGENTS_DIR" +fi actual_owner="$(stat -f '%Su' "$LAUNCH_AGENTS_DIR")" actual_group="$(stat -f '%Sg' "$LAUNCH_AGENTS_DIR")" @@ -30,6 +44,12 @@ echo "LaunchAgents directory is not writable by $CURRENT_USER." echo " Path: $LAUNCH_AGENTS_DIR" echo " Owner: ${actual_owner}:${actual_group}" echo " Fix: sudo chown \"${CURRENT_USER}:${EXPECTED_GROUP}\" \"$LAUNCH_AGENTS_DIR\"" + +if [ "$DRY_RUN" = true ]; then + echo "Would run the fix above (sudo prompts for your password)." + exit 0 +fi + echo "" echo "Running fix (sudo will prompt for your password)..." diff --git a/skills/ess/mcp-hide-secrets/scripts/install.sh b/skills/ess/ess-mcp-hide-secrets/scripts/install.sh similarity index 54% rename from skills/ess/mcp-hide-secrets/scripts/install.sh rename to skills/ess/ess-mcp-hide-secrets/scripts/install.sh index facb5cb..3506d9e 100755 --- a/skills/ess/mcp-hide-secrets/scripts/install.sh +++ b/skills/ess/ess-mcp-hide-secrets/scripts/install.sh @@ -1,14 +1,20 @@ #!/bin/bash -# Install LaunchAgent + ~/.local/share/cursor-mcp/mcp.env for Cursor MCP secrets. +# Install LaunchAgent + mcp.env for Cursor MCP secrets. +# +# Files go under the XDG data directory (${XDG_DATA_HOME:-~/.local/share}/cursor-mcp/) +# and the loader logs to the XDG state directory (${XDG_STATE_HOME:-~/.local/state}/cursor-mcp/). +# Both are resolved now and written into the LaunchAgent plist. # # Usage: # USERNAME=your-username ./install.sh # fresh install from mcp.env.example # USERNAME=your-username ./install.sh --migrate-only [mcp.json] # rewrite mcp.json + mcp.env only # USERNAME=your-username ./install.sh --migrate [mcp.json] # alias for --migrate-only +# USERNAME=your-username ./install.sh --dry-run [...] # print planned changes only # ./install.sh --fix-launchagents # # --migrate / --migrate-only do NOT install the LaunchAgent; they exit after rewriting files. -# For migrate + LaunchAgent install, use scripts/run.sh (default or `migrate` / `install` modes). +# For migrate + LaunchAgent install, use scripts/run.sh (default or `migrate` / `install` modes), +# which confirms before writing. # # See ../SKILL.md @@ -20,6 +26,8 @@ source "$SCRIPT_DIR/lib.sh" MIGRATE=false MIGRATE_ONLY=false +DRY_RUN=false +FIX_LAUNCHAGENTS=false MCP_JSON="${MCP_JSON:-$GLOBAL_MCP_JSON}" while [ $# -gt 0 ]; do @@ -32,8 +40,13 @@ while [ $# -gt 0 ]; do MIGRATE_ONLY=true shift ;; + --dry-run) + DRY_RUN=true + shift + ;; --fix-launchagents) - exec "$SCRIPT_DIR/fix-launchagents-dir.sh" + FIX_LAUNCHAGENTS=true + shift ;; *) if [ -f "$1" ]; then @@ -44,6 +57,13 @@ while [ $# -gt 0 ]; do esac done +if [ "$FIX_LAUNCHAGENTS" = true ]; then + if [ "$DRY_RUN" = true ]; then + exec bash "$SCRIPT_DIR/fix-launchagents-dir.sh" --dry-run + fi + exec bash "$SCRIPT_DIR/fix-launchagents-dir.sh" +fi + if [ "$MIGRATE" = true ]; then MIGRATE_ONLY=true fi @@ -55,24 +75,29 @@ if [ -z "$USERNAME" ]; then exit 1 fi -LABEL="local.${USERNAME}.cursor-mcp-env" -PLIST_DEST="$HOME/Library/LaunchAgents/${LABEL}.plist" -MCP_ENV="$MCP_DATA_DIR/mcp.env" -LOADER_DEST="$MCP_DATA_DIR/load-mcp-env.sh" -LEGACY_MCP_ENV="$HOME/.cursor/mcp.env" LEGACY_LOADER="$HOME/.cursor/load-mcp-env.sh" -GUI_DOMAIN="gui/$(id -u)" -LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" +# Moves inline secrets from $MCP_JSON into $MCP_ENV. Prints key names, never values. +# Writes nothing when there is nothing to change, or when $DRY_RUN is true. migrate_mcp_json() { - python3 - "$MCP_JSON" "$MCP_ENV" <<'PY' + python3 - "$MCP_JSON" "$MCP_ENV" "$DRY_RUN" <<'PY' +from __future__ import annotations + import json +import os import re +import shutil import sys +import time from pathlib import Path mcp_json_path = Path(sys.argv[1]) mcp_env_path = Path(sys.argv[2]) +dry_run = sys.argv[3] == "true" + +# Files this creates (mcp.env, the backup) must never be readable by others. +os.umask(0o077) + config = json.loads(mcp_json_path.read_text(encoding="utf-8")) servers = config.get("mcpServers", {}) @@ -110,9 +135,13 @@ def header_env_key(server_name: str, header_name: str) -> str: return f"MCP_{normalize_token(server_name)}_{normalize_token(header_name)}" +moved_keys: list[str] = [] +dropped_headers: list[str] = [] + for server_name, server in servers.items(): - if server_name.lower() == "github": - server.pop("headers", None) + if server_name.lower() == "github" and "headers" in server: + server.pop("headers") + dropped_headers.append(server_name) env_block = server.get("env") if isinstance(env_block, dict): @@ -121,6 +150,7 @@ for server_name, server in servers.items(): continue existing_env[key] = value env_block[key] = f"${{env:{key}}}" + moved_keys.append(key) headers = server.get("headers") if isinstance(headers, dict): @@ -130,28 +160,50 @@ for server_name, server in servers.items(): env_key = header_env_key(server_name, key) existing_env[env_key] = value headers[key] = f"${{env:{env_key}}}" - -if existing_env: + moved_keys.append(env_key) + +if not moved_keys and not dropped_headers: + print(f"No inline secrets in {mcp_json_path}; nothing to migrate.") + raise SystemExit(0) + +backup_path = mcp_json_path.with_name(f"{mcp_json_path.name}.bak.{time.strftime('%Y%m%d%H%M%S')}") +moved_summary = f"{len(moved_keys)} secret(s) into {mcp_env_path}: {', '.join(moved_keys)}" + +if dry_run: + print(f"Would back up {mcp_json_path} to {backup_path}") + if moved_keys: + print(f"Would move {moved_summary}") + for server_name in dropped_headers: + print(f"Would drop the headers of server '{server_name}' so it can use OAuth") + print(f"Would rewrite {mcp_json_path} with ${{env:...}} references") + raise SystemExit(0) + +shutil.copyfile(mcp_json_path, backup_path) +os.chmod(backup_path, 0o600) +print(f"Backed up {mcp_json_path} to {backup_path}") +if moved_keys: mcp_env_path.write_text( "\n".join(f"{key}={value}" for key, value in sorted(existing_env.items())) + "\n", encoding="utf-8", ) - + os.chmod(mcp_env_path, 0o600) + print(f"Moved {moved_summary}") +for server_name in dropped_headers: + print(f"Dropped the headers of server '{server_name}' so it can use OAuth") mcp_json_path.write_text( json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) +print(f"Rewrote {mcp_json_path} with ${{env:...}} references") PY - chmod 600 "$MCP_ENV" - echo "Migrated: $MCP_JSON → $MCP_ENV" } -mkdir -p "$HOME/.cursor" "$MCP_DATA_DIR" "$LAUNCH_AGENTS_DIR" +if [ "$DRY_RUN" = false ]; then + mkdir -p "$HOME/.cursor" "$MCP_DATA_DIR" "$MCP_STATE_DIR" "$LAUNCH_AGENTS_DIR" +fi -if [ -f "$LEGACY_MCP_ENV" ] && [ ! -f "$MCP_ENV" ]; then - mv "$LEGACY_MCP_ENV" "$MCP_ENV" - chmod 600 "$MCP_ENV" - echo "Moved legacy secrets: $LEGACY_MCP_ENV → $MCP_ENV" +if ! preserve_mcp_env; then + exit 1 fi if [ "$MIGRATE_ONLY" = true ]; then @@ -160,13 +212,35 @@ if [ "$MIGRATE_ONLY" = true ]; then echo " Tried: $MCP_JSON" >&2 exit 1 fi - backup_path="$MCP_JSON.bak.$(date +%Y%m%d%H%M%S)" - cp "$MCP_JSON" "$backup_path" - chmod 600 "$backup_path" migrate_mcp_json exit 0 fi +if [ "$DRY_RUN" = true ]; then + echo "Would install loader: $LOADER_DEST" + if [ -f "$LEGACY_LOADER" ] && [ "$LEGACY_LOADER" != "$LOADER_DEST" ]; then + echo "Would remove legacy loader: $LEGACY_LOADER" + fi + if [ -f "$MCP_ENV" ]; then + echo "Would keep existing $MCP_ENV" + elif [ -n "$(mcp_env_migration_source)" ]; then + echo "Would keep the copied $MCP_ENV" + else + echo "Would create $MCP_ENV from mcp.env.example unless a migration above creates it" + fi + "$SCRIPT_DIR/fix-launchagents-dir.sh" --dry-run + echo "Would write LaunchAgent plist: $PLIST_DEST" + echo " runs: $LOADER_DEST" + echo " loads: $MCP_ENV" + echo " logs to: $LOG_FILE" + if launchctl print "$GUI_DOMAIN/$LABEL" >/dev/null 2>&1; then + echo "Would reload LaunchAgent $LABEL and load $MCP_ENV into the GUI session" + else + echo "Would load LaunchAgent $LABEL and load $MCP_ENV into the GUI session" + fi + exit 0 +fi + install -m 700 "$SCRIPT_DIR/load-mcp-env.sh" "$LOADER_DEST" if [ -f "$LEGACY_LOADER" ] && [ "$LEGACY_LOADER" != "$LOADER_DEST" ]; then @@ -175,10 +249,10 @@ if [ -f "$LEGACY_LOADER" ] && [ "$LEGACY_LOADER" != "$LOADER_DEST" ]; then fi if [ ! -f "$MCP_ENV" ]; then - cp "$SCRIPT_DIR/mcp.env.example" "$MCP_ENV" + (umask 077 && cp "$SCRIPT_DIR/mcp.env.example" "$MCP_ENV") chmod 600 "$MCP_ENV" echo "Created $MCP_ENV from example — fill in secret values before restarting Cursor." -elif [ -f "$MCP_ENV" ]; then +else echo "Keeping existing $MCP_ENV" fi @@ -187,19 +261,20 @@ if ! "$SCRIPT_DIR/fix-launchagents-dir.sh"; then exit 1 fi -sed "s/local.your-username.cursor-mcp-env/${LABEL}/g" \ - "$SCRIPT_DIR/local.your-username.cursor-mcp-env.plist" >"$PLIST_DEST" +python3 "$SCRIPT_DIR/render-plist.py" "$SCRIPT_DIR/local.your-username.cursor-mcp-env.plist" \ + "$PLIST_DEST" "$LABEL" "$LOADER_DEST" "$MCP_ENV" "$LOG_FILE" if launchctl print "$GUI_DOMAIN/$LABEL" >/dev/null 2>&1; then launchctl bootout "$GUI_DOMAIN/$LABEL" || true fi launchctl bootstrap "$GUI_DOMAIN" "$PLIST_DEST" -"$LOADER_DEST" +"$LOADER_DEST" "$MCP_ENV" "$LOG_FILE" "$SCRIPT_DIR/verify.sh" echo "Installed LaunchAgent: $LABEL" echo "Loader: $LOADER_DEST" echo "Secrets: $MCP_ENV" +echo "Log: $LOG_FILE" echo "Reload after edits: launchctl kickstart -k $GUI_DOMAIN/$LABEL && restart Cursor (Cmd+Q)" diff --git a/skills/ess/ess-mcp-hide-secrets/scripts/lib.sh b/skills/ess/ess-mcp-hide-secrets/scripts/lib.sh new file mode 100755 index 0000000..45664ac --- /dev/null +++ b/skills/ess/ess-mcp-hide-secrets/scripts/lib.sh @@ -0,0 +1,77 @@ +# Shared paths for ess-mcp-hide-secrets scripts. From sibling scripts: +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# # shellcheck source=lib.sh +# source "$SCRIPT_DIR/lib.sh" + +MCP_HIDE_SECRETS_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Prints an XDG base directory: $1 when it is an absolute path, else the default $2. +# The XDG Base Directory spec says relative values are invalid and must be ignored. +xdg_base_dir() { + case "${1:-}" in + /*) echo "$1" ;; + *) echo "$2" ;; + esac +} + +USERNAME="${USERNAME:-$(whoami)}" +LABEL="local.${USERNAME}.cursor-mcp-env" +GLOBAL_MCP_JSON="${GLOBAL_MCP_JSON:-$HOME/.cursor/mcp.json}" +PROJECT_MCP_JSON="${PROJECT_MCP_JSON:-}" +MCP_DATA_DIR="${MCP_DATA_DIR:-$(xdg_base_dir "${XDG_DATA_HOME:-}" "$HOME/.local/share")/cursor-mcp}" +MCP_STATE_DIR="${MCP_STATE_DIR:-$(xdg_base_dir "${XDG_STATE_HOME:-}" "$HOME/.local/state")/cursor-mcp}" +MCP_ENV="$MCP_DATA_DIR/mcp.env" +# The path install used before XDG_DATA_HOME, plus the pre-XDG file under ~/.cursor. +DEFAULT_MCP_ENV="$HOME/.local/share/cursor-mcp/mcp.env" +LEGACY_MCP_ENV="$HOME/.cursor/mcp.env" +LOADER_DEST="$MCP_DATA_DIR/load-mcp-env.sh" +LOG_FILE="$MCP_STATE_DIR/load-mcp-env.log" +PLIST_DEST="$HOME/Library/LaunchAgents/${LABEL}.plist" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" +GUI_DOMAIN="gui/$(id -u)" +CHECK_INLINE_SECRETS="$MCP_HIDE_SECRETS_LIB_DIR/check-inline-secrets.py" + +# Prints one existing secrets file to copy when $MCP_ENV is missing. +# Prints nothing when there is nothing to copy. Returns 1 when the default and +# legacy files both exist and differ, so the caller does not guess. +mcp_env_migration_source() { + if [ -f "$MCP_ENV" ]; then + return 0 + fi + local -a candidates=() + local candidate + for candidate in "$DEFAULT_MCP_ENV" "$LEGACY_MCP_ENV"; do + if [ -f "$candidate" ] && [ "$candidate" != "$MCP_ENV" ]; then + candidates+=("$candidate") + fi + done + if [ "${#candidates[@]}" -eq 0 ]; then + return 0 + fi + if [ "${#candidates[@]}" -eq 1 ] || cmp -s "${candidates[0]}" "${candidates[1]}"; then + printf '%s\n' "${candidates[0]}" + return 0 + fi + echo "ERROR: $MCP_ENV is missing, but ${candidates[0]} and ${candidates[1]} both exist and differ." >&2 + echo "Copy the credentials you want to keep to $MCP_ENV, then rerun install." >&2 + return 1 +} + +# Copy an existing secrets file to $MCP_ENV. Does not overwrite $MCP_ENV. +preserve_mcp_env() { + local source="" + if ! source="$(mcp_env_migration_source)"; then + return 1 + fi + if [ -z "$source" ]; then + return 0 + fi + if [ "${DRY_RUN:-false}" = true ]; then + echo "Would copy secrets: $source → $MCP_ENV" + return 0 + fi + mkdir -p "$(dirname "$MCP_ENV")" + (umask 077 && cp "$source" "$MCP_ENV") + chmod 600 "$MCP_ENV" + echo "Copied secrets: $source → $MCP_ENV" +} diff --git a/skills/ess/mcp-hide-secrets/scripts/load-mcp-env.sh b/skills/ess/ess-mcp-hide-secrets/scripts/load-mcp-env.sh similarity index 57% rename from skills/ess/mcp-hide-secrets/scripts/load-mcp-env.sh rename to skills/ess/ess-mcp-hide-secrets/scripts/load-mcp-env.sh index 77ec62d..5e89ec2 100755 --- a/skills/ess/mcp-hide-secrets/scripts/load-mcp-env.sh +++ b/skills/ess/ess-mcp-hide-secrets/scripts/load-mcp-env.sh @@ -1,18 +1,39 @@ #!/bin/bash -# Load ~/.local/share/cursor-mcp/mcp.env into the macOS GUI session via launchctl setenv. +# Load mcp.env into the macOS GUI session via launchctl setenv. # Parses line-by-line without sourcing (no shell expansion on values). +# +# Usage: +# load-mcp-env.sh [mcp.env] [log file] +# +# The LaunchAgent passes both paths, resolved at install time, because it runs at +# login without your shell's XDG variables. Without arguments, the defaults follow +# the XDG Base Directory spec like lib.sh does (this file is installed on its own, +# so it cannot source lib.sh). set -u -ENV_FILE="${MCP_ENV_FILE:-$HOME/.local/share/cursor-mcp/mcp.env}" -LOG="${MCP_ENV_LOG:-/tmp/cursor-mcp-env.log}" +case "${XDG_DATA_HOME:-}" in + /*) data_home="$XDG_DATA_HOME" ;; + *) data_home="$HOME/.local/share" ;; +esac +case "${XDG_STATE_HOME:-}" in + /*) state_home="$XDG_STATE_HOME" ;; + *) state_home="$HOME/.local/state" ;; +esac + +ENV_FILE="${1:-$data_home/cursor-mcp/mcp.env}" +LOG="${2:-$state_home/cursor-mcp/load-mcp-env.log}" +mkdir -p "$(dirname "$LOG")" log() { printf '%s [load-mcp-env] %s\n' "$(date -Iseconds)" "$*" >>"$LOG" } log_err() { - printf '%s [load-mcp-env] %s\n' "$(date -Iseconds)" "$*" >>"$LOG" >&2 + local message + message="$(printf '%s [load-mcp-env] %s\n' "$(date -Iseconds)" "$*")" + printf '%s' "$message" >>"$LOG" + printf '%s' "$message" >&2 } is_valid_key() { diff --git a/skills/ess/mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist b/skills/ess/ess-mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist similarity index 61% rename from skills/ess/mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist rename to skills/ess/ess-mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist index 142567b..2fd98d5 100644 --- a/skills/ess/mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist +++ b/skills/ess/ess-mcp-hide-secrets/scripts/local.your-username.cursor-mcp-env.plist @@ -8,14 +8,11 @@ <key>ProgramArguments</key> <array> <string>/bin/bash</string> - <string>-c</string> - <string>$HOME/.local/share/cursor-mcp/load-mcp-env.sh</string> + <string>LOADER_PATH</string> + <string>MCP_ENV_PATH</string> + <string>LOG_PATH</string> </array> <key>RunAtLoad</key> <true/> - <key>StandardOutPath</key> - <string>/tmp/cursor-mcp-env.log</string> - <key>StandardErrorPath</key> - <string>/tmp/cursor-mcp-env.log</string> </dict> </plist> diff --git a/skills/ess/mcp-hide-secrets/scripts/mcp.env.example b/skills/ess/ess-mcp-hide-secrets/scripts/mcp.env.example similarity index 84% rename from skills/ess/mcp-hide-secrets/scripts/mcp.env.example rename to skills/ess/ess-mcp-hide-secrets/scripts/mcp.env.example index 7ec04eb..23f8f25 100644 --- a/skills/ess/mcp-hide-secrets/scripts/mcp.env.example +++ b/skills/ess/ess-mcp-hide-secrets/scripts/mcp.env.example @@ -1,4 +1,4 @@ -# Installed to ~/.local/share/cursor-mcp/mcp.env (chmod 600). +# Installed to ${XDG_DATA_HOME:-~/.local/share}/cursor-mcp/mcp.env (chmod 600). # One KEY=VALUE per line. No quotes, no export, no shell expansion. # Comments and blank lines are ignored. # diff --git a/skills/ess/mcp-hide-secrets/scripts/preflight.sh b/skills/ess/ess-mcp-hide-secrets/scripts/preflight.sh similarity index 92% rename from skills/ess/mcp-hide-secrets/scripts/preflight.sh rename to skills/ess/ess-mcp-hide-secrets/scripts/preflight.sh index 07614a4..42cda08 100755 --- a/skills/ess/mcp-hide-secrets/scripts/preflight.sh +++ b/skills/ess/ess-mcp-hide-secrets/scripts/preflight.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Read-only preflight for mcp-hide-secrets. Never reads or prints secret values. +# Read-only preflight for ess-mcp-hide-secrets. Never reads or prints secret values. # # Usage: # ./preflight.sh @@ -15,7 +15,7 @@ source "$SCRIPT_DIR/lib.sh" issues=0 -echo "== mcp-hide-secrets preflight ==" +echo "== ess-mcp-hide-secrets preflight ==" if [ "$(uname -s)" != "Darwin" ]; then echo "FAIL: macOS required (got $(uname -s))" diff --git a/skills/ess/ess-mcp-hide-secrets/scripts/render-plist.py b/skills/ess/ess-mcp-hide-secrets/scripts/render-plist.py new file mode 100755 index 0000000..13426cb --- /dev/null +++ b/skills/ess/ess-mcp-hide-secrets/scripts/render-plist.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Render the LaunchAgent plist from its template with absolute, resolved paths. + +Usage: render-plist.py <template> <dest> <label> <loader> <mcp.env> <log file> + +The LaunchAgent runs at login without the shell's XDG variables, so every path it +needs is written into the plist at install time. plistlib escapes any path safely. +""" + +from __future__ import annotations + +import os +import plistlib +import sys +from pathlib import Path + +_ARGC = 7 +_USAGE_EXIT_CODE = 2 + + +def render( + template: Path, label: str, loader: str, mcp_env: str, log_file: str +) -> dict: + """Return the template's plist with the label and loader arguments filled in.""" + for path in (loader, mcp_env, log_file): + if not os.path.isabs(path): + raise ValueError(f"path must be absolute: {path}") + + with template.open("rb") as handle: + plist = plistlib.load(handle) + + placeholders = { + "LOADER_PATH": loader, + "MCP_ENV_PATH": mcp_env, + "LOG_PATH": log_file, + } + plist["Label"] = label + plist["ProgramArguments"] = [ + placeholders.get(arg, arg) for arg in plist["ProgramArguments"] + ] + plist["StandardErrorPath"] = log_file + return plist + + +def main() -> int: + if len(sys.argv) != _ARGC: + print(__doc__.strip().splitlines()[2], file=sys.stderr) + return _USAGE_EXIT_CODE + + template, dest, label, loader, mcp_env, log_file = sys.argv[1:] + try: + plist = render(Path(template), label, loader, mcp_env, log_file) + except ValueError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + with open(dest, "wb") as handle: + plistlib.dump(plist, handle) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ess/ess-mcp-hide-secrets/scripts/run.sh b/skills/ess/ess-mcp-hide-secrets/scripts/run.sh new file mode 100755 index 0000000..ce837fe --- /dev/null +++ b/skills/ess/ess-mcp-hide-secrets/scripts/run.sh @@ -0,0 +1,202 @@ +#!/bin/bash +# Entry point for /ess-mcp-hide-secrets — run this script only; do not improvise checks in the agent. +# +# Usage: +# USERNAME=$(whoami) ./run.sh # install (migrate if needed, then LaunchAgent) +# USERNAME=$(whoami) ./run.sh migrate # ~/.cursor/mcp.json +# USERNAME=$(whoami) ./run.sh migrate .cursor/mcp.json +# USERNAME=$(whoami) ./run.sh status +# USERNAME=$(whoami) ./run.sh preflight +# ./run.sh fix-launchagents +# +# Write modes (default, migrate, install, fix-launchagents) print the planned +# changes and prompt before writing: +# --dry-run print the planned changes and exit without writing +# --yes, -y skip the prompt (required when stdin is not a terminal) +# +# Optional: +# PROJECT_MCP_JSON=/path/to/repo/.cursor/mcp.json ./run.sh +# +# See ../SKILL.md + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +if [ -z "${USERNAME:-}" ]; then + echo "ERROR: set USERNAME (your macOS username)." >&2 + echo "Example: USERNAME=\$(whoami) $0" >&2 + exit 1 +fi + +DRY_RUN=false +ASSUME_YES=false +MODE="" +MCP_JSON_ARG="" +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --yes | -y) ASSUME_YES=true ;; + -*) + echo "ERROR: unknown option: $arg" >&2 + exit 1 + ;; + *) + if [ -z "$MODE" ]; then + MODE="$arg" + else + MCP_JSON_ARG="$arg" + fi + ;; + esac +done +MODE="${MODE:-default}" + +# Set by execute: true while building the plan, false while applying it. +PLANNING=false +PREFLIGHT_DONE=false + +# Runs a script that writes, adding --dry-run while planning. +# bash so a preview does not need the helper to be executable. +step() { + if [ "$PLANNING" = true ]; then + bash "$@" --dry-run + else + bash "$@" + fi +} + +preflight_once() { + if [ "$PREFLIGHT_DONE" = false ]; then + bash "$SCRIPT_DIR/preflight.sh" + PREFLIGHT_DONE=true + fi +} + +# Read-only checks that only make sense after a real write. +after_write() { + if [ "$PLANNING" = false ]; then + bash "$@" + fi +} + +fix_launchagents_or_exit() { + if step "$SCRIPT_DIR/fix-launchagents-dir.sh"; then + return 0 + fi + echo "" + echo "Secrets migrated (if any). LaunchAgent step needs sudo in Terminal:" + echo " $SCRIPT_DIR/run.sh fix-launchagents" + echo " USERNAME=$USERNAME $SCRIPT_DIR/run.sh install" + after_write "$SCRIPT_DIR/status.sh" + exit 1 +} + +mcp_json_paths_need_migrate() { + local path + for path in "$@"; do + [ -f "$path" ] || continue + if python3 "$CHECK_INLINE_SECRETS" "$path" >/dev/null 2>&1; then + continue + fi + echo "$path" + done +} + +execute() { + case "$MODE" in + fix-launchagents) + step "$SCRIPT_DIR/fix-launchagents-dir.sh" + ;; + migrate) + preflight_once + step "$SCRIPT_DIR/install.sh" --migrate-only "${MCP_JSON_ARG:-$GLOBAL_MCP_JSON}" + fix_launchagents_or_exit + step "$SCRIPT_DIR/install.sh" + after_write "$SCRIPT_DIR/verify.sh" + ;; + install) + preflight_once + fix_launchagents_or_exit + step "$SCRIPT_DIR/install.sh" + after_write "$SCRIPT_DIR/verify.sh" + ;; + default) + preflight_once + + local paths=("$GLOBAL_MCP_JSON") + if [ -n "$PROJECT_MCP_JSON" ] && [ -f "$PROJECT_MCP_JSON" ]; then + paths+=("$PROJECT_MCP_JSON") + fi + + local dirty_path + while IFS= read -r dirty_path; do + [ -n "$dirty_path" ] || continue + echo "" + echo "== migrate $dirty_path ==" + step "$SCRIPT_DIR/install.sh" --migrate-only "$dirty_path" + done < <(mcp_json_paths_need_migrate "${paths[@]}") + + fix_launchagents_or_exit + + echo "" + echo "== install LaunchAgent ==" + step "$SCRIPT_DIR/install.sh" + after_write "$SCRIPT_DIR/verify.sh" + after_write "$SCRIPT_DIR/status.sh" + ;; + esac +} + +confirm_or_abort() { + if [ "$ASSUME_YES" = true ]; then + return 0 + fi + if [ ! -t 0 ]; then + echo "ERROR: stdin is not a terminal; review the plan with --dry-run, then rerun with --yes." >&2 + exit 1 + fi + local answer + printf '\nApply these changes for %s (LaunchAgent %s)? [y/N] ' "$USERNAME" "$LABEL" >&2 + read -r answer + case "$answer" in + y | Y | yes | YES) return 0 ;; + *) + echo "Aborted; nothing was written." >&2 + exit 1 + ;; + esac +} + +case "$MODE" in + status | preflight) + if [ "$DRY_RUN" = true ] || [ "$ASSUME_YES" = true ]; then + echo "ERROR: $MODE is read-only; --dry-run and --yes do not apply." >&2 + exit 1 + fi + exec bash "$SCRIPT_DIR/$MODE.sh" + ;; + default | migrate | install | fix-launchagents) ;; + *) + echo "ERROR: unknown mode: $MODE" >&2 + echo "Usage: $0 [default|migrate|install|status|preflight|fix-launchagents] [mcp.json] [--dry-run] [--yes]" >&2 + exit 1 + ;; +esac + +if [ "$DRY_RUN" = true ] || [ "$ASSUME_YES" = false ]; then + echo "== plan: $MODE (nothing is written) ==" + PLANNING=true + execute + PLANNING=false + if [ "$DRY_RUN" = true ]; then + exit 0 + fi + confirm_or_abort + echo "" + echo "== apply: $MODE ==" +fi + +execute diff --git a/skills/ess/mcp-hide-secrets/scripts/status.sh b/skills/ess/ess-mcp-hide-secrets/scripts/status.sh similarity index 93% rename from skills/ess/mcp-hide-secrets/scripts/status.sh rename to skills/ess/ess-mcp-hide-secrets/scripts/status.sh index 9fef80e..9bfe5f2 100755 --- a/skills/ess/mcp-hide-secrets/scripts/status.sh +++ b/skills/ess/ess-mcp-hide-secrets/scripts/status.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Read-only status for mcp-hide-secrets. Never reads or prints secret values. +# Read-only status for ess-mcp-hide-secrets. Never reads or prints secret values. # # Usage: # ./status.sh diff --git a/skills/ess/ess-mcp-hide-secrets/scripts/test_run.sh b/skills/ess/ess-mcp-hide-secrets/scripts/test_run.sh new file mode 100755 index 0000000..6181865 --- /dev/null +++ b/skills/ess/ess-mcp-hide-secrets/scripts/test_run.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +# Self-test for run.sh write safety and install.sh migration. +# Each case runs against a temporary HOME with stub launchctl and sudo, so it +# never touches your real mcp.json, mcp.env, LaunchAgents, or GUI session. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FAKE_SECRET="not-a-real-secret" +PASSED=0 +FAILED=0 + +pass() { + echo " PASS: $1" + PASSED=$((PASSED + 1)) +} +fail() { + echo " FAIL: $1" >&2 + FAILED=$((FAILED + 1)) +} +check() { + local description="$1" + shift + if "$@"; then pass "$description"; else fail "$description"; fi +} + +# Creates a temporary HOME holding an mcp.json with inline secrets, a writable +# LaunchAgents directory, and stubs that log every launchctl or sudo call. +# Prints the HOME path. +# $1 "dirty" (default) or "clean" mcp.json +setup_home() { + local kind="${1:-dirty}" + local home + home=$(mktemp -d) + mkdir -p "$home/.cursor" "$home/Library/LaunchAgents" "$home/bin" + if [ "$kind" = dirty ]; then + cat >"$home/.cursor/mcp.json" <<EOF +{ + "mcpServers": { + "my-api": { + "command": "my-api", + "env": {"MY_API_KEY": "$FAKE_SECRET", "ATLASSIAN_USER_EMAIL": "me@example.com"} + }, + "github": { + "url": "https://mcp.example.com/mcp", + "headers": {"Authorization": "Bearer $FAKE_SECRET"} + } + } +} +EOF + else + cat >"$home/.cursor/mcp.json" <<'EOF' +{ + "mcpServers": { + "my-api": {"command": "my-api", "env": {"MY_API_KEY": "${env:MY_API_KEY}"}} + } +} +EOF + fi + cp "$home/.cursor/mcp.json" "$home/mcp.json.orig" + for stub in launchctl sudo; do + cat >"$home/bin/$stub" <<EOF +#!/bin/bash +echo "$stub \$*" >>"$home/stub.log" +[ "$stub" = launchctl ] && [ "\${1:-}" = print ] && exit 1 +exit 0 +EOF + chmod +x "$home/bin/$stub" + done + touch "$home/stub.log" + echo "$home" +} + +# Runs a skill script with the temporary HOME and stubs. Prints combined output. +# Set TEST_XDG_DATA_HOME or TEST_XDG_STATE_HOME to pass XDG variables through. +# $1 HOME from setup_home; $2 script name; remaining args go to the script +run_in() { + local home="$1" script="$2" + shift 2 + env -u MCP_DATA_DIR -u MCP_STATE_DIR -u GLOBAL_MCP_JSON -u PROJECT_MCP_JSON -u MCP_JSON \ + -u XDG_DATA_HOME -u XDG_STATE_HOME \ + ${TEST_XDG_DATA_HOME:+"XDG_DATA_HOME=$TEST_XDG_DATA_HOME"} \ + ${TEST_XDG_STATE_HOME:+"XDG_STATE_HOME=$TEST_XDG_STATE_HOME"} \ + HOME="$home" USERNAME="$(whoami)" PATH="$home/bin:$PATH" \ + bash "$SCRIPT_DIR/$script" "$@" </dev/null 2>&1 +} + +mcp_json_unchanged() { cmp -s "$1/.cursor/mcp.json" "$1/mcp.json.orig"; } +no_mcp_env() { [ ! -e "$1/.local/share/cursor-mcp/mcp.env" ]; } +no_backup() { ! compgen -G "$1/.cursor/mcp.json.bak.*" >/dev/null; } +no_plist() { [ -z "$(ls -A "$1/Library/LaunchAgents")" ]; } +no_launchctl_writes() { ! grep -qE '^(launchctl (bootstrap|bootout|setenv|kickstart)|sudo)' "$1/stub.log"; } +nothing_written() { + mcp_json_unchanged "$1" && no_mcp_env "$1" && no_backup "$1" && no_plist "$1" && no_launchctl_writes "$1" +} +contains() { grep -qF -- "$2" <<<"$1"; } +lacks() { ! grep -qF -- "$2" <<<"$1"; } +mode_of() { stat -f '%Lp' "$1"; } + +echo "=== run.sh / install.sh self-tests ===" + +# --------------------------------------------------------------------------- +echo "-- --dry-run prints the plan and writes nothing --" +home=$(setup_home) +status=0 +output=$(run_in "$home" run.sh --dry-run) || status=$? +check "exits 0" [ "$status" -eq 0 ] +check "names the key it would move" contains "$output" "Would move 1 secret(s)" +check "lists the key name" contains "$output" "MY_API_KEY" +check "reports the github headers it would drop" contains "$output" "drop the headers of server 'github'" +check "never prints the secret value" lacks "$output" "$FAKE_SECRET" +check "writes nothing" nothing_written "$home" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- --dry-run wins over --yes --" +home=$(setup_home) +status=0 +output=$(run_in "$home" run.sh migrate --dry-run --yes) || status=$? +check "exits 0" [ "$status" -eq 0 ] +check "writes nothing" nothing_written "$home" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- a real write without a terminal requires --yes --" +home=$(setup_home) +status=0 +output=$(run_in "$home" run.sh) || status=$? +check "exits non-zero" [ "$status" -ne 0 ] +check "tells the operator to pass --yes" contains "$output" "--yes" +check "writes nothing" nothing_written "$home" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- --yes skips the prompt --" +home=$(setup_home) +status=0 +output=$(run_in "$home" run.sh fix-launchagents --yes) || status=$? +check "exits 0 with no stdin" [ "$status" -eq 0 ] +check "runs the step" contains "$output" "is writable by" +check "prints no plan" lacks "$output" "== plan" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- read-only modes reject write flags --" +home=$(setup_home) +for mode in status preflight; do + status=0 + output=$(run_in "$home" run.sh "$mode" --dry-run) || status=$? + check "$mode --dry-run exits non-zero" [ "$status" -ne 0 ] +done +status=0 +output=$(run_in "$home" run.sh --bogus) || status=$? +check "unknown option exits non-zero" [ "$status" -ne 0 ] +check "unknown option writes nothing" nothing_written "$home" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- migrate creates owner-only files under a permissive umask --" +home=$(setup_home) +status=0 +output=$(umask 022 && run_in "$home" install.sh --migrate-only "$home/.cursor/mcp.json") || status=$? +mcp_env="$home/.local/share/cursor-mcp/mcp.env" +backup=$(compgen -G "$home/.cursor/mcp.json.bak.*" | head -1 || true) +check "exits 0" [ "$status" -eq 0 ] +check "mcp.env is mode 600" [ "$(mode_of "$mcp_env")" = 600 ] +check "backup is mode 600" [ "$(mode_of "${backup:-/nonexistent}" 2>/dev/null)" = 600 ] +check "mcp.env holds the secret" grep -qx "MY_API_KEY=$FAKE_SECRET" "$mcp_env" +check "mcp.json references it" grep -qF '"MY_API_KEY": "${env:MY_API_KEY}"' "$home/.cursor/mcp.json" +check "mcp.json no longer holds the secret" lacks "$(cat "$home/.cursor/mcp.json")" "$FAKE_SECRET" +check "identifiers stay inline" grep -qF '"ATLASSIAN_USER_EMAIL": "me@example.com"' "$home/.cursor/mcp.json" +check "never prints the secret value" lacks "$output" "$FAKE_SECRET" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- migrate on a clean mcp.json with no mcp.env --" +home=$(setup_home clean) +status=0 +output=$(run_in "$home" install.sh --migrate-only "$home/.cursor/mcp.json") || status=$? +check "exits 0" [ "$status" -eq 0 ] +check "says there is nothing to migrate" contains "$output" "nothing to migrate" +check "leaves mcp.json unchanged" mcp_json_unchanged "$home" +check "makes no backup" no_backup "$home" +check "creates no mcp.env" no_mcp_env "$home" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- an absolute XDG_DATA_HOME moves mcp.env --" +home=$(setup_home) +status=0 +output=$(TEST_XDG_DATA_HOME="$home/xdg-data" run_in "$home" install.sh --migrate-only "$home/.cursor/mcp.json") || status=$? +check "exits 0" [ "$status" -eq 0 ] +check "writes mcp.env under XDG_DATA_HOME" [ -f "$home/xdg-data/cursor-mcp/mcp.env" ] +check "writes nothing under the default directory" no_mcp_env "$home" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- a relative XDG_DATA_HOME is ignored --" +home=$(setup_home) +status=0 +output=$(cd "$home" && TEST_XDG_DATA_HOME="relative-data" run_in "$home" install.sh --migrate-only "$home/.cursor/mcp.json") || status=$? +check "exits 0" [ "$status" -eq 0 ] +check "writes mcp.env under the default directory" [ -f "$home/.local/share/cursor-mcp/mcp.env" ] +check "creates nothing under the relative path" [ ! -e "$home/relative-data" ] +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- the install plan shows the resolved XDG paths --" +home=$(setup_home) +status=0 +output=$(TEST_XDG_DATA_HOME="$home/xdg-data" TEST_XDG_STATE_HOME="$home/xdg-state" \ + run_in "$home" run.sh install --dry-run) || status=$? +check "exits 0" [ "$status" -eq 0 ] +check "loader under XDG_DATA_HOME" contains "$output" "runs: $home/xdg-data/cursor-mcp/load-mcp-env.sh" +check "mcp.env under XDG_DATA_HOME" contains "$output" "loads: $home/xdg-data/cursor-mcp/mcp.env" +check "log under XDG_STATE_HOME" contains "$output" "logs to: $home/xdg-state/cursor-mcp/load-mcp-env.log" +check "writes nothing" nothing_written "$home" +check "creates no XDG data directory" [ ! -e "$home/xdg-data" ] +check "creates no XDG state directory" [ ! -e "$home/xdg-state" ] +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- render-plist.py writes absolute paths into a valid plist --" +tmp=$(mktemp -d) +odd_dir="$tmp/a & b <c>" +status=0 +python3 "$SCRIPT_DIR/render-plist.py" "$SCRIPT_DIR/local.your-username.cursor-mcp-env.plist" \ + "$tmp/agent.plist" "local.tester.cursor-mcp-env" \ + "$odd_dir/load-mcp-env.sh" "$odd_dir/mcp.env" "$tmp/state/load-mcp-env.log" || status=$? +check "exits 0" [ "$status" -eq 0 ] +check "plist passes plutil -lint" plutil -lint -s "$tmp/agent.plist" +check "sets the label" [ "$(plutil -extract Label raw "$tmp/agent.plist")" = "local.tester.cursor-mcp-env" ] +check "runs the loader with bash" [ "$(plutil -extract ProgramArguments.0 raw "$tmp/agent.plist")" = "/bin/bash" ] +check "passes the loader path, escaped" [ "$(plutil -extract ProgramArguments.1 raw "$tmp/agent.plist")" = "$odd_dir/load-mcp-env.sh" ] +check "passes the mcp.env path" [ "$(plutil -extract ProgramArguments.2 raw "$tmp/agent.plist")" = "$odd_dir/mcp.env" ] +check "passes the log path" [ "$(plutil -extract ProgramArguments.3 raw "$tmp/agent.plist")" = "$tmp/state/load-mcp-env.log" ] +check "routes stderr to the log" [ "$(plutil -extract StandardErrorPath raw "$tmp/agent.plist")" = "$tmp/state/load-mcp-env.log" ] +check "no longer logs to /tmp" lacks "$(cat "$tmp/agent.plist")" "/tmp/cursor-mcp-env.log" +status=0 +python3 "$SCRIPT_DIR/render-plist.py" "$SCRIPT_DIR/local.your-username.cursor-mcp-env.plist" \ + "$tmp/relative.plist" "local.tester.cursor-mcp-env" \ + "relative/load-mcp-env.sh" "$odd_dir/mcp.env" "$tmp/log" 2>/dev/null || status=$? +check "rejects a relative path" [ "$status" -ne 0 ] +check "writes no plist for it" [ ! -e "$tmp/relative.plist" ] +rm -rf "$tmp" + +# --------------------------------------------------------------------------- +echo "-- the loader takes its paths as arguments, else XDG defaults --" +home=$(setup_home) +status=0 +run_in "$home" load-mcp-env.sh "$home/none/mcp.env" "$home/logs/loader.log" >/dev/null || status=$? +check "exits 0 when mcp.env is missing" [ "$status" -eq 0 ] +check "creates the log directory and logs there" grep -qF "missing $home/none/mcp.env" "$home/logs/loader.log" +TEST_XDG_DATA_HOME="$home/xdg-data" TEST_XDG_STATE_HOME="$home/xdg-state" \ + run_in "$home" load-mcp-env.sh >/dev/null || true +check "defaults the log to XDG_STATE_HOME" grep -qF "missing $home/xdg-data/cursor-mcp/mcp.env" \ + "$home/xdg-state/cursor-mcp/load-mcp-env.log" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- invalid env lines are written to the log --" +home=$(setup_home) +printf 'not a valid line\n' >"$home/mcp.env" +status=0 +run_in "$home" load-mcp-env.sh "$home/mcp.env" "$home/logs/loader.log" >/dev/null || status=$? +check "exits non-zero" [ "$status" -ne 0 ] +check "records the invalid line" grep -q "skip invalid line" "$home/logs/loader.log" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- --dry-run --fix-launchagents does not create LaunchAgents --" +home=$(setup_home) +rm -rf "$home/Library" +status=0 +output=$(run_in "$home" install.sh --dry-run --fix-launchagents) || status=$? +check "exits 0 with --dry-run first" [ "$status" -eq 0 ] +check "does not create LaunchAgents with --dry-run first" [ ! -e "$home/Library/LaunchAgents" ] +check "previews the create with --dry-run first" contains "$output" "Would create $home/Library/LaunchAgents" +rm -rf "$home" +home=$(setup_home) +rm -rf "$home/Library" +status=0 +output=$(run_in "$home" install.sh --fix-launchagents --dry-run) || status=$? +check "exits 0 with --dry-run last" [ "$status" -eq 0 ] +check "does not create LaunchAgents with --dry-run last" [ ! -e "$home/Library/LaunchAgents" ] +check "previews the create with --dry-run last" contains "$output" "Would create $home/Library/LaunchAgents" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- dry-run and a rejected mode leave helper modes unchanged --" +home=$(setup_home) +helper="$SCRIPT_DIR/preflight.sh" +original_mode=$(mode_of "$helper") +chmod 644 "$helper" +status=0 +run_in "$home" run.sh --dry-run >/dev/null || status=$? +check "dry-run exits 0" [ "$status" -eq 0 ] +check "dry-run leaves the helper at 644" [ "$(mode_of "$helper")" = 644 ] +status=0 +run_in "$home" run.sh not-a-mode >/dev/null || status=$? +check "unknown mode exits non-zero" [ "$status" -ne 0 ] +check "unknown mode leaves the helper at 644" [ "$(mode_of "$helper")" = 644 ] +chmod "$original_mode" "$helper" +rm -rf "$home" + +# --------------------------------------------------------------------------- +echo "-- a different XDG_DATA_HOME keeps the previous default secrets --" +home=$(setup_home) +mkdir -p "$home/.local/share/cursor-mcp" +printf 'MY_API_KEY=kept-secret\n' >"$home/.local/share/cursor-mcp/mcp.env" +chmod 600 "$home/.local/share/cursor-mcp/mcp.env" +status=0 +output=$(TEST_XDG_DATA_HOME="$home/xdg-data" run_in "$home" install.sh) || status=$? +check "install exits 0" [ "$status" -eq 0 ] +check "copies the previous secret" grep -qx 'MY_API_KEY=kept-secret' "$home/xdg-data/cursor-mcp/mcp.env" +check "leaves the previous file in place" grep -qx 'MY_API_KEY=kept-secret' "$home/.local/share/cursor-mcp/mcp.env" +check "does not install the example over it" lacks "$(cat "$home/xdg-data/cursor-mcp/mcp.env")" "Populated by install.sh" +rm -rf "$home" + +echo "-- differing default and legacy files are refused --" +home=$(setup_home) +mkdir -p "$home/.local/share/cursor-mcp" "$home/.cursor" +printf 'MY_API_KEY=from-default\n' >"$home/.local/share/cursor-mcp/mcp.env" +printf 'MY_API_KEY=from-legacy\n' >"$home/.cursor/mcp.env" +status=0 +output=$(TEST_XDG_DATA_HOME="$home/xdg-data" run_in "$home" install.sh) || status=$? +check "refuses to guess" [ "$status" -ne 0 ] +check "names both files" contains "$output" "both exist and differ" +check "does not create the new mcp.env" [ ! -e "$home/xdg-data/cursor-mcp/mcp.env" ] +rm -rf "$home" + +echo "" +echo "=== Results: ${PASSED} passed, ${FAILED} failed ===" +[ "$FAILED" -eq 0 ] diff --git a/skills/ess/mcp-hide-secrets/scripts/verify.sh b/skills/ess/ess-mcp-hide-secrets/scripts/verify.sh similarity index 100% rename from skills/ess/mcp-hide-secrets/scripts/verify.sh rename to skills/ess/ess-mcp-hide-secrets/scripts/verify.sh diff --git a/skills/ess/ess-my-groups/LICENSE b/skills/ess/ess-my-groups/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/skills/ess/ess-my-groups/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/ess-my-groups/SKILL.md b/skills/ess/ess-my-groups/SKILL.md new file mode 100644 index 0000000..0039f6f --- /dev/null +++ b/skills/ess/ess-my-groups/SKILL.md @@ -0,0 +1,151 @@ +--- +name: ess-my-groups +description: >- + Resolve and maintain the user's personal users and groups: nicknames like "Ada" + and group names like "my team", "my pod", or "platform team", which may overlap + and nest. Use whenever a request refers to people by a group name or a nickname + ("PRs my pod asked me to review", "what did Ada send me", "ping my team"), to turn + those names into users with emails and service handles before doing the task, or + when the user asks to add, rename, or remove a user or a group, or asks who is in + a group. These are personal groups kept in a local file, not corporate directory + groups. +compatibility: >- + Requires uv. The first run fetches the pinned tomlkit package, which needs network + access. +metadata: + author: Matt Norris + version: "1.0.0" +--- + +# My Groups + +The user keeps a small file of users (with nicknames, an email, and handles on +services such as GitHub or Webex) and named groups of those users. This skill turns +"my pod" or "Ada" into those users, and edits the file when the user asks, so they +never touch TOML themselves. It only answers "who"; the task that needs the people +(a search, a message, a review) is done with whatever tool fits it. + +`SKILL_DIR` below is the absolute path of the directory containing this `SKILL.md`. +Do not use a repo-relative path; the skill may be installed outside the repo. + +```bash +uv run --script "$SKILL_DIR/scripts/my_groups.py" resolve "my pod" +``` + +## The file + +`${XDG_CONFIG_HOME}/ess-my-groups/groups.toml`. `XDG_CONFIG_HOME` counts only when it +is absolute; otherwise the path is `~/.config/ess-my-groups/groups.toml`. +`ESS_MY_GROUPS_FILE` overrides both. `my_groups.py path` prints the resolved path. + +Read [references/groups-format.md](references/groups-format.md) only if you need the +schema, for example when the user wants to edit the file by hand. + +## Resolve before acting + +When a request names a group or a user by nickname: + +1. Run `resolve` with every name from the request, in one call: + `uv run --script "$SKILL_DIR/scripts/my_groups.py" resolve "my pod" Ada` +2. Read the output: a JSON array of [JSContact](https://www.rfc-editor.org/rfc/rfc9553) + Cards. + - Each group you named comes first, as a Card with `"kind": "group"`. Its + `members` holds the `uid` of every user in the group, nested groups included. + - Then comes one Card per user, deduplicated across overlapping and nested + groups. Its `uid` is `user:<key>`, or `mailto:<address>` for an email with no + user entry. It may have `name.full`, `nicknames`, `emails` (each with an + `address`), and `onlineServices`, where each entry has a `service`, such as + `github`, and a `user`, the handle. A user may have several handles on one + service; use all of them. +3. Handle gaps before doing the task, not after: + - stderr lists `warning: unresolved name '…'` for bare names with no user + entry. Say so, and ask whether to add them (with an email) or skip them. + - Check each member for the exact identifier the task needs: an email, or a + handle on that particular service. Having one doesn't stand in for the other; + a member with an email but no GitHub handle can't be found by a GitHub search. + Name every member who lacks it in your answer, and offer to add it. +4. If `resolve` fails with "No user or group named …", show the "Did you mean" hint + if there is one, and otherwise ask the user who they meant. Don't guess. If it + fails because a name matches more than one entry, retry with the `user:` or + `group:` form it lists. +5. Then do the task with the resolved emails and handles. + +When a command takes plain values, print them one per line instead of JSON: +`resolve "my pod" --handle github` prints every GitHub handle, and `--email` prints +every email. Members without one are listed on stderr, so report them. Add `-0` to +end each value with NUL instead, for `xargs -0`. + +Data goes to stdout and warnings to stderr. `resolve` exits 0 even when something +is missing; add `--strict` to exit 3 instead when a name is unresolved or a member +lacks the requested email or handle. Other failures exit 1, and bad arguments +exit 2. + +To answer "who is in my team?", run `show "my team"`. `list` prints everything. + +## Adding and editing + +Turn the user's plain-language request into CLI calls. Ask only for what a command +can't run without: a user needs a name, and a new group needs at least one member +or nested group. Don't ask for optional handles up front; offer to add them later. + +| User says | Command | +| --- | --- | +| "Add Alan, alan@example.com, nickname Al" | `user add "Alan Turing" --alias Al --email alan@example.com` | +| "Ada's GitHub is ada_example" | `user update Ada --add-handle github=ada_example` | +| "Alan's Slack handle is al" | `user update Alan --add-handle slack=al` | +| "Ada stopped using ada_oss" | `user update Ada --remove-handle github=ada_oss` | +| "Forget Alan's Slack" | `user update Alan --remove-handle slack` | +| "Drop Ada's email" | `user update Ada --email ""` | +| "Remove Alan entirely" | `user remove Alan` | +| "Make a group called reviewers with Ada and Grace" | `group add reviewers --member Ada --member Grace` | +| "Also call my team 'the crew'" | `group update "my team" --add-alias "the crew"` | +| "My team includes my pod" | `group update "my team" --include "my pod"` | +| "Add Alan and jdoe@example.com to my pod" | `group add-member "my pod" Alan jdoe@example.com` | +| "Take Grace out of my team" | `group remove-member "my team" Grace` | +| "Delete the reviewers group" | `group remove reviewers` | + +Run `uv run --script "$SKILL_DIR/scripts/my_groups.py" --help` (or `user --help`, +`group --help`) for every flag. + +Every write follows the same three steps: + +1. Run the command with `--dry-run`. It prints a list of planned changes and a diff + of the file. Nothing is written. +2. Show the user the planned changes. Point out every line that says "Create user + … with only a name", especially ones with a "Did you mean" hint: that's usually + a typo, or a user not added yet. +3. Only after the user confirms, re-run the identical command with `--yes`. Your + shell has no terminal, so without `--yes` the command refuses to write. + +One confirmation can cover several related commands: show all their dry-runs +together, then run each with `--yes`. + +## Gotchas + +- Keys, display names, aliases, emails, and handles all match, ignoring case. + Beyond that: + - Keys are slugs, so "Platform Team" and "platform_team" find the key + `platform-team`. + - Names and aliases also ignore extra spaces, but punctuation counts: + "my-pod" doesn't match the alias "my pod". It gets a "Did you mean" hint. + - Handles and emails are exact apart from case: `sam-smith` and `sam_smith` are + different accounts. Pass them exactly as written. +- A name may belong to only one entry. Writes refuse a name that is already taken, + and refuse any change that would leave a name meaning two entries. `user:ada` or + `group:my-team` picks the kind explicitly, in any command. +- A group member can be a user, a bare email, or (from hand edits) a bare name. + `group add-member` turns an unknown bare name into a stub user with only a + name, so it can be completed later with `user update`. +- To nest groups, use `--include`. Passing a group name as a member is an error. +- `remove-member` removes only direct members. If the user is in the group + through a nested group, remove them there, or `--exclude` that nested group. +- Every write keeps the previous file as `groups.toml.bak`, next to the file. + Restore it by copying it back if the user wants to undo. +- A write refuses if the file changed after its plan was shown, or if another write + is in progress. Nothing is written; run the dry run again and show the new plan. +- Comments and the order of entries survive CLI edits. A comment sitting directly + above a table belongs to the table before it, so removing that earlier entry + removes the comment too. +- If there is no file yet, the first `user add` or `group add` creates it. + `init` instead writes an example file to start from; it never overwrites. +- After hand edits, or when results look wrong, run `validate`. diff --git a/skills/ess/ess-my-groups/assets/groups.example.toml b/skills/ess/ess-my-groups/assets/groups.example.toml new file mode 100644 index 0000000..e88de24 --- /dev/null +++ b/skills/ess/ess-my-groups/assets/groups.example.toml @@ -0,0 +1,26 @@ +# Personal users and groups for the ess-my-groups agent skill. +# Edit with `my_groups.py user ...` and `my_groups.py group ...`, or by hand. +# +# Users are optional: a group member can be a user key, an email address, or a bare +# name the agent will ask about. Groups may overlap and may nest other groups. + +[users.ada] +name = "Ada Lovelace" +aliases = ["Ada"] +email = "ada@example.com" + +[users.ada.handles] +github = ["ada_example", "ada_oss"] + +[users.grace] +name = "Grace Hopper" +aliases = ["Amazing Grace"] +email = "grace@example.com" + +[groups.my-team] +aliases = ["my team"] +members = ["ada", "grace"] + +[groups.reviewers] +members = ["ada", "alan@example.com"] +groups = ["my-team"] diff --git a/skills/ess/ess-my-groups/references/groups-format.md b/skills/ess/ess-my-groups/references/groups-format.md new file mode 100644 index 0000000..78583a6 --- /dev/null +++ b/skills/ess/ess-my-groups/references/groups-format.md @@ -0,0 +1,97 @@ +# groups.toml format + +Read this only when someone wants to edit the file by hand. The CLI writes the same +format, and hand edits survive later CLI writes, including your comments. + +```toml +[users.ada] +name = "Ada Lovelace" +aliases = ["Ada"] +email = "ada@example.com" + +[users.ada.handles] +github = ["ada_example", "ada_oss"] +webex = ["ada@example.com"] + +[groups.platform-team] +aliases = ["my pod"] +members = ["ada", "grace@example.com", "Someone New"] + +[groups.my-team] +aliases = ["my team"] +members = ["ada"] +groups = ["platform-team"] +``` + +## `[users.<key>]` + +The key is a short, stable identifier, such as `ada`. Quote it in the header if it +contains anything other than letters, digits, `-`, or `_`: `[users."zoe.obrien"]`. +A key needs at least one ASCII letter or digit, and can't start with `user:` or +`group:`; the same holds for group keys. + +| Field | Required | Meaning | +| --- | --- | --- | +| `name` | Yes | Display name. | +| `aliases` | No | Nicknames and shortcuts, such as `["Ada"]`. | +| `email` | No | Email address ([RFC 5322](https://www.rfc-editor.org/rfc/rfc5322) addr-spec). | + +## `[users.<key>.handles]` + +Usernames on online services, one key per service: `github`, `webex`, `slack`, or +any other. Each value is a list, so a user can have a work login and a public +one; a single string also works. Service names ignore case, as JSContact requires, +and a CLI edit merges keys that differ only in case. Handles themselves ignore case +only: `sam-smith` and `sam_smith` are two accounts. The skill attaches no meaning to +a service name; whatever uses the handles decides which service it needs. + +## `[groups.<key>]` + +| Field | Required | Meaning | +| --- | --- | --- | +| `aliases` | No | Other names for the group, such as `["my pod"]`. | +| `members` | No | Users in the group. Store user keys; an email or a bare name also works. | +| `groups` | No | Keys of other groups whose members are also members of this one. | + +The CLI always stores members by key. A member written another way, such as `"Ada"` +or a known user's email, still resolves, but `validate` warns and names the key to +store instead. A bare name that matches no user (and has no `@`) is reported as +unresolved: the agent asks who it is rather than guessing. + +## Rules + +- The file has only `[users.<key>]` and `[groups.<key>]` tables. +- `name` and `email` are strings. `aliases`, `members`, and `groups` are lists of + strings, even with one entry: `members = ["ada"]`, not `members = "ada"`. + Fields not listed here may be a string or a list of strings. +- Keys, names, aliases, emails, and handles are all lookup names, and each kind + compares by its own rule. All of them ignore case, and Unicode text matches + however it was typed: `zoë` written as one character or as `o` plus an accent. + - Keys compare as slugs: `Platform Team`, `platform_team`, and + `platform-team` all find the key `platform-team`. + - Names and aliases compare as nicknames + ([RFC 8266](https://www.rfc-editor.org/rfc/rfc8266)): runs of spaces count as + one, and punctuation counts, so `Jean-Luc` and `Jean Luc` differ. + - Handles compare as usernames ([RFC 8265](https://www.rfc-editor.org/rfc/rfc8265)): + `sam-smith` and `sam_smith` are different accounts. + - Emails ignore case only, so `alice-smith@example.com` and + `alice_smith@example.com` are different users. +- An email must be a valid RFC 5322 address; `validate` and the CLI reject one + that isn't. The CLI stores and prints it in canonical form, dropping comments + and extra spaces: `Jane@Example.com (work)` becomes `Jane@Example.com`. +- A lookup name may belong to only one user or group. `validate` reports clashes, + and the CLI refuses any change that would create one. +- A `user:` or `group:` prefix limits a lookup to that kind, as `chown user:group` + does: `user:pod` and `group:pod` stay distinct even when a hand edit made `pod` + ambiguous. +- Changing a user's name, email, alias, or handle with `user update` rewrites group + members that used the old value to the user's key, so they stay attached. + Removing a group alias with `group update` does the same for nested groups. +- Groups may overlap freely, and may nest, but not in a cycle. `validate` reports + each unknown nested group, and warns about one not stored by key. + +Check the file after editing: + +```bash +uv run --script "$SKILL_DIR/scripts/my_groups.py" validate +``` diff --git a/skills/ess/ess-my-groups/scripts/my_groups.py b/skills/ess/ess-my-groups/scripts/my_groups.py new file mode 100644 index 0000000..6430067 --- /dev/null +++ b/skills/ess/ess-my-groups/scripts/my_groups.py @@ -0,0 +1,1953 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["tomlkit==0.14.0"] +# /// +"""Resolve and edit the personal users-and-groups file used by ess-my-groups. + +The file lives at ``$XDG_CONFIG_HOME/ess-my-groups/groups.toml``, or +``~/.config/ess-my-groups/groups.toml`` when ``XDG_CONFIG_HOME`` is unset or relative. +``ESS_MY_GROUPS_FILE`` overrides the path. +""" + +import argparse +import difflib +import json +import os +import re +import shutil +import sys +import tempfile +import unicodedata +from collections.abc import Callable, Iterable, Iterator, Mapping +from contextlib import AbstractContextManager, contextmanager, nullcontext +from dataclasses import dataclass, field, replace +from email.errors import HeaderParseError +from email.headerregistry import Address +from pathlib import Path +from typing import Any + +try: + import fcntl +except ImportError: + fcntl = None +try: + import msvcrt +except ImportError: + msvcrt = None + +import tomlkit +from tomlkit.exceptions import ParseError +from tomlkit.toml_document import TOMLDocument + +CONFIG_ENV_VAR = "ESS_MY_GROUPS_FILE" +_APP_DIR = "ess-my-groups" +_FILE_NAME = "groups.toml" +_EXAMPLE_PATH = ( + Path(__file__).resolve().parent.parent / "assets" / "groups.example.toml" +) +_HEADER_LINES = ( + "Personal users and groups for the ess-my-groups agent skill.", + "Edit with `my_groups.py user ...` and `my_groups.py group ...`, or by hand.", +) +_SECTIONS = ("users", "groups") +_KINDS = ("user", "group") +_TEXT_FIELDS = frozenset({"name", "email"}) +_LIST_FIELDS = frozenset({"aliases", "members", "groups"}) +_HANDLES = "handles" +_JSCONTACT_VERSION = "1.0" +EXIT_INCOMPLETE = 3 +_HINT_CUTOFF = 0.75 +_WIDTH_TAGS = ("<wide>", "<narrow>") +_YES_ANSWERS = frozenset({"y", "yes"}) + +Identity = tuple[str, str] +Mutation = Callable[[TOMLDocument], list[str]] +# (rule, compared value); the rule is "key", "name", "handle", or "email". +IndexKey = tuple[str, str] + + +class MyGroupsError(Exception): + """Base error for the groups file and its commands.""" + + +class ConfigError(MyGroupsError): + """The groups file is missing or malformed.""" + + +class NameNotFoundError(MyGroupsError): + """No user or group matches the given name.""" + + +class ConflictError(MyGroupsError): + """A name is ambiguous or already belongs to another entry.""" + + +class AbortedError(MyGroupsError): + """The operator declined the confirmation prompt.""" + + +@dataclass(frozen=True, order=True) +class Entry: + """A user or group, written ``user:<key>`` or ``group:<key>``.""" + + kind: str + key: str + + def __str__(self) -> str: + return f"{self.kind}:{self.key}" + + +@dataclass(frozen=True) +class Handle: + """A username on an online service; ``user`` is ``None`` for "every handle".""" + + service: str + user: str | None = None + + +@dataclass(frozen=True) +class WriteOptions: + """Flags shared by every write command.""" + + dry_run: bool = False + yes: bool = False + + +@dataclass(frozen=True) +class UserInput: + """Fields for a new user.""" + + name: str + aliases: list[str] = field(default_factory=list) + email: str | None = None + handles: list[Handle] = field(default_factory=list) + key: str | None = None + + +@dataclass(frozen=True) +class UserUpdate: + """Changes to a user. ``email=""`` clears the email; ``None`` keeps it.""" + + name: str | None = None + add_aliases: list[str] = field(default_factory=list) + remove_aliases: list[str] = field(default_factory=list) + email: str | None = None + add_handles: list[Handle] = field(default_factory=list) + remove_handles: list[Handle] = field(default_factory=list) + + +@dataclass(frozen=True) +class _ListEdit: + field: str + noun: str + add: list[str] + remove: list[str] + + +@dataclass(frozen=True) +class GroupInput: + """Fields for a new group.""" + + name: str + aliases: list[str] = field(default_factory=list) + members: list[str] = field(default_factory=list) + includes: list[str] = field(default_factory=list) + key: str | None = None + + +@dataclass(frozen=True) +class GroupUpdate: + """Changes to an existing group's aliases and nested groups.""" + + add_aliases: list[str] = field(default_factory=list) + remove_aliases: list[str] = field(default_factory=list) + includes: list[str] = field(default_factory=list) + excludes: list[str] = field(default_factory=list) + + +# === Names and paths === + + +def nickname_key(text: str) -> str: + """Compare a name or alias as an RFC 8266 nickname. + + Runs of whitespace become one space and the ends are trimmed, case is folded, and + the result is NFKC-normalized. Punctuation counts: ``Jean-Luc`` is not + ``Jean Luc``. + """ + return unicodedata.normalize("NFKC", " ".join(text.split()).casefold()) + + +def handle_key(text: str) -> str: + """Compare a handle as an RFC 8265 case-mapped username. + + Full-width and half-width characters are mapped to their normal forms, case is + folded, and the result is NFC-normalized. Punctuation counts: ``sam-smith`` and + ``sam_smith`` are different accounts. + """ + narrowed = "".join( + unicodedata.normalize("NFKC", char) + if unicodedata.decomposition(char).startswith(_WIDTH_TAGS) + else char + for char in text.strip() + ) + return unicodedata.normalize("NFC", narrowed.casefold()) + + +def parse_email(text: str) -> str | None: + """Return ``text`` as a canonical RFC 5322 addr-spec, or ``None`` if it isn't one. + + Comments and surrounding whitespace are dropped, so ``jane@example.com (work)`` + becomes ``jane@example.com``. JSContact requires this form for email addresses. + """ + try: + address = Address(addr_spec=unicodedata.normalize("NFC", text.strip())) + except (ValueError, IndexError, HeaderParseError): + return None + return address.addr_spec + + +def email_key(text: str) -> str | None: + """Return the lookup key for an email address, or ``None`` if ``text`` isn't one. + + Only case is ignored; the local part keeps its punctuation, so ``a-b@x`` and + ``a_b@x`` differ. + """ + address = parse_email(text) + return address.casefold() if address else None + + +def checked_email(value: str | None) -> str | None: + """Return a new email value in canonical form; ``""`` and ``None`` pass through. + + Raises: + MyGroupsError: ``value`` is not an RFC 5322 address. + """ + if not value: + return value + address = parse_email(value) + if address is None: + raise MyGroupsError(f"'{value}' is not an email address.") + return address + + +def alias_key(text: str) -> str: + """Compare an alias: as an email if it is one, otherwise as a nickname.""" + return email_key(text) or nickname_key(text) + + +def split_kind(text: str) -> tuple[str | None, str]: + """Split an optional ``user:`` or ``group:`` prefix off a name.""" + prefix, sep, rest = text.partition(":") + kind = prefix.strip().casefold() + if sep and kind in _KINDS and rest.strip(): + return kind, rest.strip() + return None, text + + +def slugify(text: str) -> str: + """Turn a display name into an ASCII key, e.g. ``platform-team``.""" + ascii_text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() + return re.sub(r"[^a-z0-9]+", "-", ascii_text.casefold()).strip("-") + + +# Keys are slugs, so a key and a query compare as slugs: "platform team" finds +# platform-team. Names and handles keep their punctuation. +_RULES: dict[str, Callable[[str], str]] = { + "key": slugify, + "name": nickname_key, + "handle": handle_key, +} + + +def _index_key(rule: str, term: str) -> IndexKey | None: + """Return the index key for a stored ``term``; any email is compared as one.""" + email = email_key(term) + if email: + return ("email", email) + value = _RULES[rule](term) + return (rule, value) if value else None + + +def _query_keys(term: str) -> list[IndexKey]: + """Return every index key a lookup of ``term`` can match, one per rule.""" + email = email_key(term) + if email: + return [("email", email)] + return [ + (rule, value) for rule, compare in _RULES.items() if (value := compare(term)) + ] + + +def get_config_path(environ: Mapping[str, str] | None = None) -> Path: + """Return the groups file path. + + ``ESS_MY_GROUPS_FILE`` wins; otherwise an absolute ``XDG_CONFIG_HOME`` is used, and + a relative or unset one falls back to ``~/.config`` as the XDG spec requires. + """ + env = os.environ if environ is None else environ + override = env.get(CONFIG_ENV_VAR, "") + if override: + return Path(override).expanduser() + config_home = env.get("XDG_CONFIG_HOME", "") + base = Path(config_home) if os.path.isabs(config_home) else Path.home() / ".config" + return base / _APP_DIR / _FILE_NAME + + +# === Loading === + + +def _new_document() -> TOMLDocument: + doc = tomlkit.document() + for line in _HEADER_LINES: + doc.add(tomlkit.comment(line)) + doc.add(tomlkit.nl()) + return doc + + +def _value_error(location: str, name: str, value: Any) -> str | None: + is_text = isinstance(value, str) + is_text_list = isinstance(value, list) and all( + isinstance(item, str) for item in value + ) + if name in _TEXT_FIELDS: + if not is_text: + return f"{location} must be a string." + if name == "email" and value.strip() and parse_email(value) is None: + return f"{location} is not an email address: {value!r}." + return None + if name in _LIST_FIELDS: + return None if is_text_list else f"{location} must be a list of strings." + if is_text or is_text_list: + return None + return f"{location} must be a string or a list of strings." + + +def _entry_errors(section: str, location: str, entry: Any) -> list[str]: + errors: list[str] = [] + for name, value in entry.items(): + if section == "users" and name == _HANDLES: + if not isinstance(value, dict): + errors.append( + f"{location}.{name} must be a table, such as " + f'[{location}.{name}] with github = ["login"].' + ) + continue + errors.extend( + error + for service, logins in value.items() + if (error := _value_error(f"{location}.{name}.{service}", "", logins)) + ) + continue + error = _value_error(f"{location}.{name}", name, value) + if error: + errors.append(error) + return errors + + +def _check_schema(doc: TOMLDocument) -> None: + for name in doc: + if name not in _SECTIONS: + raise ConfigError( + f"Unknown top-level entry '{name}'; expected [users.<key>] and " + "[groups.<key>] tables." + ) + for section in _SECTIONS: + tables = doc.get(section, {}) + if not isinstance(tables, dict): + raise ConfigError(f"[{section}] must be a table.") + for key, entry in tables.items(): + if not isinstance(entry, dict): + raise ConfigError(f"{section}.{key} must be a table.") + key_problem = key_error(key) + if key_problem: + raise ConfigError(f"{section}.{key}: {key_problem}") + errors = _entry_errors(section, f"{section}.{key}", entry) + if errors: + raise ConfigError(errors[0]) + + +def key_error(key: str) -> str | None: + """Return why ``key`` can't be a user or group key, or ``None`` if it can.""" + if split_kind(key)[0]: + return "a key can't start with 'user:' or 'group:'." + if not slugify(key): + return "a key needs at least one ASCII letter or digit." + return None + + +def load_document(path: Path, *, missing_ok: bool = False) -> TOMLDocument: + """Parse the groups file; with ``missing_ok``, a missing file yields a new one.""" + if not path.exists(): + if missing_ok: + return _new_document() + raise ConfigError( + f"No groups file at {path}. Add someone with `user add`, or run `init`." + ) + try: + doc = tomlkit.parse(path.read_text(encoding="utf-8")) + except ParseError as exc: + raise ConfigError(f"{path} is not valid TOML: {exc}") from exc + _check_schema(doc) + return doc + + +def _users(doc: TOMLDocument) -> Any: + return doc.get("users", {}) + + +def _groups(doc: TOMLDocument) -> Any: + return doc.get("groups", {}) + + +def _section(doc: TOMLDocument, name: str) -> Any: + if name not in doc: + doc[name] = tomlkit.table(is_super_table=True) + return doc[name] + + +def _text(entry: Any, name: str) -> str: + return str(entry.get(name, "")) + + +def _texts(entry: Any, name: str) -> list[str]: + return [str(item) for item in entry.get(name, [])] + + +def _handles(user: Any) -> dict[str, list[str]]: + """Return a user's handles by service; a single string counts as one handle.""" + found: dict[str, list[str]] = {} + for service, value in user.get(_HANDLES, {}).items(): + logins = [value] if isinstance(value, str) else value + found.setdefault(service.casefold(), []).extend( + str(login) for login in logins if login + ) + return found + + +# === Lookup === + + +def entry_terms(doc: TOMLDocument) -> Iterator[tuple[Entry, str, str]]: + """Yield ``(entry, rule, term)`` for every lookup name in the file.""" + for key, user in _users(doc).items(): + entry = Entry("user", key) + yield entry, "key", key + for term in ( + _text(user, "name"), + _text(user, "email"), + *_texts(user, "aliases"), + ): + yield entry, "name", term + for logins in _handles(user).values(): + for login in logins: + yield entry, "handle", login + for key, group in _groups(doc).items(): + entry = Entry("group", key) + yield entry, "key", key + for alias in _texts(group, "aliases"): + yield entry, "name", alias + + +def build_index(doc: TOMLDocument) -> dict[IndexKey, set[Entry]]: + """Map every lookup name (key, name, alias, email, handle) to what it names.""" + index: dict[IndexKey, set[Entry]] = {} + for entry, rule, term in entry_terms(doc): + index_key = _index_key(rule, term) if term else None + if index_key: + index.setdefault(index_key, set()).add(entry) + return index + + +def lookup(index: dict[IndexKey, set[Entry]], term: str) -> set[Entry]: + """Return every entry ``term`` names under any rule.""" + found: set[Entry] = set() + for index_key in _query_keys(term): + found |= index.get(index_key, set()) + return found + + +def _describe(entries: set[Entry]) -> str: + return ", ".join(str(entry) for entry in sorted(entries)) + + +def find_entry( + index: dict[IndexKey, set[Entry]], text: str, kind: str | None = None +) -> Entry | None: + """Return the entry ``text`` names, ``None`` if none; raise if it names several. + + A ``user:`` or ``group:`` prefix on ``text`` limits the match to that kind. + """ + prefix, name = split_kind(text) + if prefix and kind and prefix != kind: + raise ConflictError(f"'{text}' names a {prefix}, but a {kind} is needed here.") + wanted = prefix or kind + matches = lookup(index, name) + if wanted: + matches = {entry for entry in matches if entry.kind == wanted} + if len(matches) > 1: + raise ConflictError( + f"'{text}' matches more than one entry ({_describe(matches)}); " + "use one of those." + ) + return next(iter(matches), None) + + +def suggest(index: dict[IndexKey, set[Entry]], text: str) -> list[str]: + """Return entries, as ``user:<key>`` or ``group:<key>``, named close to ``text``.""" + by_value: dict[str, set[Entry]] = {} + for (_, value), entries in index.items(): + by_value.setdefault(value, set()).update(entries) + close = { + match + for _, value in _query_keys(split_kind(text)[1]) + for match in difflib.get_close_matches( + value, by_value, n=3, cutoff=_HINT_CUTOFF + ) + } + return sorted({str(entry) for match in close for entry in by_value[match]}) + + +def _hint(index: dict[IndexKey, set[Entry]], text: str) -> str: + hints = suggest(index, text) + return f" Did you mean: {', '.join(hints)}?" if hints else "" + + +def _require(index: dict[IndexKey, set[Entry]], text: str, kind: str) -> Entry: + entry = find_entry(index, text, kind=kind) + if entry is None: + raise NameNotFoundError(f"No {kind} named '{text}'.{_hint(index, text)}") + return entry + + +def member_identity(index: dict[IndexKey, set[Entry]], member: str) -> Identity: + """Classify a member string as a known user, an undeclared email, or a name.""" + entry = find_entry(index, member, kind="user") + if entry: + return ("user", entry.key) + name = split_kind(member)[1] + email = email_key(name) + if email: + return ("email", email) + return ("name", nickname_key(name)) + + +# === Resolution === + + +def expand_group( + doc: TOMLDocument, + index: dict[IndexKey, set[Entry]], + key: str, + trail: tuple[str, ...] = (), + done: set[str] | None = None, +) -> list[str]: + """Return the member strings of a group, followed by those of its nested groups. + + A group reached by more than one path is expanded only once. + """ + if key in trail: + raise ConfigError(f"Group cycle: {' -> '.join((*trail, key))}.") + done = set() if done is None else done + group = _groups(doc)[key] + members = _texts(group, "members") + for nested in _texts(group, "groups"): + nested_entry = find_entry(index, nested, kind="group") + if nested_entry is None: + raise ConfigError(f"Group '{key}' includes unknown group '{nested}'.") + if nested_entry.key not in done: + members.extend( + expand_group(doc, index, nested_entry.key, (*trail, key), done) + ) + done.add(key) + return members + + +def _member_record(doc: TOMLDocument, identity: Identity, raw: str) -> dict[str, Any]: + kind, value = identity + if kind == "email": + return { + "key": None, + "name": None, + "aliases": [], + "email": parse_email(split_kind(raw)[1]), + "handles": {}, + } + user = _users(doc)[value] + return { + "key": value, + "name": _text(user, "name") or None, + "aliases": _texts(user, "aliases"), + "email": parse_email(_text(user, "email")), + "handles": _handles(user), + } + + +def _collect( + doc: TOMLDocument, index: dict[IndexKey, set[Entry]], raw_members: list[str] +) -> tuple[list[dict], list[str]]: + members: list[dict] = [] + unresolved: list[str] = [] + seen: set[Identity] = set() + for raw in raw_members: + identity = member_identity(index, raw) + if identity in seen: + continue + seen.add(identity) + if identity[0] == "name": + unresolved.append(raw.strip()) + else: + members.append(_member_record(doc, identity, raw)) + return members, unresolved + + +def resolve(doc: TOMLDocument, names: list[str]) -> dict[str, Any]: + """Resolve user or group names to deduplicated users and unresolved names.""" + index = build_index(doc) + raw_members: list[str] = [] + matched: list[str] = [] + for name in names: + entry = find_entry(index, name) + if entry is None: + raise NameNotFoundError( + f"No user or group named '{name}'.{_hint(index, name)}" + ) + matched.append(str(entry)) + if entry.kind == "group": + raw_members.extend(expand_group(doc, index, entry.key)) + else: + raw_members.append(entry.key) + members, unresolved = _collect(doc, index, raw_members) + return { + "query": names, + "matched": matched, + "members": members, + "unresolved": unresolved, + } + + +# === JSContact output === + + +def _card(uid: str, kind: str) -> dict[str, Any]: + return {"@type": "Card", "version": _JSCONTACT_VERSION, "uid": uid, "kind": kind} + + +def _numbered(prefix: str, values: Iterable[dict[str, str]]) -> dict[str, Any]: + return {f"{prefix}{number}": value for number, value in enumerate(values, 1)} + + +def _nicknames(aliases: list[str]) -> dict[str, Any]: + return _numbered("n", ({"name": alias} for alias in aliases)) + + +def _member_uid(member: dict[str, Any]) -> str: + return f"user:{member['key']}" if member["key"] else f"mailto:{member['email']}" + + +def _user_card(member: dict[str, Any]) -> dict[str, Any]: + card = _card(_member_uid(member), "individual") + if member["name"]: + card["name"] = {"full": member["name"]} + if member["aliases"]: + card["nicknames"] = _nicknames(member["aliases"]) + if member["email"]: + card["emails"] = {"e1": {"address": member["email"]}} + services = [ + {"service": service, "user": login} + for service, logins in member["handles"].items() + for login in logins + ] + if services: + card["onlineServices"] = _numbered("s", services) + return card + + +def _group_card( + doc: TOMLDocument, index: dict[IndexKey, set[Entry]], key: str +) -> dict[str, Any]: + card = _card(f"group:{key}", "group") + card["name"] = {"full": key} + aliases = _texts(_groups(doc)[key], "aliases") + if aliases: + card["nicknames"] = _nicknames(aliases) + members, _ = _collect(doc, index, expand_group(doc, index, key)) + card["members"] = {_member_uid(member): True for member in members} + return card + + +def to_jscontact(doc: TOMLDocument, result: dict[str, Any]) -> list[dict[str, Any]]: + """Return ``resolve`` output as JSContact (RFC 9553) Cards. + + Each named group becomes a group Card whose ``members`` lists every user in it, + nested groups included. Every user's Card follows once, in resolution order. + Unresolved names have no Card; callers report them separately. + """ + index = build_index(doc) + groups = [ + _group_card(doc, index, key) + for kind, key in map(split_kind, result["matched"]) + if kind == "group" + ] + return groups + [_user_card(member) for member in result["members"]] + + +def _ambiguity_errors( + doc: TOMLDocument, index: dict[IndexKey, set[Entry]] +) -> list[str]: + """Report every stored name whose lookup, under any rule, finds several entries.""" + errors: set[str] = set() + for _, _, term in entry_terms(doc): + entries = lookup(index, term) if term else set() + if len(entries) > 1: + errors.add(f"'{term}' refers to more than one entry: {_describe(entries)}.") + return sorted(errors) + + +def _member_problem( + index: dict[IndexKey, set[Entry]], group_key: str, member: str +) -> tuple[str, str] | None: + """Return ``("error" | "warning", message)`` for one stored member, or ``None``.""" + label = f"Group '{group_key}'" + try: + kind, value = member_identity(index, member) + entry = find_entry(index, member) if kind == "name" else None + except MyGroupsError as exc: + return "error", f"{label}: {exc}" + if entry is not None and entry.kind == "group": + return "error", f"{label}: member '{member}' is {entry}; list it in groups." + if kind == "name": + return "warning", f"{label}: '{member}' is not a known user or an email." + if kind == "user" and member != value: + return ( + "warning", + f"{label}: member '{member}' is user:{value}; store '{value}'.", + ) + if kind == "email" and split_kind(member)[0]: + return ( + "warning", + f"{label}: no user has the email in '{member}'; store the bare address.", + ) + return None + + +def _nested_entry(index: dict[IndexKey, set[Entry]], text: str) -> Entry | None: + try: + return find_entry(index, text, kind="group") + except MyGroupsError: + return None + + +def _find_cycle( + doc: TOMLDocument, + index: dict[IndexKey, set[Entry]], + key: str, + trail: tuple[str, ...] = (), + done: set[str] | None = None, +) -> tuple[str, ...] | None: + """Return the first nesting cycle through ``key``, skipping broken references. + + ``done`` holds groups already explored without a cycle, so each is walked once. + """ + if key in trail: + return (*trail, key) + done = set() if done is None else done + for nested in _texts(_groups(doc)[key], "groups"): + entry = _nested_entry(index, nested) + if entry is None or entry.key in done: + continue + cycle = _find_cycle(doc, index, entry.key, (*trail, key), done) + if cycle: + return cycle + done.add(key) + return None + + +def _nested_problems( + doc: TOMLDocument, index: dict[IndexKey, set[Entry]], key: str +) -> tuple[list[str], list[str]]: + """Check each nested-group reference of ``key`` on its own.""" + errors: list[str] = [] + warnings: list[str] = [] + for nested in _texts(_groups(doc)[key], "groups"): + try: + entry = find_entry(index, nested, kind="group") + except MyGroupsError as exc: + errors.append(f"Group '{key}': {exc}") + continue + if entry is None: + errors.append(f"Group '{key}' includes unknown group '{nested}'.") + elif nested != entry.key: + warnings.append( + f"Group '{key}': nested group '{nested}' is {entry}; " + f"store '{entry.key}'." + ) + cycle = _find_cycle(doc, index, key) + if cycle: + errors.append(f"Group cycle: {' -> '.join(cycle)}.") + return errors, warnings + + +def _group_problems( + doc: TOMLDocument, index: dict[IndexKey, set[Entry]], key: str +) -> tuple[list[str], list[str]]: + errors, warnings = _nested_problems(doc, index, key) + for member in _texts(_groups(doc)[key], "members"): + problem = _member_problem(index, key, member) + if problem: + (errors if problem[0] == "error" else warnings).append(problem[1]) + return errors, warnings + + +def validate(doc: TOMLDocument) -> tuple[list[str], list[str]]: + """Return ``(errors, warnings)`` for ambiguous names, bad nesting, and unknowns.""" + index = build_index(doc) + errors = _ambiguity_errors(doc, index) + warnings: list[str] = [] + for key in _groups(doc): + group_errors, group_warnings = _group_problems(doc, index, key) + errors.extend(group_errors) + warnings.extend(group_warnings) + return list(dict.fromkeys(errors)), warnings + + +# === Mutations === + + +def _ensure_terms_free( + index: dict[IndexKey, set[Entry]], + terms: Iterable[str | None], + owner: Entry | None = None, +) -> None: + for term in terms: + if not term: + continue + others = lookup(index, term) - ({owner} if owner else set()) + if others: + raise ConflictError(f"'{term}' already refers to {_describe(others)}.") + + +def _unique_key(doc: TOMLDocument, index: dict[IndexKey, set[Entry]], base: str) -> str: + candidate = base + suffix = 2 + while ( + candidate in _users(doc) + or candidate in _groups(doc) + or lookup(index, candidate) + ): + candidate = f"{base}-{suffix}" + suffix += 1 + return candidate + + +def _list_add(table: Any, name: str, value: str) -> None: + if name in table: + table[name].append(value) + else: + table[name] = [value] + + +def _list_remove(table: Any, name: str, matches: Callable[[str], bool]) -> int: + values = table.get(name) + if values is None: + return 0 + positions = [position for position, item in enumerate(values) if matches(str(item))] + for position in reversed(positions): + del values[position] + if not values: + del table[name] + return len(positions) + + +def _edit_list(table: Any, edit: _ListEdit, label: str) -> list[str]: + notes: list[str] = [] + for value in edit.add: + if any( + alias_key(existing) == alias_key(value) + for existing in _texts(table, edit.field) + ): + notes.append(f"'{value}' is already a {edit.noun} of {label}.") + continue + _list_add(table, edit.field, value) + notes.append(f"Add {edit.noun} '{value}' to {label}.") + for value in edit.remove: + target = alias_key(value) + if not _list_remove( + table, edit.field, lambda item, target=target: alias_key(item) == target + ): + raise NameNotFoundError(f"'{value}' is not a {edit.noun} of {label}.") + notes.append(f"Remove {edit.noun} '{value}' from {label}.") + return notes + + +def _handle_logins(handles: Iterable[Handle]) -> list[str | None]: + return [handle.user for handle in handles] + + +def _same_handle(left: str, right: str) -> bool: + return handle_key(str(left)) == handle_key(str(right)) + + +def _merge_service(table: Any, service: str) -> str: + """Return the spelling of ``service`` in ``table``, merging its case variants. + + The first spelling keeps every handle, as a list; later variants are removed. + """ + names = [name for name in table if name.casefold() == service] + if not names: + return service + first = names[0] + if len(names) == 1 and not isinstance(table[first], str): + return first + merged: list[str] = [] + for name in names: + value = table[name] + for login in [value] if isinstance(value, str) else value: + if login and not any(_same_handle(login, seen) for seen in merged): + merged.append(str(login)) + for name in names[1:]: + del table[name] + table[first] = merged + return first + + +def _add_handles(user: Any, handles: list[Handle], label: str) -> list[str]: + notes: list[str] = [] + for handle in handles: + if _HANDLES not in user: + user[_HANDLES] = tomlkit.table() + table = user[_HANDLES] + service = _merge_service(table, handle.service) + if any( + _same_handle(login, handle.user or "") for login in _texts(table, service) + ): + notes.append(f"'{handle.user}' is already a {service} handle of {label}.") + continue + _list_add(table, service, handle.user) + notes.append(f"Add {service} handle '{handle.user}' to {label}.") + return notes + + +def _remove_handles(user: Any, handles: list[Handle], label: str) -> list[str]: + notes: list[str] = [] + table = user.get(_HANDLES, {}) + for handle in handles: + service = _merge_service(table, handle.service) + if service not in table: + raise NameNotFoundError(f"{label} has no {handle.service} handle.") + if handle.user is None: + del table[service] + notes.append(f"Remove every {service} handle from {label}.") + continue + target = handle.user + if not _list_remove( + table, service, lambda item, target=target: _same_handle(item, target) + ): + raise NameNotFoundError( + f"'{handle.user}' is not a {service} handle of {label}." + ) + notes.append(f"Remove {service} handle '{handle.user}' from {label}.") + if _HANDLES in user and not user[_HANDLES]: + del user[_HANDLES] + return notes + + +def _require_valid_key(key: str) -> None: + problem = key_error(key) + if problem: + raise MyGroupsError(f"Key '{key}': {problem}") + + +def _new_table( + values: Mapping[str, str | list[str] | None], handles: Iterable[Handle] = () +) -> Any: + table = tomlkit.table() + for name, value in values.items(): + if value: + table[name] = value + by_service: dict[str, list[str]] = {} + for handle in handles: + if handle.user: + by_service.setdefault(handle.service, []).append(handle.user) + if by_service: + table[_HANDLES] = tomlkit.table() + for service, logins in by_service.items(): + table[_HANDLES][service] = logins + table.add(tomlkit.nl()) + return table + + +def add_user(doc: TOMLDocument, user: UserInput) -> list[str]: + """Add a user; fails if the key, name, an alias, email, or handle is taken.""" + index = build_index(doc) + user = replace(user, email=checked_email(user.email)) + key = user.key or slugify(user.aliases[0] if user.aliases else user.name) + if not key: + raise MyGroupsError(f"Cannot derive a key from '{user.name}'; pass --key.") + _require_valid_key(key) + if key in _users(doc) or key in _groups(doc): + raise ConflictError(f"Key '{key}' is already taken; pass a different --key.") + _ensure_terms_free( + index, + [key, user.name, user.email, *user.aliases, *_handle_logins(user.handles)], + ) + values = {"name": user.name, "aliases": user.aliases, "email": user.email} + _section(doc, "users")[key] = _new_table(values, user.handles) + return [f"Add user {key} ({user.name})."] + + +def _pin_members_to_key( + doc: TOMLDocument, index: dict[IndexKey, set[Entry]], key: str +) -> list[str]: + """Rewrite group members that name user ``key`` another way to the key itself.""" + notes: list[str] = [] + for group_key, group in _groups(doc).items(): + members = group.get("members", []) + for position, member in enumerate(members): + text = str(member) + if text != key and member_identity(index, text) == ("user", key): + members[position] = key + notes.append(f"Point member '{text}' of {group_key} at {key}.") + return notes + + +def _pin_nested_to_key( + doc: TOMLDocument, index: dict[IndexKey, set[Entry]], key: str +) -> list[str]: + """Rewrite nested-group references that name group ``key`` another way to it.""" + notes: list[str] = [] + for group_key, group in _groups(doc).items(): + nested = group.get("groups", []) + for position, item in enumerate(nested): + text = str(item) + if text != key and _nested_entry(index, text) == Entry("group", key): + nested[position] = key + notes.append(f"Point nested group '{text}' of {group_key} at {key}.") + return notes + + +def _changes_lookup_names(update: UserUpdate) -> bool: + return bool( + update.name + or update.email is not None + or update.remove_aliases + or update.remove_handles + ) + + +def update_user(doc: TOMLDocument, who: str, update: UserUpdate) -> list[str]: + """Change a user's name, aliases, email, or handles.""" + update = replace(update, email=checked_email(update.email)) + index = build_index(doc) + entry = _require(index, who, "user") + table = _users(doc)[entry.key] + _ensure_terms_free( + index, + [ + update.name, + update.email, + *update.add_aliases, + *_handle_logins(update.add_handles), + ], + owner=entry, + ) + notes: list[str] = [] + if _changes_lookup_names(update): + notes.extend(_pin_members_to_key(doc, index, entry.key)) + if update.name: + table["name"] = update.name + notes.append(f"Set name of {entry.key} to '{update.name}'.") + if update.email: + table["email"] = update.email + notes.append(f"Set email of {entry.key} to '{update.email}'.") + elif update.email == "" and "email" in table: + del table["email"] + notes.append(f"Clear email of {entry.key}.") + alias_edit = _ListEdit( + "aliases", "alias", update.add_aliases, update.remove_aliases + ) + notes.extend(_edit_list(table, alias_edit, entry.key)) + notes.extend(_add_handles(table, update.add_handles, entry.key)) + notes.extend(_remove_handles(table, update.remove_handles, entry.key)) + if not notes: + raise MyGroupsError("Nothing to change; pass at least one option.") + return notes + + +def _remove_member_everywhere( + doc: TOMLDocument, index: dict[IndexKey, set[Entry]], identity: Identity +) -> list[str]: + notes: list[str] = [] + for group_key, group in _groups(doc).items(): + removed = _list_remove( + group, "members", lambda item: member_identity(index, item) == identity + ) + if removed: + notes.append(f"Remove {identity[1]} from group {group_key}.") + return notes + + +def remove_user(doc: TOMLDocument, who: str) -> list[str]: + """Remove a user and take them out of every group that lists them.""" + index = build_index(doc) + entry = _require(index, who, "user") + notes = [f"Remove user {entry.key}."] + notes.extend(_remove_member_everywhere(doc, index, ("user", entry.key))) + del _users(doc)[entry.key] + return notes + + +def _prepare_member( + doc: TOMLDocument, member: str, existing: set[Identity], group_key: str +) -> tuple[str | None, str]: + """Return ``(value to store or None, note)``; unknown names become stub users.""" + index = build_index(doc) + identity = member_identity(index, member) + kind, value = identity + prefix, bare = split_kind(member) + if kind == "email" and prefix: + raise NameNotFoundError( + f"No user has the email '{bare}'; pass the bare address to add it " + "without a user entry." + ) + if identity in existing: + return None, f"'{member}' is already a member of {group_key}." + existing.add(identity) + if kind == "user": + return value, f"Add {value} to {group_key}." + if kind == "email": + address = parse_email(bare) or bare.strip() + return address, f"Add {address} to {group_key} (no user entry yet)." + found = find_entry(index, member) + if found is not None and found.kind == "group": + raise ConflictError(f"'{member}' is {found}; nest it with --include instead.") + name = bare.strip() + key = _unique_key(doc, index, slugify(name) or "user") + _section(doc, "users")[key] = _new_table({"name": name}) + existing.add(("user", key)) + note = f"Create user {key} with only a name ('{name}') and add them to {group_key}." + return key, note + _hint(index, member) + + +def add_group(doc: TOMLDocument, group: GroupInput) -> list[str]: + """Add a group; members are keys, aliases, or emails, and new names become stubs.""" + if not group.members and not group.includes: + raise MyGroupsError("A new group needs at least one --member or --include.") + index = build_index(doc) + key = group.key or slugify(group.name) + if not key: + raise MyGroupsError(f"Cannot derive a key from '{group.name}'; pass --key.") + _require_valid_key(key) + if key in _users(doc) or key in _groups(doc): + raise ConflictError(f"Key '{key}' is already taken; pass a different --key.") + aliases = [alias for alias in group.aliases if alias_key(alias) != alias_key(key)] + if alias_key(group.name) != alias_key(key): + aliases.insert(0, group.name) + _ensure_terms_free(index, [key, *aliases]) + nested = [_require(index, include, "group").key for include in group.includes] + notes = [f"Add group {key}."] + stored: list[str] = [] + existing: set[Identity] = set() + for member in group.members: + value, note = _prepare_member(doc, member, existing, key) + notes.append(note) + if value is not None: + stored.append(value) + notes.extend(f"Include group {nested_key} in {key}." for nested_key in nested) + _section(doc, "groups")[key] = _new_table( + {"aliases": aliases, "members": stored, "groups": nested} + ) + return notes + + +def add_group_members(doc: TOMLDocument, group: str, members: list[str]) -> list[str]: + """Add members to a group, skipping ones already listed.""" + index = build_index(doc) + entry = _require(index, group, "group") + table = _groups(doc)[entry.key] + existing = {member_identity(index, member) for member in _texts(table, "members")} + notes: list[str] = [] + for member in members: + value, note = _prepare_member(doc, member, existing, entry.key) + notes.append(note) + if value is not None: + _list_add(table, "members", value) + return notes + + +def remove_group_members( + doc: TOMLDocument, group: str, members: list[str] +) -> list[str]: + """Remove direct members from a group. User entries are kept.""" + index = build_index(doc) + entry = _require(index, group, "group") + table = _groups(doc)[entry.key] + notes: list[str] = [] + for member in members: + identity = member_identity(index, member) + if not _list_remove( + table, + "members", + lambda item, identity=identity: member_identity(index, item) == identity, + ): + raise NameNotFoundError( + f"'{member}' is not a direct member of {entry.key}." + ) + notes.append(f"Remove {member} from {entry.key}.") + return notes + + +def _include_group( + doc: TOMLDocument, index: dict[IndexKey, set[Entry]], key: str, include: str +) -> str: + nested = _require(index, include, "group").key + if nested == key: + raise ConflictError(f"Group {key} cannot include itself.") + if any( + find_entry(index, item, kind="group") == Entry("group", nested) + for item in _texts(_groups(doc)[key], "groups") + ): + return f"{key} already includes {nested}." + _list_add(_groups(doc)[key], "groups", nested) + expand_group(doc, index, key) + return f"Include group {nested} in {key}." + + +def update_group(doc: TOMLDocument, group: str, update: GroupUpdate) -> list[str]: + """Change a group's aliases and nested groups.""" + index = build_index(doc) + entry = _require(index, group, "group") + table = _groups(doc)[entry.key] + _ensure_terms_free(index, list(update.add_aliases), owner=entry) + alias_edit = _ListEdit( + "aliases", "alias", update.add_aliases, update.remove_aliases + ) + notes = _pin_nested_to_key(doc, index, entry.key) if update.remove_aliases else [] + notes.extend(_edit_list(table, alias_edit, entry.key)) + notes.extend( + _include_group(doc, index, entry.key, include) for include in update.includes + ) + for exclude in update.excludes: + target = _require(index, exclude, "group") + if not _list_remove( + table, + "groups", + lambda item, target=target: find_entry(index, item, kind="group") == target, + ): + raise NameNotFoundError(f"{entry.key} does not include group {target.key}.") + notes.append(f"Stop including group {target.key} in {entry.key}.") + if not notes: + raise MyGroupsError("Nothing to change; pass at least one option.") + return notes + + +def remove_group(doc: TOMLDocument, group: str) -> list[str]: + """Remove a group and every reference to it from other groups. Users are kept.""" + index = build_index(doc) + entry = _require(index, group, "group") + notes = [f"Remove group {entry.key}."] + for other_key, other in _groups(doc).items(): + if other_key == entry.key: + continue + if _list_remove( + other, "groups", lambda item: find_entry(index, item, kind="group") == entry + ): + notes.append(f"Stop including {entry.key} in {other_key}.") + del _groups(doc)[entry.key] + return notes + + +# === Writing === + + +def _render(doc: TOMLDocument) -> str: + # tomlkit separates a newly created top-level section from the table before it even + # when that table already ends in a blank line; keep one blank line per header. + # The pattern can also match inside a multiline string, so keep the cleanup only + # if every value survives it. + text = doc.as_string() + cleaned = re.sub(r"\n{3,}(?=\[)", "\n\n", text).rstrip("\n") + "\n" + if tomlkit.parse(cleaned).unwrap() == doc.unwrap(): + return cleaned + return text + + +def _read_current(path: Path) -> str | None: + return path.read_text(encoding="utf-8") if path.exists() else None + + +def _lock_file(handle: Any) -> None: + """Lock ``handle`` exclusively without waiting; raise ``OSError`` if it's held.""" + if fcntl is not None: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + return + # msvcrt locks bytes from the current position; byte 0 may lie past EOF. + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + + +def _unlock_file(handle: Any) -> None: + if fcntl is not None: + fcntl.flock(handle, fcntl.LOCK_UN) + return + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + + +@contextmanager +def _write_lock(path: Path) -> Iterator[None]: + """Hold an exclusive lock on ``<name>.lock`` so two writers can't interleave.""" + if fcntl is None and msvcrt is None: + raise ConflictError("This platform has no file locking; refusing to write.") + path.parent.mkdir(parents=True, exist_ok=True) + with path.with_name(f"{path.name}.lock").open("a", encoding="utf-8") as handle: + try: + _lock_file(handle) + except OSError as exc: + raise ConflictError( + f"Another write to {path} is in progress; try again after it ends." + ) from exc + try: + yield + finally: + _unlock_file(handle) + + +def _print_plan(path: Path, notes: list[str], before: str | None, after: str) -> None: + print(f"Planned changes to {path}:") + for note in notes: + print(f" - {note}") + diff = difflib.unified_diff( + (before or "").splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=f"{path} (current)", + tofile=f"{path} (planned)", + ) + print() + sys.stdout.writelines(diff) + + +def confirm_or_abort(path: Path, options: WriteOptions) -> None: + """Require consent before a real write; ``--yes`` or ``--dry-run`` skips it.""" + if options.yes or options.dry_run: + return + if not sys.stdin.isatty(): + raise MyGroupsError( + "Refusing to write without a terminal to confirm; re-run with --yes." + ) + sys.stderr.write(f"\nWrite these changes to {path}? [y/N] ") + sys.stderr.flush() + try: + answer = input() + except EOFError: + answer = "" + if answer.strip().casefold() not in _YES_ANSWERS: + raise AbortedError("Aborted; nothing written.") + + +def _write_target(path: Path) -> Path: + """Return the file a write replaces: a symlink's target, so the link survives.""" + return path.resolve() if path.is_symlink() else path + + +def write_config(path: Path, text: str) -> Path | None: + """Atomically replace ``path`` with ``text``, keeping a ``<name>.bak`` copy.""" + path.parent.mkdir(parents=True, exist_ok=True) + backup = None + if path.exists(): + backup = path.with_name(f"{path.name}.bak") + shutil.copy2(path, backup) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(text) + temp_path = Path(handle.name) + try: + os.replace(temp_path, path) + except OSError: + temp_path.unlink(missing_ok=True) + raise + return backup + + +def _commit( + path: Path, + notes: list[str], + before: str | None, + after: str, + options: WriteOptions, +) -> int: + """Show the plan, confirm, and write ``after`` if the file still holds ``before``. + + ``before`` is ``None`` when the file did not exist when the plan was made. + """ + _print_plan(path, notes, before, after) + if options.dry_run: + print("\nDry run: nothing written.") + return 0 + confirm_or_abort(path, options) + if _read_current(path) != before: + raise ConflictError( + f"{path} changed after this plan was made; nothing written. Run the " + "command again to plan against the current file." + ) + backup = write_config(path, after) + suffix = f" (previous version saved to {backup})" if backup else "" + print(f"\nWrote {path}{suffix}.") + return 0 + + +def _lock_unless_dry_run(path: Path, options: WriteOptions) -> AbstractContextManager: + return nullcontext() if options.dry_run else _write_lock(path) + + +def _reject_new_errors(doc: TOMLDocument, existing: set[str]) -> None: + new_errors = [error for error in validate(doc)[0] if error not in existing] + if new_errors: + raise ConflictError( + "Nothing written; the change would leave the file inconsistent:\n " + + "\n ".join(new_errors) + ) + + +def apply_change(options: WriteOptions, mutate: Mutation) -> int: + """Load the file, apply ``mutate``, show the plan, and write unless dry-running. + + The change is refused if it introduces a ``validate`` error, such as a name that + now refers to two entries. + """ + path = _write_target(get_config_path()) + with _lock_unless_dry_run(path, options): + before = _read_current(path) + doc = load_document(path, missing_ok=True) + existing_errors = set(validate(doc)[0]) + notes = mutate(doc) + _reject_new_errors(doc, existing_errors) + if before is not None and doc.as_string() == before: + for note in notes: + print(note) + print("No changes.") + return 0 + return _commit(path, notes, before, _render(doc), options) + + +# === Command handlers === + + +def _write_options(args: argparse.Namespace) -> WriteOptions: + return WriteOptions(dry_run=args.dry_run, yes=args.yes) + + +def _handle_path(_args: argparse.Namespace) -> int: + print(get_config_path()) + return 0 + + +def _handles_text(user: Any) -> str: + return "; ".join( + f"{service}: {', '.join(logins)}" for service, logins in _handles(user).items() + ) + + +def _user_line(key: str, user: Any) -> str: + parts = [key, _text(user, "name")] + aliases = _texts(user, "aliases") + if aliases: + parts.append(f"aliases: {', '.join(aliases)}") + if _text(user, "email"): + parts.append(f"email: {_text(user, 'email')}") + if _handles(user): + parts.append(_handles_text(user)) + return " " + " | ".join(parts) + + +def _group_line(doc: TOMLDocument, index: dict[IndexKey, set[Entry]], key: str) -> str: + group = _groups(doc)[key] + aliases = _texts(group, "aliases") + try: + members, unresolved = _collect(doc, index, expand_group(doc, index, key)) + count = f"{len(members) + len(unresolved)} members" + except MyGroupsError: + count = "invalid; run validate" + alias_text = f" | aliases: {', '.join(aliases)}" if aliases else "" + return f" {key}{alias_text} | {count}" + + +def _handle_list(_args: argparse.Namespace) -> int: + doc = load_document(get_config_path()) + index = build_index(doc) + users = _users(doc) + groups = _groups(doc) + print(f"Users ({len(users)}):") + for key, user in users.items(): + print(_user_line(key, user)) + print(f"Groups ({len(groups)}):") + for key in groups: + print(_group_line(doc, index, key)) + return 0 + + +def _groups_containing( + doc: TOMLDocument, index: dict[IndexKey, set[Entry]], identity: Identity +) -> list[str]: + found = [] + for key in _groups(doc): + try: + members = expand_group(doc, index, key) + except MyGroupsError: + continue + if any(member_identity(index, member) == identity for member in members): + found.append(key) + return found + + +def _show_group(doc: TOMLDocument, index: dict[IndexKey, set[Entry]], key: str) -> None: + group = _groups(doc)[key] + print(Entry("group", key)) + for name in ("aliases", "groups"): + if _texts(group, name): + print(f" {name}: {', '.join(_texts(group, name))}") + members, unresolved = _collect(doc, index, expand_group(doc, index, key)) + print(f" members ({len(members) + len(unresolved)}):") + for member in members: + label = member["key"] or member["email"] + detail = " ".join( + part + for part in ( + member["name"], + f"<{member['email']}>" if member["email"] else "", + ) + if part + ) + print(f" {label} {detail}".rstrip()) + for name in unresolved: + print(f" {name} (unresolved)") + + +def _show_user(doc: TOMLDocument, index: dict[IndexKey, set[Entry]], key: str) -> None: + user = _users(doc)[key] + print(Entry("user", key)) + for name, value in user.items(): + if name == _HANDLES: + continue + shown = ( + ", ".join(str(item) for item in value) if isinstance(value, list) else value + ) + print(f" {name}: {shown}") + for service, logins in _handles(user).items(): + print(f" {service}: {', '.join(logins)}") + containing = _groups_containing(doc, index, ("user", key)) + print(f" groups: {', '.join(containing) if containing else '(none)'}") + + +def _handle_show(args: argparse.Namespace) -> int: + doc = load_document(get_config_path()) + index = build_index(doc) + entry = find_entry(index, args.name) + if entry is None: + raise NameNotFoundError( + f"No user or group named '{args.name}'.{_hint(index, args.name)}" + ) + if entry.kind == "group": + _show_group(doc, index, entry.key) + else: + _show_user(doc, index, entry.key) + return 0 + + +def _member_values(member: dict[str, Any], args: argparse.Namespace) -> list[str]: + if args.email: + return [member["email"]] if member["email"] else [] + return member["handles"].get(args.handle.casefold(), []) + + +def _print_lines(result: dict[str, Any], args: argparse.Namespace) -> int: + """Print each member's values, one per line; return how many members had none.""" + wanted = "email" if args.email else f"{args.handle} handle" + end = "\0" if args.null else "\n" + missing = 0 + for member in result["members"]: + values = _member_values(member, args) + for value in values: + sys.stdout.write(value + end) + if not values: + missing += 1 + label = member["name"] or member["key"] or member["email"] + print(f"warning: no {wanted} for {label}", file=sys.stderr) + return missing + + +def _handle_resolve(args: argparse.Namespace) -> int: + doc = load_document(get_config_path()) + result = resolve(doc, args.names) + missing = 0 + if args.email or args.handle: + missing = _print_lines(result, args) + else: + print(json.dumps(to_jscontact(doc, result), indent=2, ensure_ascii=False)) + for name in result["unresolved"]: + print(f"warning: unresolved name '{name}'", file=sys.stderr) + incomplete = missing or result["unresolved"] + return EXIT_INCOMPLETE if args.strict and incomplete else 0 + + +def _handle_validate(_args: argparse.Namespace) -> int: + path = get_config_path() + doc = load_document(path) + errors, warnings = validate(doc) + for warning in warnings: + print(f"warning: {warning}", file=sys.stderr) + for error in errors: + print(f"error: {error}", file=sys.stderr) + if errors: + return 1 + print(f"OK: {len(_users(doc))} users, {len(_groups(doc))} groups in {path}.") + return 0 + + +def _handle_init(args: argparse.Namespace) -> int: + path = _write_target(get_config_path()) + options = _write_options(args) + with _lock_unless_dry_run(path, options): + if path.exists(): + raise ConflictError(f"{path} already exists; init never overwrites it.") + text = _EXAMPLE_PATH.read_text(encoding="utf-8") + return _commit( + path, ["Create the file from the example template."], None, text, options + ) + + +def _handle_user_add(args: argparse.Namespace) -> int: + user = UserInput( + name=args.name, + aliases=args.alias, + email=args.email, + handles=args.handle, + key=args.key, + ) + return apply_change(_write_options(args), lambda doc: add_user(doc, user)) + + +def _handle_user_update(args: argparse.Namespace) -> int: + update = UserUpdate( + name=args.name, + add_aliases=args.add_alias, + remove_aliases=args.remove_alias, + email=args.email, + add_handles=args.add_handle, + remove_handles=args.remove_handle, + ) + return apply_change( + _write_options(args), lambda doc: update_user(doc, args.who, update) + ) + + +def _handle_user_remove(args: argparse.Namespace) -> int: + return apply_change(_write_options(args), lambda doc: remove_user(doc, args.who)) + + +def _handle_group_add(args: argparse.Namespace) -> int: + group = GroupInput( + name=args.name, + aliases=args.alias, + members=args.member, + includes=args.include, + key=args.key, + ) + return apply_change(_write_options(args), lambda doc: add_group(doc, group)) + + +def _handle_group_update(args: argparse.Namespace) -> int: + update = GroupUpdate( + add_aliases=args.add_alias, + remove_aliases=args.remove_alias, + includes=args.include, + excludes=args.exclude, + ) + return apply_change( + _write_options(args), lambda doc: update_group(doc, args.group, update) + ) + + +def _handle_group_add_member(args: argparse.Namespace) -> int: + return apply_change( + _write_options(args), + lambda doc: add_group_members(doc, args.group, args.members), + ) + + +def _handle_group_remove_member(args: argparse.Namespace) -> int: + return apply_change( + _write_options(args), + lambda doc: remove_group_members(doc, args.group, args.members), + ) + + +def _handle_group_remove(args: argparse.Namespace) -> int: + return apply_change(_write_options(args), lambda doc: remove_group(doc, args.group)) + + +# === Argument parsing === + + +def _parse_handle(text: str, *, user_required: bool) -> Handle: + service, sep, user = text.partition("=") + handle = Handle(service.strip().casefold(), user.strip() or None) + if not handle.service or (handle.user is None and (sep or user_required)): + raise argparse.ArgumentTypeError( + f"expected SERVICE=USER, e.g. github=octocat; got '{text}'" + ) + return handle + + +def _handle_arg(text: str) -> Handle: + return _parse_handle(text, user_required=True) + + +def _handle_ref_arg(text: str) -> Handle: + return _parse_handle(text, user_required=False) + + +def _write_parent() -> argparse.ArgumentParser: + parent = argparse.ArgumentParser(add_help=False) + parent.add_argument( + "--dry-run", + action="store_true", + help="Show the planned change without writing it.", + ) + parent.add_argument( + "--yes", "-y", action="store_true", help="Skip the confirmation prompt." + ) + return parent + + +def _add_user_commands(subparsers: Any, write_parent: argparse.ArgumentParser) -> None: + user = subparsers.add_parser("user", help="Add, update, or remove a user.") + commands = user.add_subparsers(dest="user_command", required=True) + + add = commands.add_parser("add", parents=[write_parent], help="Add a user.") + add.add_argument("name", help="Display name, e.g. 'Ada Lovelace'.") + add.add_argument( + "--key", help="Table key; defaults to a slug of the first alias or the name." + ) + add.add_argument( + "--alias", action="append", default=[], help="Nickname or shortcut; repeatable." + ) + add.add_argument("--email", help="Email address.") + add.add_argument( + "--handle", + action="append", + default=[], + type=_handle_arg, + metavar="SERVICE=USER", + help="Username on a service, e.g. github=octocat; repeatable.", + ) + add.set_defaults(handler=_handle_user_add) + + update = commands.add_parser( + "update", parents=[write_parent], help="Change a user." + ) + update.add_argument("who", help="Key, alias, name, email, or handle of the user.") + update.add_argument("--name", help="New display name.") + update.add_argument( + "--add-alias", action="append", default=[], help="Alias to add; repeatable." + ) + update.add_argument( + "--remove-alias", + action="append", + default=[], + help="Alias to remove; repeatable.", + ) + update.add_argument("--email", help='Email address. Pass "" to clear it.') + update.add_argument( + "--add-handle", + action="append", + default=[], + type=_handle_arg, + metavar="SERVICE=USER", + help="Handle to add, e.g. github=octocat; repeatable.", + ) + update.add_argument( + "--remove-handle", + action="append", + default=[], + type=_handle_ref_arg, + metavar="SERVICE[=USER]", + help="Handle to remove, or SERVICE alone to remove all of them; repeatable.", + ) + update.set_defaults(handler=_handle_user_update) + + remove = commands.add_parser( + "remove", + parents=[write_parent], + help="Remove a user from the file and all groups.", + ) + remove.add_argument("who", help="Key, alias, name, email, or handle of the user.") + remove.set_defaults(handler=_handle_user_remove) + + +def _add_group_commands(subparsers: Any, write_parent: argparse.ArgumentParser) -> None: + group = subparsers.add_parser( + "group", help="Add, update, or remove a group and its members." + ) + commands = group.add_subparsers(dest="group_command", required=True) + + add = commands.add_parser("add", parents=[write_parent], help="Add a group.") + add.add_argument("name", help="Group name, e.g. 'platform team'.") + add.add_argument("--key", help="Table key; defaults to a slug of the name.") + add.add_argument( + "--alias", + action="append", + default=[], + help="Other name, e.g. 'my pod'; repeatable.", + ) + add.add_argument( + "--member", + action="append", + default=[], + help="User key, alias, name, or email; repeatable.", + ) + add.add_argument( + "--include", + action="append", + default=[], + help="Group to nest inside this one; repeatable.", + ) + add.set_defaults(handler=_handle_group_add) + + update = commands.add_parser( + "update", parents=[write_parent], help="Change a group's aliases or nesting." + ) + update.add_argument("group", help="Key or alias of the group.") + update.add_argument( + "--add-alias", action="append", default=[], help="Alias to add; repeatable." + ) + update.add_argument( + "--remove-alias", + action="append", + default=[], + help="Alias to remove; repeatable.", + ) + update.add_argument( + "--include", action="append", default=[], help="Group to nest; repeatable." + ) + update.add_argument( + "--exclude", + action="append", + default=[], + help="Nested group to drop; repeatable.", + ) + update.set_defaults(handler=_handle_group_update) + + add_member = commands.add_parser( + "add-member", parents=[write_parent], help="Add members to a group." + ) + add_member.add_argument("group", help="Key or alias of the group.") + add_member.add_argument( + "members", nargs="+", help="User keys, aliases, names, or emails." + ) + add_member.set_defaults(handler=_handle_group_add_member) + + remove_member = commands.add_parser( + "remove-member", parents=[write_parent], help="Remove members from a group." + ) + remove_member.add_argument("group", help="Key or alias of the group.") + remove_member.add_argument( + "members", nargs="+", help="User keys, aliases, names, or emails." + ) + remove_member.set_defaults(handler=_handle_group_remove_member) + + remove = commands.add_parser( + "remove", parents=[write_parent], help="Remove a group; users are kept." + ) + remove.add_argument("group", help="Key or alias of the group.") + remove.set_defaults(handler=_handle_group_remove) + + +def _add_resolve_command(subparsers: Any) -> None: + resolve_parser = subparsers.add_parser( + "resolve", + help="Resolve groups or users to JSContact (RFC 9553) JSON, or to one email " + "or handle per line.", + ) + resolve_parser.add_argument( + "names", nargs="+", help="Group or user names, e.g. 'my pod' Ada group:pod." + ) + output = resolve_parser.add_mutually_exclusive_group() + output.add_argument( + "--email", action="store_true", help="Print one email per line instead." + ) + output.add_argument( + "--handle", + metavar="SERVICE", + help="Print each member's SERVICE handles, one per line, instead.", + ) + resolve_parser.add_argument( + "-0", + "--null", + action="store_true", + help="With --email or --handle, end each value with NUL, for xargs -0.", + ) + resolve_parser.add_argument( + "--strict", + action="store_true", + help=f"Exit {EXIT_INCOMPLETE} if a name is unresolved or a member lacks the " + "requested email or handle.", + ) + resolve_parser.set_defaults(handler=_handle_resolve) + + +def build_parser() -> argparse.ArgumentParser: + """Build the ``my_groups.py`` argument parser.""" + parser = argparse.ArgumentParser( + prog="my_groups.py", description=__doc__.splitlines()[0] + ) + subparsers = parser.add_subparsers(dest="command", required=True) + write_parent = _write_parent() + + subparsers.add_parser("path", help="Print the groups file path.").set_defaults( + handler=_handle_path + ) + subparsers.add_parser("list", help="List users and groups.").set_defaults( + handler=_handle_list + ) + show = subparsers.add_parser("show", help="Show one user or group.") + show.add_argument("name", help="Key, alias, name, email, or handle.") + show.set_defaults(handler=_handle_show) + _add_resolve_command(subparsers) + subparsers.add_parser("validate", help="Check the file for problems.").set_defaults( + handler=_handle_validate + ) + init = subparsers.add_parser( + "init", + parents=[write_parent], + help="Create the file from the example template.", + ) + init.set_defaults(handler=_handle_init) + + _add_user_commands(subparsers, write_parent) + _add_group_commands(subparsers, write_parent) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the CLI and return its exit code.""" + parser = build_parser() + args = parser.parse_args(argv) + if getattr(args, "null", False) and not (args.email or args.handle): + parser.error("--null needs --email or --handle.") + try: + return args.handler(args) + except MyGroupsError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ess/ess-my-groups/scripts/test_my_groups.py b/skills/ess/ess-my-groups/scripts/test_my_groups.py new file mode 100644 index 0000000..3a6de8c --- /dev/null +++ b/skills/ess/ess-my-groups/scripts/test_my_groups.py @@ -0,0 +1,1139 @@ +"""Tests for the ess-my-groups resolver and editor.""" + +import json +import os +import sys +import tomllib +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import my_groups # noqa: E402 -- the script directory is not a package + +_USAGE_ERROR = 2 # argparse's exit code for bad arguments + +SEED = """\ +# My groups -- keep this comment + +[users.ada] # inline note +name = "Ada Lovelace" +aliases = ["Ada"] +email = "ada@example.com" + +[users.grace] +name = "Grace Hopper" +email = "grace@example.com" + +[groups.platform-team] +aliases = ["my pod"] +members = ["ada", "grace", "alan@example.com", "Mystery Person"] + +[groups.my-team] +aliases = ["my team"] +members = ["ada", "jdoe@example.com"] +""" + +HANDLES = """\ +[users.ada] +name = "Ada Lovelace" +aliases = ["Ada"] +email = "ada@example.com" + +[users.ada.handles] +github = ["ada_example", "ada_oss"] +webex = "ada@example.com" + +[users.grace] +name = "Grace Hopper" + +[groups.pod] +members = ["ada", "grace", "jdoe@example.com", "Nobody"] +""" + + +class _FakeStdin: + def __init__(self, *, tty: bool) -> None: + self._tty = tty + + def isatty(self) -> bool: + return self._tty + + +@pytest.fixture(name="config") +def fixture_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + path = tmp_path / "groups.toml" + monkeypatch.setenv(my_groups.CONFIG_ENV_VAR, str(path)) + monkeypatch.setattr(sys, "stdin", _FakeStdin(tty=False)) + return path + + +@pytest.fixture(name="seeded") +def fixture_seeded(config: Path) -> Path: + config.write_text(SEED, encoding="utf-8") + return config + + +@pytest.fixture(name="with_handles") +def fixture_with_handles(config: Path) -> Path: + config.write_text(HANDLES, encoding="utf-8") + return config + + +def _resolve(*names: str) -> dict: + doc = my_groups.load_document(my_groups.get_config_path()) + return my_groups.resolve(doc, list(names)) + + +def _keys(result: dict) -> list[str | None]: + return [member["key"] or member["email"] for member in result["members"]] + + +def _load(path: Path) -> dict: + return tomllib.loads(path.read_text(encoding="utf-8")) + + +# === Paths === + + +def test_override_env_var_wins(tmp_path: Path) -> None: + environ = { + my_groups.CONFIG_ENV_VAR: str(tmp_path / "x.toml"), + "XDG_CONFIG_HOME": "/elsewhere", + } + assert my_groups.get_config_path(environ) == tmp_path / "x.toml" + + +def test_absolute_xdg_config_home_is_used(tmp_path: Path) -> None: + path = my_groups.get_config_path({"XDG_CONFIG_HOME": str(tmp_path)}) + assert path == tmp_path / "ess-my-groups" / "groups.toml" + + +@pytest.mark.parametrize("config_home", ["", "relative/config"]) +def test_relative_or_unset_xdg_falls_back_to_home( + config_home: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + path = my_groups.get_config_path({"XDG_CONFIG_HOME": config_home}) + assert path == tmp_path / ".config" / "ess-my-groups" / "groups.toml" + + +# === Names === + + +def test_nickname_key_follows_rfc_8266() -> None: + assert my_groups.nickname_key(" Platform \u3000 Team ") == "platform team" + assert my_groups.nickname_key("Jean-Luc") != my_groups.nickname_key("Jean Luc") + assert my_groups.nickname_key("Zoe\u0308") == my_groups.nickname_key("Zo\u00eb") + assert my_groups.nickname_key("\ufb01le") == "file" + + +def test_handle_key_follows_rfc_8265() -> None: + assert my_groups.handle_key(" Sam_Smith ") == "sam_smith" + assert my_groups.handle_key("sam-smith") != my_groups.handle_key("sam_smith") + assert my_groups.handle_key("\uff53\uff41\uff4d") == "sam" + assert my_groups.handle_key("zoe\u0308") == my_groups.handle_key("zo\u00eb") + + +@pytest.mark.parametrize( + ("text", "key"), + [ + ("Alice.Smith@Example.COM", "alice.smith@example.com"), + (" a-b@example.com ", "a-b@example.com"), + ("@ada", None), + ("ada@", None), + ("a@b@example.com", None), + ("a b@example.com", None), + ("Ada", None), + ('"a b"@example.com', '"a b"@example.com'), + ("a@[192.0.2.1]", "a@[192.0.2.1]"), + ("Jane@Example.com (work)", "jane@example.com"), + ("a..b@example.com", None), + (".a@example.com", None), + ("a@example\n.com", None), + ], +) +def test_email_key(text: str, key: str | None) -> None: + assert my_groups.email_key(text) == key + + +def test_parse_email_keeps_case_and_drops_comments() -> None: + assert my_groups.parse_email(" Jane@Example.com (work) ") == "Jane@Example.com" + + +@pytest.mark.parametrize( + ("key", "problem"), + [ + ("ada", None), + ("user:ada", "can't start with"), + ("Group: pod", "can't start with"), + ("!!", "at least one ASCII"), + ("", "at least one ASCII"), + ], +) +def test_key_error(key: str, problem: str | None) -> None: + error = my_groups.key_error(key) + assert error == problem if problem is None else problem in error + + +@pytest.mark.parametrize( + ("text", "split"), + [ + ("user:ada", ("user", "ada")), + ("GROUP: my pod", ("group", "my pod")), + ("ada", (None, "ada")), + ("team:ada", (None, "team:ada")), + ("user:", (None, "user:")), + ], +) +def test_split_kind(text: str, split: tuple[str | None, str]) -> None: + assert my_groups.split_kind(text) == split + + +# === Resolution === + + +@pytest.mark.usefixtures("seeded") +@pytest.mark.parametrize( + "name", ["MY POD", " my pod ", "Platform Team", "platform_team"] +) +def test_resolve_group_by_alias_or_key_spelling(name: str) -> None: + assert _keys(_resolve(name)) == ["ada", "grace", "alan@example.com"] + + +def test_handles_differing_in_punctuation_name_different_users(config: Path) -> None: + config.write_text( + '[users.sam]\nname = "Sam"\n\n[users.sam.handles]\ngithub = ["sam_smith"]\n\n' + '[users.pat]\nname = "Pat"\n\n[users.pat.handles]\nslack = ["sam-smith"]\n', + encoding="utf-8", + ) + assert my_groups.validate(my_groups.load_document(config))[0] == [] + assert _keys(_resolve("SAM_SMITH")) == ["sam"] + assert _keys(_resolve("sam-smith")) == ["pat"] + + +def test_handle_lookup_does_not_fold_punctuation(config: Path) -> None: + config.write_text( + '[users.pat]\nname = "Pat"\n\n[users.pat.handles]\nslack = ["sam-smith"]\n', + encoding="utf-8", + ) + with pytest.raises(my_groups.NameNotFoundError, match="Did you mean: user:pat"): + _resolve("sam_smith") + + +def test_handle_that_is_another_users_alias_is_refused( + seeded: Path, capsys: pytest.CaptureFixture +) -> None: + argv = ["user", "update", "grace", "--add-handle", "slack=ADA", "--yes"] + assert my_groups.main(argv) == 1 + assert "already refers to user:ada" in capsys.readouterr().err + assert seeded.read_text(encoding="utf-8") == SEED + + +def test_names_match_across_unicode_forms(config: Path) -> None: + config.write_text('[users.zoe]\nname = "Zo\u00eb Smith"\n', encoding="utf-8") + assert _keys(_resolve("zoe\u0308 smith")) == ["zoe"] + + +@pytest.mark.usefixtures("seeded") +def test_alias_punctuation_counts_but_gets_a_hint() -> None: + with pytest.raises(my_groups.NameNotFoundError, match="group:platform-team"): + _resolve("my-pod") + + +@pytest.mark.usefixtures("seeded") +def test_overlapping_groups_dedupe_shared_users() -> None: + assert _keys(_resolve("my pod", "my team")) == [ + "ada", + "grace", + "alan@example.com", + "jdoe@example.com", + ] + + +@pytest.mark.usefixtures("seeded") +def test_undeclared_email_and_unresolved_name() -> None: + result = _resolve("my pod") + email_member = result["members"][2] + assert email_member == { + "key": None, + "name": None, + "aliases": [], + "email": "alan@example.com", + "handles": {}, + } + assert result["unresolved"] == ["Mystery Person"] + + +@pytest.mark.usefixtures("with_handles") +def test_user_resolves_by_nickname_with_handles_by_service() -> None: + (member,) = _resolve("ada")["members"] + assert member["key"] == "ada" + assert member["handles"] == { + "github": ["ada_example", "ada_oss"], + "webex": ["ada@example.com"], + } + + +@pytest.mark.usefixtures("with_handles") +def test_handles_are_lookup_names() -> None: + assert _keys(_resolve("ADA_EXAMPLE")) == ["ada"] + + +@pytest.mark.usefixtures("seeded") +def test_unknown_name_suggests_close_match() -> None: + with pytest.raises(my_groups.NameNotFoundError, match="Did you mean: user:ada"): + _resolve("adaa") + + +@pytest.mark.usefixtures("seeded") +def test_kind_prefix_limits_the_match() -> None: + assert _keys(_resolve("user:Ada")) == ["ada"] + assert _keys(_resolve("group:my team")) == ["ada", "jdoe@example.com"] + with pytest.raises(my_groups.NameNotFoundError): + _resolve("group:ada") + + +def test_kind_prefix_settles_a_name_shared_by_a_user_and_a_group( + config: Path, +) -> None: + config.write_text( + '[users.pat]\nname = "Pat"\naliases = ["pod"]\n\n' + '[groups.team]\naliases = ["pod"]\nmembers = ["x@example.com"]\n', + encoding="utf-8", + ) + with pytest.raises(my_groups.ConflictError, match="group:team, user:pat"): + _resolve("pod") + assert _keys(_resolve("user:pod")) == ["pat"] + assert _keys(_resolve("group:pod")) == ["x@example.com"] + + +def test_emails_keep_punctuation_when_matching(config: Path) -> None: + config.write_text( + '[users.alice]\nname = "Alice"\nemail = "alice-smith@example.com"\n\n' + '[groups.pod]\nmembers = ["alice_smith@example.com", ' + '"ALICE-SMITH@example.com"]\n', + encoding="utf-8", + ) + members = _resolve("pod")["members"] + assert [(member["key"], member["email"]) for member in members] == [ + (None, "alice_smith@example.com"), + ("alice", "alice-smith@example.com"), + ] + + +def test_nested_groups_are_included(config: Path) -> None: + config.write_text( + '[groups.a]\nmembers = ["x@example.com"]\ngroups = ["b"]\n' + '[groups.b]\nmembers = ["y@example.com"]\n', + encoding="utf-8", + ) + assert _keys(_resolve("a")) == ["x@example.com", "y@example.com"] + + +def test_group_cycle_is_an_error(config: Path) -> None: + config.write_text( + '[groups.a]\ngroups = ["b"]\n[groups.b]\ngroups = ["a"]\n', encoding="utf-8" + ) + with pytest.raises(my_groups.ConfigError, match="cycle"): + _resolve("a") + errors, _ = my_groups.validate(my_groups.load_document(config)) + assert any("cycle" in error for error in errors) + + +def test_shared_nested_groups_are_walked_once(config: Path) -> None: + layers = 25 # 2**25 paths from the top if each path is walked separately + tables = [ + f'[groups.g{layer}{side}]\ngroups = ["g{layer + 1}a", "g{layer + 1}b"]\n' + for layer in range(layers) + for side in "ab" + ] + tables += [ + f'[groups.g{layers}{side}]\nmembers = ["{side}@example.com"]\n' for side in "ab" + ] + config.write_text("".join(tables), encoding="utf-8") + assert my_groups.validate(my_groups.load_document(config)) == ([], []) + assert _keys(_resolve("g0a")) == ["a@example.com", "b@example.com"] + + +def test_validate_reports_every_broken_nested_group(config: Path) -> None: + config.write_text( + '[groups.a]\naliases = ["Team A"]\ngroups = ["gone", "b", "lost"]\n' + '[groups.b]\ngroups = ["Team A"]\n', + encoding="utf-8", + ) + errors, warnings = my_groups.validate(my_groups.load_document(config)) + assert errors == [ + "Group 'a' includes unknown group 'gone'.", + "Group 'a' includes unknown group 'lost'.", + "Group cycle: a -> b -> a.", + "Group cycle: b -> a -> b.", + ] + assert warnings == ["Group 'b': nested group 'Team A' is group:a; store 'a'."] + + +# === Resolve output === + + +@pytest.mark.usefixtures("with_handles") +def test_resolve_prints_one_handle_per_line(capsys: pytest.CaptureFixture) -> None: + assert my_groups.main(["resolve", "pod", "--handle", "GitHub"]) == 0 + captured = capsys.readouterr() + assert captured.out == "ada_example\nada_oss\n" + assert "no GitHub handle for Grace Hopper" in captured.err + assert "no GitHub handle for jdoe@example.com" in captured.err + assert "unresolved name 'Nobody'" in captured.err + + +@pytest.mark.usefixtures("with_handles") +def test_resolve_prints_one_email_per_line(capsys: pytest.CaptureFixture) -> None: + assert my_groups.main(["resolve", "pod", "--email"]) == 0 + captured = capsys.readouterr() + assert captured.out == "ada@example.com\njdoe@example.com\n" + assert "no email for Grace Hopper" in captured.err + + +def _card(uid: str, kind: str = "individual", **fields: object) -> dict: + return {"@type": "Card", "version": "1.0", "uid": uid, "kind": kind, **fields} + + +@pytest.mark.usefixtures("with_handles") +def test_resolve_prints_jscontact_cards(capsys: pytest.CaptureFixture) -> None: + assert my_groups.main(["resolve", "pod"]) == 0 + captured = capsys.readouterr() + assert json.loads(captured.out) == [ + _card( + "group:pod", + "group", + name={"full": "pod"}, + members={ + "user:ada": True, + "user:grace": True, + "mailto:jdoe@example.com": True, + }, + ), + _card( + "user:ada", + name={"full": "Ada Lovelace"}, + nicknames={"n1": {"name": "Ada"}}, + emails={"e1": {"address": "ada@example.com"}}, + onlineServices={ + "s1": {"service": "github", "user": "ada_example"}, + "s2": {"service": "github", "user": "ada_oss"}, + "s3": {"service": "webex", "user": "ada@example.com"}, + }, + ), + _card("user:grace", name={"full": "Grace Hopper"}), + _card( + "mailto:jdoe@example.com", emails={"e1": {"address": "jdoe@example.com"}} + ), + ] + assert "unresolved name 'Nobody'" in captured.err + + +def test_group_card_members_include_nested_groups( + config: Path, capsys: pytest.CaptureFixture +) -> None: + config.write_text( + '[users.ada]\nname = "Ada"\n\n[users.grace]\nname = "Grace"\n\n' + '[groups.team]\naliases = ["my team"]\nmembers = ["ada"]\ngroups = ["pod"]\n\n' + '[groups.pod]\nmembers = ["grace", "ada"]\n', + encoding="utf-8", + ) + assert my_groups.main(["resolve", "my team", "pod"]) == 0 + cards = json.loads(capsys.readouterr().out) + assert [card["uid"] for card in cards] == [ + "group:team", + "group:pod", + "user:ada", + "user:grace", + ] + assert cards[0]["nicknames"] == {"n1": {"name": "my team"}} + assert cards[0]["members"] == {"user:ada": True, "user:grace": True} + assert cards[1]["members"] == {"user:grace": True, "user:ada": True} + + +@pytest.mark.usefixtures("with_handles") +@pytest.mark.parametrize( + ("argv", "code"), + [ + (["pod", "--handle", "github"], my_groups.EXIT_INCOMPLETE), + (["ada", "--handle", "github"], 0), + (["pod"], my_groups.EXIT_INCOMPLETE), + (["ada", "grace"], 0), + (["grace", "--email"], my_groups.EXIT_INCOMPLETE), + ], +) +def test_strict_exits_nonzero_when_anything_is_missing( + argv: list[str], code: int +) -> None: + assert my_groups.main(["resolve", *argv]) == 0 + assert my_groups.main(["resolve", *argv, "--strict"]) == code + + +@pytest.mark.usefixtures("with_handles") +def test_null_ends_each_value_with_nul(capsys: pytest.CaptureFixture) -> None: + assert my_groups.main(["resolve", "pod", "--handle", "github", "-0"]) == 0 + assert capsys.readouterr().out == "ada_example\0ada_oss\0" + + +@pytest.mark.usefixtures("with_handles") +def test_null_needs_a_line_mode() -> None: + with pytest.raises(SystemExit) as exc: + my_groups.main(["resolve", "pod", "--null"]) + assert exc.value.code == _USAGE_ERROR + + +# === Validation === + + +def test_validate_reports_shared_alias_and_unresolved_member(config: Path) -> None: + config.write_text( + '[users.ada]\nname = "Ada"\naliases = ["pod"]\n' + '[groups.team]\naliases = ["pod"]\nmembers = ["Nobody"]\n', + encoding="utf-8", + ) + errors, warnings = my_groups.validate(my_groups.load_document(config)) + assert any("'pod' refers to more than one entry" in error for error in errors) + assert any("'Nobody'" in warning for warning in warnings) + + +def test_validate_reports_problems_on_stderr( + config: Path, capsys: pytest.CaptureFixture +) -> None: + config.write_text( + '[users.ada]\nname = "Ada"\n\n[groups.a]\nmembers = ["Ada", "b"]\n\n' + '[groups.b]\nmembers = ["ada"]\n', + encoding="utf-8", + ) + assert my_groups.main(["validate"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "warning: Group 'a': member 'Ada' is user:ada" in captured.err + assert "error: Group 'a': member 'b' is group:b" in captured.err + + +def test_validate_flags_members_not_stored_by_key(config: Path) -> None: + config.write_text( + '[users.ada]\nname = "Ada"\nemail = "ada@example.com"\n\n' + '[groups.a]\nmembers = ["Ada", "ada@example.com", "b"]\n\n' + '[groups.b]\nmembers = ["ada"]\n', + encoding="utf-8", + ) + errors, warnings = my_groups.validate(my_groups.load_document(config)) + assert errors == ["Group 'a': member 'b' is group:b; list it in groups."] + assert warnings == [ + "Group 'a': member 'Ada' is user:ada; store 'ada'.", + "Group 'a': member 'ada@example.com' is user:ada; store 'ada'.", + ] + + +def test_schema_rejects_non_string_values(config: Path) -> None: + config.write_text("[users.ada]\nname = 3\n", encoding="utf-8") + with pytest.raises(my_groups.ConfigError, match="users.ada.name"): + my_groups.load_document(config) + + +@pytest.mark.parametrize( + ("toml", "message"), + [ + ('[groups.pod]\nmembers = "ada"\n', "groups.pod.members must be a list"), + ('[groups.pod]\naliases = "pod"\n', "groups.pod.aliases must be a list"), + ( + '[users.ada]\nemail = ["a@example.com"]\n', + "users.ada.email must be a string", + ), + ('[users.ada]\nname = ["Ada"]\n', "users.ada.name must be a string"), + ("[users.ada.handles]\ngithub = 3\n", "users.ada.handles.github must be"), + ('[users.ada]\nhandles = "github"\n', "users.ada.handles must be a table"), + ('[users.ada]\nhandles = ["github"]\n', "users.ada.handles must be a table"), + ('[people.ada]\nname = "Ada"\n', "Unknown top-level entry 'people'"), + ('[users.ada]\nemail = "ada@"\n', "users.ada.email is not an email address"), + ('[users."user:ada"]\nname = "Ada"\n', "users.user:ada: a key can't start"), + ('[groups."!!"]\nmembers = []\n', "groups.!!: a key needs at least one"), + ], +) +def test_schema_checks_each_field_type(config: Path, toml: str, message: str) -> None: + config.write_text(toml, encoding="utf-8") + with pytest.raises(my_groups.ConfigError, match=message): + my_groups.load_document(config) + + +def test_read_commands_report_missing_file( + config: Path, capsys: pytest.CaptureFixture +) -> None: + assert my_groups.main(["list"]) == 1 + assert "No groups file" in capsys.readouterr().err + assert not config.exists() + + +# === Write safety === + +WRITE_COMMANDS = [ + ["user", "add", "Alan Turing", "--alias", "Alan"], + ["user", "update", "ada", "--add-handle", "github=ada_example"], + ["user", "remove", "grace"], + ["group", "add", "reviewers", "--member", "ada"], + ["group", "update", "my team", "--add-alias", "the team"], + ["group", "add-member", "my team", "grace"], + ["group", "remove-member", "my pod", "grace"], + ["group", "remove", "my team"], +] + + +@pytest.mark.parametrize("argv", WRITE_COMMANDS, ids=lambda argv: " ".join(argv[:2])) +def test_dry_run_writes_nothing( + argv: list[str], seeded: Path, capsys: pytest.CaptureFixture +) -> None: + assert my_groups.main([*argv, "--dry-run"]) == 0 + assert seeded.read_text(encoding="utf-8") == SEED + assert not seeded.with_name("groups.toml.bak").exists() + assert "Dry run: nothing written." in capsys.readouterr().out + + +def test_dry_run_on_missing_file_creates_nothing(config: Path) -> None: + assert my_groups.main(["user", "add", "Ada", "--dry-run"]) == 0 + assert not config.exists() + assert not config.parent.joinpath("groups.toml.bak").exists() + + +@pytest.mark.parametrize("argv", WRITE_COMMANDS, ids=lambda argv: " ".join(argv[:2])) +def test_yes_skips_the_prompt( + argv: list[str], seeded: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def _fail_prompt() -> str: + raise AssertionError("--yes must not prompt") + + monkeypatch.setattr("builtins.input", _fail_prompt) + assert my_groups.main([*argv, "--yes"]) == 0 + assert seeded.read_text(encoding="utf-8") != SEED + + +def test_write_without_tty_or_yes_fails( + seeded: Path, capsys: pytest.CaptureFixture +) -> None: + assert my_groups.main(["user", "add", "Alan Turing"]) == 1 + assert "--yes" in capsys.readouterr().err + assert seeded.read_text(encoding="utf-8") == SEED + + +@pytest.mark.parametrize( + ("answer", "written"), [("y", True), ("n", False), ("", False)] +) +def test_interactive_prompt_goes_to_stderr( + answer: str, + written: bool, + seeded: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, +) -> None: + monkeypatch.setattr(sys, "stdin", _FakeStdin(tty=True)) + monkeypatch.setattr("builtins.input", lambda: answer) + my_groups.main(["user", "add", "Alan Turing"]) + assert "Write these changes" in capsys.readouterr().err + assert (seeded.read_text(encoding="utf-8") != SEED) is written + + +def test_write_is_atomic_and_keeps_a_backup(seeded: Path) -> None: + assert my_groups.main(["user", "add", "Alan Turing", "--yes"]) == 0 + assert seeded.with_name("groups.toml.bak").read_text(encoding="utf-8") == SEED + leftovers = [path.name for path in seeded.parent.iterdir() if path.suffix == ".tmp"] + assert leftovers == [] + + +def test_write_through_a_symlink_keeps_the_link(config: Path) -> None: + target = config.parent / "dotfiles" / "groups.toml" + target.parent.mkdir() + target.write_text(SEED, encoding="utf-8") + config.symlink_to(target) + assert my_groups.main(["user", "add", "Alan Turing", "--yes"]) == 0 + assert config.is_symlink() + assert "alan-turing" in _load(target)["users"] + assert target.with_name("groups.toml.bak").read_text(encoding="utf-8") == SEED + + +def test_file_changed_during_prompt_is_not_overwritten( + seeded: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + other_edit = SEED + '\n[users.ada]\nname = "Ada"\n' + + def _edit_then_confirm() -> str: + seeded.write_text(other_edit, encoding="utf-8") + return "y" + + monkeypatch.setattr(sys, "stdin", _FakeStdin(tty=True)) + monkeypatch.setattr("builtins.input", _edit_then_confirm) + assert my_groups.main(["user", "add", "Alan Turing"]) == 1 + assert "changed after this plan was made" in capsys.readouterr().err + assert seeded.read_text(encoding="utf-8") == other_edit + + +def test_init_does_not_replace_a_file_created_during_prompt( + config: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def _create_then_confirm() -> str: + config.write_text(SEED, encoding="utf-8") + return "y" + + monkeypatch.setattr(sys, "stdin", _FakeStdin(tty=True)) + monkeypatch.setattr("builtins.input", _create_then_confirm) + assert my_groups.main(["init"]) == 1 + assert config.read_text(encoding="utf-8") == SEED + + +def _assert_lock_refuses_second_writer( + path: Path, capsys: pytest.CaptureFixture +) -> None: + with my_groups._write_lock(path): # pylint: disable=protected-access + assert my_groups.main(["user", "add", "Alan Turing", "--yes"]) == 1 + assert "in progress" in capsys.readouterr().err + assert path.read_text(encoding="utf-8") == SEED + assert my_groups.main(["user", "add", "Alan Turing", "--yes"]) == 0 + + +def test_second_writer_is_refused_while_the_lock_is_held( + seeded: Path, capsys: pytest.CaptureFixture +) -> None: + _assert_lock_refuses_second_writer(seeded, capsys) + + +class _FakeMsvcrt: + """Stands in for Windows ``msvcrt``: one lock on byte 0, refused while held.""" + + LK_NBLCK = 2 + LK_UNLCK = 0 + + def __init__(self) -> None: + self.held = False + + def locking(self, fd: int, mode: int, nbytes: int) -> None: + assert os.lseek(fd, 0, os.SEEK_CUR) == 0 + assert nbytes == 1 + if mode == self.LK_UNLCK: + self.held = False + elif self.held: + raise OSError("locked") + else: + self.held = True + + +def test_windows_lock_refuses_second_writer( + seeded: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + fake = _FakeMsvcrt() + monkeypatch.setattr(my_groups, "fcntl", None) + monkeypatch.setattr(my_groups, "msvcrt", fake) + _assert_lock_refuses_second_writer(seeded, capsys) + assert not fake.held + + +def test_writes_are_refused_without_file_locking( + seeded: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(my_groups, "fcntl", None) + monkeypatch.setattr(my_groups, "msvcrt", None) + assert my_groups.main(["user", "add", "Alan Turing", "--yes"]) == 1 + assert "no file locking" in capsys.readouterr().err + assert seeded.read_text(encoding="utf-8") == SEED + assert my_groups.main(["user", "add", "Alan Turing", "--dry-run"]) == 0 + + +def test_change_that_creates_an_ambiguous_name_is_refused( + seeded: Path, capsys: pytest.CaptureFixture +) -> None: + argv = ["group", "add", "reviewers", "--alias", "Alice", "--member", "Alice"] + assert my_groups.main([*argv, "--yes"]) == 1 + err = capsys.readouterr().err + assert "would leave the file inconsistent" in err + assert "'alice' refers to more than one entry" in err + assert seeded.read_text(encoding="utf-8") == SEED + + +def test_existing_errors_do_not_block_unrelated_changes(config: Path) -> None: + config.write_text( + '[users.pat]\nname = "Pat"\naliases = ["pod"]\n\n' + '[groups.team]\naliases = ["pod"]\nmembers = ["pat"]\n', + encoding="utf-8", + ) + assert my_groups.main(["user", "add", "Ada", "--yes"]) == 0 + assert "ada" in _load(config)["users"] + + +# === Editing users === + + +def test_first_add_creates_file_and_directory( + tmp_path: Path, config: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + nested = tmp_path / "deep" / "groups.toml" + monkeypatch.setenv(my_groups.CONFIG_ENV_VAR, str(nested)) + assert my_groups.main(["user", "add", "Ada", "--yes"]) == 0 + assert _load(nested) == {"users": {"ada": {"name": "Ada"}}} + assert not config.exists() + + +@pytest.mark.parametrize( + "argv", + [ + ["user", "add", "Someone", "--alias", "ADA"], + ["user", "add", "Someone", "--email", "Ada@example.com"], + ["user", "add", "Ada Lovelace", "--key", "other"], + ["user", "add", "Someone", "--key", "my-team"], + ["user", "add", "Someone", "--handle", "slack=ada"], + ], +) +def test_user_add_rejects_taken_names( + argv: list[str], seeded: Path, capsys: pytest.CaptureFixture +) -> None: + assert my_groups.main([*argv, "--yes"]) == 1 + assert "already" in capsys.readouterr().err + assert seeded.read_text(encoding="utf-8") == SEED + + +@pytest.mark.parametrize( + ("argv", "message"), + [ + (["user", "add", "Ada", "--email", "ada@"], "is not an email address"), + (["user", "update", "ada", "--email", "a..b@example.com"], "is not an email"), + (["user", "add", "Ada", "--key", "user:ada"], "can't start with"), + (["user", "add", "Ada", "--key", "!!"], "at least one ASCII"), + (["group", "add", "x", "--key", "group:x", "--member", "ada"], "can't start"), + ], +) +def test_writes_reject_bad_emails_and_keys( + argv: list[str], message: str, seeded: Path, capsys: pytest.CaptureFixture +) -> None: + assert my_groups.main([*argv, "--yes"]) == 1 + assert message in capsys.readouterr().err + assert seeded.read_text(encoding="utf-8") == SEED + + +def test_emails_are_stored_and_printed_as_addr_specs( + seeded: Path, capsys: pytest.CaptureFixture +) -> None: + argv = ["user", "add", "Zoe", "--email", " Zoe@Example.com (work) ", "--yes"] + assert my_groups.main(argv) == 0 + argv = ["group", "add-member", "my team", "New@Example.org (new)", "--yes"] + assert my_groups.main(argv) == 0 + data = _load(seeded) + assert data["users"]["zoe"]["email"] == "Zoe@Example.com" + assert data["groups"]["my-team"]["members"][-1] == "New@Example.org" + capsys.readouterr() + assert my_groups.main(["resolve", "my team", "--email"]) == 0 + assert capsys.readouterr().out.splitlines()[-1] == "New@Example.org" + + +def test_user_add_with_handles(config: Path) -> None: + argv = ["user", "add", "Katherine Johnson", "--key", "katherine"] + handles = ["--handle", "GitHub=katherine_example", "--handle", "github=octocat"] + assert ( + my_groups.main([*argv, *handles, "--handle", "webex=m@example.com", "--yes"]) + == 0 + ) + assert _load(config)["users"]["katherine"] == { + "name": "Katherine Johnson", + "handles": { + "github": ["katherine_example", "octocat"], + "webex": ["m@example.com"], + }, + } + assert _keys(_resolve("octocat")) == ["katherine"] + + +@pytest.mark.parametrize("bad", ["github", "=octocat", "github="]) +def test_handle_flag_needs_service_and_user(config: Path, bad: str) -> None: + with pytest.raises(SystemExit): + my_groups.main(["user", "add", "Ada", "--handle", bad]) + assert not config.exists() + + +def test_user_update_sets_and_clears_fields(seeded: Path) -> None: + argv = ["user", "update", "ada", "--add-handle", "github=ada_example"] + assert my_groups.main([*argv, "--email", "", "--add-alias", "S", "--yes"]) == 0 + assert _load(seeded)["users"]["ada"] == { + "name": "Ada Lovelace", + "aliases": ["Ada", "S"], + "handles": {"github": ["ada_example"]}, + } + + +def test_user_update_removes_one_handle_or_a_whole_service( + with_handles: Path, +) -> None: + argv = ["user", "update", "ada", "--remove-handle", "github=ADA_OSS", "--yes"] + assert my_groups.main(argv) == 0 + assert _load(with_handles)["users"]["ada"]["handles"] == { + "github": ["ada_example"], + "webex": "ada@example.com", + } + argv = ["user", "update", "ada", "--remove-handle", "github"] + assert my_groups.main([*argv, "--remove-handle", "webex", "--yes"]) == 0 + assert "handles" not in _load(with_handles)["users"]["ada"] + + +def test_single_string_handle_is_upgraded_on_edit(with_handles: Path) -> None: + argv = ["user", "update", "ada", "--add-handle", "webex=ada@example.net", "--yes"] + assert my_groups.main(argv) == 0 + assert _load(with_handles)["users"]["ada"]["handles"]["webex"] == [ + "ada@example.com", + "ada@example.net", + ] + + +def test_handles_differing_only_in_punctuation_are_separate_accounts( + with_handles: Path, +) -> None: + argv = ["user", "update", "ada", "--add-handle", "github=ada-example", "--yes"] + assert my_groups.main(argv) == 0 + argv = ["user", "update", "ada", "--remove-handle", "github=ADA_example", "--yes"] + assert my_groups.main(argv) == 0 + assert _load(with_handles)["users"]["ada"]["handles"]["github"] == [ + "ada_oss", + "ada-example", + ] + + +def test_edits_merge_service_names_that_differ_only_in_case(config: Path) -> None: + config.write_text( + '[users.ada]\nname = "Ada"\n\n[users.ada.handles]\n' + 'github = ["a"]\nGitHub = ["b", "A"]\n', + encoding="utf-8", + ) + argv = ["user", "update", "ada", "--add-handle", "GITHUB=c", "--yes"] + assert my_groups.main(argv) == 0 + assert _load(config)["users"]["ada"]["handles"] == {"github": ["a", "b", "c"]} + config.write_text( + '[users.ada]\nname = "Ada"\n\n[users.ada.handles]\n' + 'github = ["a"]\nGitHub = "b"\nslack = "s"\n', + encoding="utf-8", + ) + argv = ["user", "update", "ada", "--remove-handle", "github", "--yes"] + assert my_groups.main(argv) == 0 + assert _load(config)["users"]["ada"]["handles"] == {"slack": "s"} + + +def test_email_aliases_keep_their_punctuation(config: Path) -> None: + config.write_text( + '[users.alice]\nname = "Alice"\naliases = ["alice-smith@example.com"]\n', + encoding="utf-8", + ) + argv = ["user", "update", "alice", "--yes"] + assert my_groups.main([*argv, "--add-alias", "alice_smith@example.com"]) == 0 + assert my_groups.main([*argv, "--remove-alias", "ALICE-smith@example.com"]) == 0 + assert _load(config)["users"]["alice"]["aliases"] == ["alice_smith@example.com"] + + +def test_new_handles_table_lands_under_the_user(with_handles: Path) -> None: + argv = ["user", "update", "grace", "--add-handle", "github=grace_example", "--yes"] + assert my_groups.main(argv) == 0 + text = with_handles.read_text(encoding="utf-8") + assert ( + '[users.grace]\nname = "Grace Hopper"\n\n[users.grace.handles]\n' + 'github = ["grace_example"]\n' + ) in text + assert _load(with_handles)["groups"]["pod"]["members"][1] == "grace" + + +def test_handle_cannot_belong_to_two_users( + seeded: Path, capsys: pytest.CaptureFixture +) -> None: + argv = ["user", "update", "ada", "--add-handle", "github=g_shared", "--yes"] + assert my_groups.main(argv) == 0 + argv = ["user", "update", "grace", "--add-handle", "slack=g_shared", "--yes"] + assert my_groups.main(argv) == 1 + assert "already refers to user:ada" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("member", "update"), + [ + ("ada@example.com", ["--email", "ada.l@example.com"]), + ("ada@example.com", ["--email", ""]), + ("Ada Lovelace", ["--name", "Ada B Lovelace"]), + ("Ada", ["--remove-alias", "Ada"]), + ("ada_example", ["--remove-handle", "github=ada_example"]), + ("ada_example", ["--remove-handle", "github"]), + ], +) +def test_changing_a_lookup_name_keeps_group_members_attached( + config: Path, member: str, update: list[str] +) -> None: + config.write_text( + '[users.ada]\nname = "Ada Lovelace"\naliases = ["Ada"]\n' + 'email = "ada@example.com"\n\n[users.ada.handles]\n' + 'github = ["ada_example"]\n\n' + f'[groups.pod]\nmembers = ["{member}"]\n', + encoding="utf-8", + ) + assert my_groups.main(["user", "update", "ada", *update, "--yes"]) == 0 + assert _load(config)["groups"]["pod"]["members"] == ["ada"] + assert _keys(_resolve("pod")) == ["ada"] + + +def test_user_remove_cascades_into_groups(seeded: Path) -> None: + assert my_groups.main(["user", "remove", "Ada", "--yes"]) == 0 + data = _load(seeded) + assert "ada" not in data["users"] + assert data["groups"]["platform-team"]["members"] == [ + "grace", + "alan@example.com", + "Mystery Person", + ] + assert data["groups"]["my-team"]["members"] == ["jdoe@example.com"] + + +# === Editing groups === + + +def test_add_member_creates_stub_with_hint( + seeded: Path, capsys: pytest.CaptureFixture +) -> None: + assert my_groups.main(["group", "add-member", "my team", "Adaa", "--yes"]) == 0 + assert "Did you mean: user:ada?" in capsys.readouterr().out + data = _load(seeded) + assert data["users"]["adaa"] == {"name": "Adaa"} + assert data["groups"]["my-team"]["members"] == ["ada", "jdoe@example.com", "adaa"] + + +def test_add_member_skips_existing_member_by_any_name( + seeded: Path, capsys: pytest.CaptureFixture +) -> None: + assert my_groups.main(["group", "add-member", "my pod", "ada@example.com"]) == 0 + assert "No changes." in capsys.readouterr().out + assert seeded.read_text(encoding="utf-8") == SEED + + +def test_add_member_resolves_a_prefixed_email_or_refuses_it( + seeded: Path, capsys: pytest.CaptureFixture +) -> None: + argv = ["group", "add-member", "my team", "user:GRACE@example.com", "--yes"] + assert my_groups.main(argv) == 0 + assert _load(seeded)["groups"]["my-team"]["members"][-1] == "grace" + argv = ["group", "add-member", "my team", "user:new@example.com", "--yes"] + assert my_groups.main(argv) == 1 + assert "pass the bare address" in capsys.readouterr().err + assert "new@example.com" not in seeded.read_text(encoding="utf-8") + + +def test_hand_edited_prefixed_email_resolves_bare_and_warns(config: Path) -> None: + config.write_text( + '[groups.a]\nmembers = ["user:new@example.com"]\n', encoding="utf-8" + ) + assert _keys(_resolve("a")) == ["new@example.com"] + _, warnings = my_groups.validate(my_groups.load_document(config)) + assert warnings == [ + "Group 'a': no user has the email in 'user:new@example.com'; store the bare address." + ] + + +@pytest.mark.parametrize("member", ["my pod", "group:my pod"]) +def test_add_member_rejects_a_group_name(seeded: Path, member: str) -> None: + assert my_groups.main(["group", "add-member", "my team", member, "--yes"]) == 1 + assert seeded.read_text(encoding="utf-8") == SEED + + +def test_group_add_requires_members(seeded: Path) -> None: + assert my_groups.main(["group", "add", "empty", "--yes"]) == 1 + assert seeded.read_text(encoding="utf-8") == SEED + + +def test_group_add_keeps_display_name_as_alias(seeded: Path) -> None: + argv = ["group", "add", "R&D Leads", "--member", "grace", "--include", "my team"] + assert my_groups.main([*argv, "--yes"]) == 0 + assert _load(seeded)["groups"]["r-d-leads"] == { + "aliases": ["R&D Leads"], + "members": ["grace"], + "groups": ["my-team"], + } + + +def test_group_update_rejects_a_cycle(seeded: Path) -> None: + assert ( + my_groups.main(["group", "update", "my pod", "--include", "my team", "--yes"]) + == 0 + ) + assert ( + my_groups.main(["group", "update", "my team", "--include", "my pod", "--yes"]) + == 1 + ) + assert "groups" not in _load(seeded)["groups"]["my-team"] + + +def test_removing_a_group_alias_keeps_nested_references_attached( + seeded: Path, +) -> None: + argv = ["group", "update", "my team", "--include", "platform-team", "--yes"] + assert my_groups.main(argv) == 0 + text = seeded.read_text(encoding="utf-8") + seeded.write_text( + text.replace('groups = ["platform-team"]', 'groups = ["my pod"]'), + encoding="utf-8", + ) + argv = ["group", "update", "platform-team", "--remove-alias", "my pod", "--yes"] + assert my_groups.main(argv) == 0 + data = _load(seeded) + assert data["groups"]["my-team"]["groups"] == ["platform-team"] + assert "aliases" not in data["groups"]["platform-team"] + assert my_groups.validate(my_groups.load_document(seeded))[0] == [] + + +def test_group_remove_drops_nested_references(seeded: Path) -> None: + assert ( + my_groups.main(["group", "update", "my pod", "--include", "my team", "--yes"]) + == 0 + ) + assert my_groups.main(["group", "remove", "my team", "--yes"]) == 0 + data = _load(seeded) + assert set(data["groups"]) == {"platform-team"} + assert "groups" not in data["groups"]["platform-team"] + assert "ada" in data["users"] + + +# === File format === + + +def test_edit_preserves_comments_and_order(seeded: Path) -> None: + argv = ["user", "update", "grace", "--add-alias", "Gracie", "--yes"] + assert my_groups.main(argv) == 0 + text = seeded.read_text(encoding="utf-8") + assert text.startswith("# My groups -- keep this comment\n") + assert '[users.ada] # inline note\nname = "Ada Lovelace"' in text + headers = [line for line in text.splitlines() if line.startswith("[")] + assert [header.split(" #")[0] for header in headers] == [ + "[users.ada]", + "[users.grace]", + "[groups.platform-team]", + "[groups.my-team]", + ] + assert tomllib.loads(text)["users"]["grace"]["aliases"] == ["Gracie"] + + +def test_special_characters_round_trip(config: Path) -> None: + name = 'Zoë "Z" O\'Brien \\ Jr.' + argv = ["user", "add", name, "--key", "zoe.obrien", "--alias", "Zoë"] + assert my_groups.main([*argv, "--yes"]) == 0 + assert _load(config)["users"]["zoe.obrien"] == {"name": name, "aliases": ["Zoë"]} + (member,) = _resolve("zoë")["members"] + assert member["name"] == name + + +def test_blank_line_cleanup_never_changes_a_multiline_value(config: Path) -> None: + name = "Ada\n\n\n[team]" + config.write_text( + f'[users.ada]\nname = """{name}"""\n\n\n\n[users.grace]\nname = "Grace"\n', + encoding="utf-8", + ) + argv = ["user", "update", "grace", "--add-alias", "Gracie", "--yes"] + assert my_groups.main(argv) == 0 + assert _load(config)["users"]["ada"]["name"] == name + + +def test_init_writes_a_valid_example_and_never_overwrites( + config: Path, capsys: pytest.CaptureFixture +) -> None: + assert my_groups.main(["init", "--yes"]) == 0 + errors, _ = my_groups.validate(my_groups.load_document(config)) + assert errors == [] + assert my_groups.main(["init", "--yes"]) == 1 + assert "never overwrites" in capsys.readouterr().err diff --git a/skills/ess/ess-pr-review/LICENSE b/skills/ess/ess-pr-review/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/skills/ess/ess-pr-review/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/ess/pr-review/SKILL.md b/skills/ess/ess-pr-review/SKILL.md similarity index 71% rename from skills/ess/pr-review/SKILL.md rename to skills/ess/ess-pr-review/SKILL.md index f5f7987..e7a4cf5 100644 --- a/skills/ess/pr-review/SKILL.md +++ b/skills/ess/ess-pr-review/SKILL.md @@ -1,13 +1,15 @@ --- -name: pr-review +name: ess-pr-review description: >- AI-assisted review of one or more GitHub Pull Requests. Offloads deterministic checks (lint, complexity, duplication, security, perf) to repo linters via self-contained scripts, then applies LLM judgment to what linters cannot catch and posts high-signal findings after a single human approval. Pass one or many PRs (numbers or URLs); with none, it discovers PRs where your review is - requested. Use for /pr-review, "review PR <number/URL>", "review my requested - PRs", "code review these pull requests". Requires gh and a git checkout. + requested, optionally narrowed to given authors or a GitHub org team. Use for + /ess-pr-review, "review PR <number/URL>", "review my requested PRs", "code review + these pull requests", "review PRs from <person>", "review my teammate's PRs", + "review the PRs from <team>". Requires gh and a git checkout. metadata: version: "2.0" --- @@ -29,21 +31,25 @@ gate. ## Quick start ``` -/pr-review https://github.com/<owner>/<repo>/pull/456 # one PR -/pr-review 456 # one PR, bare number -/pr-review 456 461 470 # several PRs -/pr-review # discover: PRs awaiting my review +/ess-pr-review https://github.com/<owner>/<repo>/pull/456 # one PR +/ess-pr-review 456 # one PR, bare number +/ess-pr-review 456 461 470 # several PRs +/ess-pr-review # discover: PRs awaiting my review +/ess-pr-review from alice # ...only hers +/ess-pr-review from @acme/platform-team # ...only that team's ``` --- ## Prerequisites -- **GitHub CLI** (`gh`) — authenticated, with access to the repo. Primary integration. +- **GitHub CLI** (`gh`) — authenticated, with access to the repo. Primary + integration. Filtering discovery by a GitHub org team additionally needs the + `read:org` scope (`gh auth status` lists your scopes). - The current directory must be a git repo (`.git/` present). - Linters are optional: the scan runs whatever is installed (`ruff`, `pylint`, - `bandit`, `eslint`) and records the rest as "not run". In this repo Python - linters run via `uv`. + `bandit`, `eslint`) and records the rest as "not run". Python linters run via + `uv run` when the repo is a uv project, and as bare executables otherwise. - TypeScript/eslint needs the workspace deps hoisted to the repo-root `node_modules` — run `npm install` once at the repo root. Until then the scan records eslint as "not run" with that hint and continues. @@ -51,6 +57,12 @@ gate. isolated worktrees. GitHub MCP is an optional alternative for posting (see [references/github-cli.md](references/github-cli.md)); `gh` is the default. +## Scripts + +Every step runs the bundled scripts from `SKILL_DIR`: the absolute path of the directory +that contains this `SKILL.md`. Resolve it once, before Step 1, and reuse it. Do not use a +repo-relative path; the skill may be installed outside the repo you are reviewing. + --- ## Workflow @@ -66,14 +78,45 @@ Build the list of PR numbers to review, then let N = its length. - **No refs**: discover PRs awaiting your review: ```bash - scripts/find_review_requests.sh # user-requested PRs (default) - scripts/find_review_requests.sh --include-team # also team-requested + "$SKILL_DIR/scripts/find-review-requests.sh" # user-requested PRs (default) + "$SKILL_DIR/scripts/find-review-requests.sh" --include-team # also team-requested ``` It prints matching numbers to stdout and a human table to stderr. Show the table and **confirm the set with the user** before reviewing. If empty, say so and stop. +- **Narrowing to certain people**: when the user names whose PRs they want, pass + `--author` — repeatable, and a PR is kept if it matches **any** term: + + ```bash + "$SKILL_DIR/scripts/find-review-requests.sh" --author alice --author 'Bob Brown' + "$SKILL_DIR/scripts/find-review-requests.sh" --author @acme/platform-team + ``` + + | Term | Matches | + | --- | --- | + | `@me` | you | + | `@org/team-slug` | every member of that GitHub org team | + | `@team-slug` | same, resolved in this repo's own org | + | `alice` | a GitHub login, whole-field | + | `bob@corp.example.com` | a PR or commit author email, whole-field | + | `Bob Brown` | a display name, substring — so `Bob` also matches | + + `@me` and team terms are answered entirely by the one query discovery already + makes, so they cost nothing extra. Every other term is also matched against + each PR's **commit** authors, which that query cannot carry — so a login, + email, or display-name term costs one extra lookup per PR it has not already + matched. That is deliberate: a commit author's name or email often differs from + the PR author's GitHub profile, and skipping the lookup silently drops those + PRs. To keep the cost down, prefer `@me` or a team term, or a term that matches + the PR author directly. + + `--author` only ever **narrows** the review-requested set; it cannot surface a + PR that was never assigned to you. It applies to discovery only — when the user + passes explicit PR numbers or URLs, review exactly those and ignore authorship. + Listing org team members needs the `read:org` scope on the gh token. + If N = 1, continue with the fast path below. If N > 1, follow [references/batch-mechanics.md](references/batch-mechanics.md) to scan each PR in its own worktree (via a read-only subagent per PR), then converge on the same @@ -122,9 +165,9 @@ For N > 1, each PR is already isolated in its own detached read-only worktree ### 4. Run the deterministic scan (per PR) ```bash -scripts/scan-pr.sh <N> # a PR ref (URL or number) -scripts/scan-pr.sh --base origin/main --head HEAD # or an explicit range -scripts/scan-pr.sh --base origin/main --head <SHA> --number <N> --repo <o>/<r> +"$SKILL_DIR/scripts/scan-pr.sh" <N> # a PR ref (URL or number) +"$SKILL_DIR/scripts/scan-pr.sh" --base origin/main --head HEAD # or an explicit range +"$SKILL_DIR/scripts/scan-pr.sh" --base origin/main --head <SHA> --number <N> --repo <o>/<r> ``` Given a PR ref, `scan-pr.sh` fetches and pins the PR's head SHA (printed in its @@ -170,8 +213,8 @@ linters cannot decide: - **Version-scoped deprecations** — check `pyproject.toml` / `.nvmrc` first; do not flag a deprecation the project's minimum version is unaffected by. -For LangChain/LangGraph files (`*/tools.py`, `*_skill.py`, `*/prompts/*.py`), -apply the framework checklist in +For LangChain/LangGraph files (tool, prompt, and agent-definition modules — e.g. +`*/tools.py`, `*/prompts/*.py`), apply the framework checklist in [references/agent-conventions.md](references/agent-conventions.md). ### 6. High-signal filtering (required, per PR) @@ -228,6 +271,10 @@ N = 1 creates no worktree, so there is nothing to clean up. - DRY/duplication is detected across **changed files only** — the scan will not compare a changed file against untouched files. - Same-origin PRs only (fork PRs are out of scope). +- `--author` filters the queue *after* it is fetched, so a queue truncated by + `--limit` can hide matching PRs. The script warns when that happens, and exits + non-zero when a commit-author lookup failed, since the kept set may then be + missing a PR. - Scripts depend only on `git`, `gh`, `openssl`/`jq` (N > 1), and the repo's auto-discovered linter config; they run standalone when the skill is copied into another repo. diff --git a/skills/ess/ess-pr-review/references/agent-conventions.md b/skills/ess/ess-pr-review/references/agent-conventions.md new file mode 100644 index 0000000..8357a44 --- /dev/null +++ b/skills/ess/ess-pr-review/references/agent-conventions.md @@ -0,0 +1,52 @@ +# LangChain / LangGraph conventions (framework-specific) + +Apply this only when the PR touches LangChain/LangGraph agent code. These are +conventions the linters do not enforce, so they belong to the LLM judgment pass. + +## `@tool` checklist + +For each changed tool definition (commonly `*/tools.py`): + +- [ ] `@tool` decorator used correctly. +- [ ] The project's error-handling decorator or wrapper is applied, so a raised + exception reaches the model as a message instead of ending the run. +- [ ] `RunnableConfig` parameter included where the tool needs run context. +- [ ] Any cache-control parameter (e.g. a refresh flag) is honored by tools that + read cached data. +- [ ] Docstring says *when to select* the tool, not just what it does — that is + what the model routes on. +- [ ] Docstring within the length limit the project enforces (model providers cap + tool description length). +- [ ] Any citation or source constant the project requires is defined. +- [ ] Error messages are user-friendly. + +Check each item against the repo's own conventions before flagging it: decorator +and parameter names differ between projects. + +## Quoting the repo's AGENTS.md + +Agent repos usually encode these conventions as AGENTS.md rules. When the diff +violates one, quote that repo's exact rule text in the finding instead of +paraphrasing it. Rule areas worth looking for: + +| Rule area | What it typically constrains | +| --- | --- | +| Parameter count | A cap per function; group related params into dataclasses/config objects | +| Tool docstring format | Required sections, including routing guidance for tool selection | +| Tool docstring length | A maximum character count for tool descriptions | +| Type hints | A minimum language version, and type hints on all functions | +| DRY | Consolidating duplicated logic into reusable functions | +| Early returns | Guard clauses instead of deep nesting | +| Nested ternaries | Disallowed; use if/else or match/case | +| Descriptive names | Full words over single letters (`customer_data`, not `d`) | +| Dependency workflow | Use the project's package manager; don't hand-edit the lockfile | + +Note: several of these (parameter count, nesting, complexity, naming) are also +enforced mechanically by ruff and will appear in `report.md` — quote the AGENTS.md +rule only when adding context the linter finding lacks, and don't double-post. + +## Scope a rule before flagging + +- Root `AGENTS.md` applies to all files. +- A directory-specific `AGENTS.md` applies only to files in that directory (or + its children). Don't apply an unrelated directory's rules to a file outside it. diff --git a/skills/ess/pr-review/references/batch-mechanics.md b/skills/ess/ess-pr-review/references/batch-mechanics.md similarity index 77% rename from skills/ess/pr-review/references/batch-mechanics.md rename to skills/ess/ess-pr-review/references/batch-mechanics.md index 9af4328..dbf704e 100644 --- a/skills/ess/pr-review/references/batch-mechanics.md +++ b/skills/ess/ess-pr-review/references/batch-mechanics.md @@ -1,6 +1,6 @@ # Batch mechanics (N > 1) -How `pr-review` reviews **multiple** PRs at once. Read this only when the set has +How `ess-pr-review` reviews **multiple** PRs at once. Read this only when the set has more than one PR — a single-PR review (N = 1) uses the in-place fast path in `SKILL.md` and never touches any of this. @@ -17,12 +17,12 @@ is posted. - **Read-only**: review posts comments, it never edits code. So worktrees are created **detached at the PR head SHA** (`git worktree add --detach`), and the subagents are told to edit nothing. This is the key contrast with - `pr-address-comments-all`, whose worktrees use `-B <branch>` because it writes + `ess-pr-address-comments`, whose worktrees use `-B <branch>` because it writes fixes back to the PR. ## Per-PR worktree layout -[`scripts/create_review_worktree.sh`](../scripts/create_review_worktree.sh) +[`scripts/create-review-worktree.sh`](../scripts/create-review-worktree.sh) creates each worktree and provisions it (runs `.cursor/worktrees.json` `setup-worktree`, or a best-effort `.env` + `uv sync` default): @@ -34,10 +34,11 @@ creates each worktree and provisions it (runs `.cursor/worktrees.json` - `REPO_KEY = <repo-basename>-<sha256(REPO_ROOT)[:12]>` — matches the `/worktree` command, so `/delete-worktree` recognizes it. -Run it once per PR from the repo root: +Run it once per PR from the repo root. `SKILL_DIR` is the absolute path of this +skill (see below): ```bash -scripts/create_review_worktree.sh --pr <N> --repo <owner>/<repo> +"$SKILL_DIR/scripts/create-review-worktree.sh" --pr <N> --repo <owner>/<repo> # prints WORKTREE_ID=..., WORKTREE_PATH=..., HEAD_SHA=... ``` @@ -50,32 +51,32 @@ Launch one subagent per PR. Use an `explore` subagent with `readonly: true` and `run_in_background: true` so they run in parallel. Each must post nothing and edit nothing — it only returns proposed findings for the GATE. -First resolve `<SKILL_DIR_ABS>` = the absolute path to the directory holding the -`pr-review` skill you are running (the one that contains this `SKILL.md`, -`scripts/`, and `references/`). Pass it into each subagent so the scan script and -reference docs are reachable **by absolute path** — the worktree is a detached -checkout of the PR head and may not contain the skill at all (it is only on your -branch), or may contain a stale copy. +First resolve `SKILL_DIR` = the absolute path to the directory holding the +`ess-pr-review` skill you are running (the one that contains its `SKILL.md`, +`scripts/`, and `references/`). Fill it in as `<SKILL_DIR>` in each subagent +prompt so the scan script and reference docs are reachable **by absolute path** +— the worktree is a detached checkout of the PR head and may not contain the +skill at all, or may contain a stale copy. -Fill in `<...>` from `create_review_worktree.sh` output and the PR metadata: +Fill in `<...>` from `create-review-worktree.sh` output and the PR metadata: ``` Repo worktree (READ-ONLY, detached at the PR head): <WORKTREE_PATH> -PR #<N> in <owner>/<repo>. pr-review skill dir: <SKILL_DIR_ABS> +PR #<N> in <owner>/<repo>. ess-pr-review skill dir: <SKILL_DIR> cd into that worktree. Do NOT edit, commit, push, or post anything. 1. Run the deterministic scan by ABSOLUTE path (the worktree does not contain the skill), passing --number so this PR gets its own output dir: - bash "<SKILL_DIR_ABS>/scripts/scan-pr.sh" \ + bash "<SKILL_DIR>/scripts/scan-pr.sh" \ --base origin/<baseRef> --head <HEAD_SHA> \ --number <N> --repo <owner>/<repo> Read the generated report.md (its path is printed on the last line; it is /tmp/pr-review-<owner>-<repo>-<N>). 2. Do the LLM judgment pass and high-signal filtering exactly as - <SKILL_DIR_ABS>/SKILL.md Steps 5–6 describe (correctness vs intent, + <SKILL_DIR>/SKILL.md Steps 5–6 describe (correctness vs intent, cross-file logic, resource cleanup, suppression justifications, AGENTS.md - rules, version-scoped deprecations; <SKILL_DIR_ABS>/references/agent-conventions.md + rules, version-scoped deprecations; <SKILL_DIR>/references/agent-conventions.md for LangChain files). 3. Dedup against existing reviews/comments on the PR. @@ -85,9 +86,9 @@ Also note: files scanned, any linters that did not run, and whether the PR should be skipped (draft / already reviewed / trivial) with the reason. ``` -The scan script and reference docs come from `<SKILL_DIR_ABS>` (your checkout), -not the worktree — do not assume a workspace-relative -`skills/ess/pr-review/...` path resolves inside the worktree. The worktree only +The scan script and reference docs come from `<SKILL_DIR>`, not the worktree — +do not assume a workspace-relative path to the skill resolves inside the +worktree. The worktree only supplies the code under review: it is the scan's working directory, and because worktrees share the repo object store, `origin/<baseRef>` is already available. Reports are written under `/tmp`, so the worktree stays read-only, and the diff --git a/skills/ess/pr-review/references/deterministic-checks.md b/skills/ess/ess-pr-review/references/deterministic-checks.md similarity index 82% rename from skills/ess/pr-review/references/deterministic-checks.md rename to skills/ess/ess-pr-review/references/deterministic-checks.md index d7fb956..f7e9f7d 100644 --- a/skills/ess/pr-review/references/deterministic-checks.md +++ b/skills/ess/ess-pr-review/references/deterministic-checks.md @@ -20,9 +20,9 @@ then spend your judgment on the rows in the **LLM-only** table below. | Performance | ruff `PERF`, pylint `perflint` W8xxx | N+1, list/dict literals, loops | | Security | `bandit -ll` | SQL injection `B608`, subprocess, etc. (medium+) | | Secrets | TruffleHog (pre-commit/CI) | Not re-run here; note if relevant | -| Duplication (Python) | pylint `R0801` (re-enabled) | Root config disables it; the script turns it back on | +| Duplication (Python) | pylint `R0801` (re-enabled) | Projects often disable it in their pylint config; the script turns it back on | | Duplication / cognitive complexity (TS) | eslint `eslint-plugin-sonarjs` | `no-identical-functions`, `no-duplicate-string` | -| Suppressions added | `scripts/scan_disables.sh` | `pylint: disable`, `noqa`, `type: ignore`, `@ts-ignore` | +| Suppressions added | `scripts/scan-disables.sh` | `pylint: disable`, `noqa`, `type: ignore`, `@ts-ignore` | Do **not** hand-flag anything in this table — if it's real, it's already in `report.md`. Re-flagging it just adds noise. @@ -42,12 +42,14 @@ Do **not** hand-flag anything in this table — if it's real, it's already in ## Review focus by file type +Adapt these patterns to the repo you are reviewing; the categories matter more +than the exact filenames. + | File pattern | Extra scrutiny | | --- | --- | -| `*/tools.py` | `@tool`, `@handle_tool_error`, ROUTING docstring, citation (see `agent-conventions.md`) | -| `*/queries.py` | SQL injection, parameterized queries, N+1 | -| `*_skill.py` | `routing_examples`, skill model pattern | -| `*/prompts/*.py` | Prompt-injection risk, instructions matching code | +| Tool definitions (e.g. `*/tools.py`) | Decorator usage, error handling, docstring contract (see `agent-conventions.md`) | +| Data access (e.g. `*/queries.py`) | SQL injection, parameterized queries, N+1 | +| Prompt modules (e.g. `*/prompts/*.py`) | Prompt-injection risk, instructions matching the code that consumes them | ## Verify project config before flagging version issues diff --git a/skills/ess/pr-review/references/github-cli.md b/skills/ess/ess-pr-review/references/github-cli.md similarity index 100% rename from skills/ess/pr-review/references/github-cli.md rename to skills/ess/ess-pr-review/references/github-cli.md diff --git a/skills/ess/pr-review/references/output-format.md b/skills/ess/ess-pr-review/references/output-format.md similarity index 100% rename from skills/ess/pr-review/references/output-format.md rename to skills/ess/ess-pr-review/references/output-format.md diff --git a/skills/ess/pr-review/references/pylint-disables.md b/skills/ess/ess-pr-review/references/pylint-disables.md similarity index 86% rename from skills/ess/pr-review/references/pylint-disables.md rename to skills/ess/ess-pr-review/references/pylint-disables.md index 96be392..d04419c 100644 --- a/skills/ess/pr-review/references/pylint-disables.md +++ b/skills/ess/ess-pr-review/references/pylint-disables.md @@ -1,6 +1,6 @@ # Evaluating suppression comments -`scripts/scan_disables.sh` lists every `pylint: disable`, `noqa`, `type: ignore`, +`scripts/scan-disables.sh` lists every `pylint: disable`, `noqa`, `type: ignore`, and `@ts-ignore` the PR **adds** (with file and new-file line number). A suppression is not automatically acceptable just because it carries a comment — judge each one. The disable exists to hide a linter finding; decide whether @@ -46,9 +46,10 @@ hiding it is the right call or whether the underlying issue should be fixed. - 🟢 **Low**: `import-error`, `no-member` on valid dynamic attributes, `protected-access` for a known API limitation. Usually no action needed. -## Repo note +## Where these findings come from -The root `pyproject.toml` disables pylint's `C`/`R`/`W` categories, so -`scripts/lint_python.sh` re-enables `duplicate-code` (R0801) and the perflint -checks explicitly. The `too-many-*` rules are enforced by **ruff** (`PLR09xx`, -`C901`, `PLR1702`) and appear in the report from there. +Projects commonly disable pylint's `C`/`R`/`W` categories in `pyproject.toml`, so +`scripts/lint-python.sh` re-enables `duplicate-code` (R0801) and the perflint +checks explicitly rather than relying on the repo's config. The `too-many-*` +rules are enforced by **ruff** (`PLR09xx`, `C901`, `PLR1702`) and reach the +report from there. diff --git a/skills/ess/pr-review/scripts/create_review_worktree.sh b/skills/ess/ess-pr-review/scripts/create-review-worktree.sh similarity index 95% rename from skills/ess/pr-review/scripts/create_review_worktree.sh rename to skills/ess/ess-pr-review/scripts/create-review-worktree.sh index 1bc94fa..97673fc 100755 --- a/skills/ess/pr-review/scripts/create_review_worktree.sh +++ b/skills/ess/ess-pr-review/scripts/create-review-worktree.sh @@ -1,18 +1,18 @@ #!/usr/bin/env bash # -# create_review_worktree.sh +# create-review-worktree.sh # Create a READ-ONLY git worktree checked out at a PR's head commit, so each PR # in an N>1 review can be scanned in isolation without touching the main tree. # # Review is read-only: this uses `git worktree add --detach <headSha>` (NOT # `-B <branch>`), so there is no local branch to accidentally commit or push to. -# Contrast with pr-address-comments-all/scripts/create_pr_worktree.sh, which +# Contrast with ess-pr-address-comments/scripts/create-pr-worktree.sh, which # needs a real branch because it writes back to the PR. # # The on-disk layout matches the /worktree command # (~/.cursor/worktrees/<WORKTREE_ID>/<repo-key>) so /delete-worktree still works. # -# Usage: create_review_worktree.sh --pr N --repo owner/repo +# Usage: create-review-worktree.sh --pr N --repo owner/repo # --pr N - PR number (used to name the worktree) # --repo owner/repo - Repo the PR lives in # @@ -74,7 +74,7 @@ info "Creating read-only detached worktree at ${HEAD_SHA:0:8}" git worktree add --detach "$WORKTREE_DIR" "$HEAD_SHA" # Run the repo's worktree setup so linters resolve (venv, .env). Mirrors -# create_pr_worktree.sh; kept identical so both skills behave the same. +# create-pr-worktree.sh; kept identical so both skills behave the same. WORKTREES_JSON="${REPO_ROOT}/.cursor/worktrees.json" export ROOT_WORKTREE_PATH="$REPO_ROOT" ( diff --git a/skills/ess/ess-pr-review/scripts/find-review-requests.sh b/skills/ess/ess-pr-review/scripts/find-review-requests.sh new file mode 100755 index 0000000..862e072 --- /dev/null +++ b/skills/ess/ess-pr-review/scripts/find-review-requests.sh @@ -0,0 +1,422 @@ +#!/usr/bin/env bash +# +# find-review-requests.sh +# Discover the current repo's open PRs where a review is requested of you +# (review-requested:@me). Use when /ess-pr-review is invoked with no PR refs. +# +# Usage: find-review-requests.sh [--repo owner/repo] [--limit N] [--include-team] +# [--author TERM]... +# --repo owner/repo - Repo to query (default: current remote) +# --limit N - Max PRs to return (default: 50) +# --include-team - Also include PRs requested from a team you belong to +# (default only counts direct, user-level requests) +# --author TERM - Keep only PRs from TERM. Repeatable; a PR is kept when +# it matches ANY term. TERM is one of: +# @me - you +# @org/team-slug - members of that GitHub org team +# @team-slug - same, in this repo's own org +# anything else - a GitHub login or email (exact), or +# a display name (substring), matched +# against the PR author and its +# commit authors +# +# --author only ever NARROWS the review-requested set; it cannot surface a PR +# that was never assigned to you. Listing org team members needs the `read:org` +# scope on your gh token. +# +# Environment: +# PR_REVIEW_PR_FIXTURE - path to a `gh pr list --json` JSON array that stands +# in for the live query, so test_find_review_requests.sh +# can exercise filtering offline +# PR_REVIEW_TEAM_FIXTURE - path to a TSV of "org/slug<TAB>login" rows that +# stands in for org team member lookups +# PR_REVIEW_COMMIT_FAIL - space-separated PR numbers whose commit-author lookup +# should fail, so the test can reach that path (only +# honored alongside PR_REVIEW_PR_FIXTURE) +# +# Output: +# stdout - matching PR numbers, one per line (for the skill to loop over) +# stderr - a human-readable table (number, branch, author, title) +# +# Drafts are skipped. Exits 0 with empty stdout when nothing matches. Exits +# non-zero, after printing the table and every warning, when a commit-author +# lookup failed and the results are therefore incomplete. +# +# This script only reads; it posts and writes nothing, so it takes no --dry-run +# and never prompts. Portable to bash 3.2 (macOS): no associative arrays and no +# unguarded expansion of a possibly-empty array. + +set -euo pipefail + +_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "${_LIB_DIR}/lib.sh" + +PR_FIXTURE="${PR_REVIEW_PR_FIXTURE:-}" +TEAM_FIXTURE="${PR_REVIEW_TEAM_FIXTURE:-}" +COMMIT_FAIL_NUMBERS="${PR_REVIEW_COMMIT_FAIL:-}" + +# gh is only needed for the live queries; the fixtures replace both of them. +if [[ -z "$PR_FIXTURE" || -z "$TEAM_FIXTURE" ]]; then + command -v gh >/dev/null 2>&1 || error "gh CLI not found on PATH" +fi + +OWNER_REPO="" +LIMIT="50" +INCLUDE_TEAM="false" +RAW_TERMS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) OWNER_REPO="${2:-}"; shift 2 ;; + --limit) LIMIT="${2:-}"; shift 2 ;; + --include-team) INCLUDE_TEAM="true"; shift ;; + --author) + [[ -n "${2:-}" ]] || error "--author needs a value" + RAW_TERMS+=("$2"); shift 2 ;; + -h|--help) sed -n '2,43p' "$0"; exit 0 ;; + *) error "Unknown argument: $1" ;; + esac +done + +if [[ -z "$OWNER_REPO" ]]; then + OWNER_REPO="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" \ + || error "Could not determine repo from current remote; pass --repo owner/repo" +fi +REPO_OWNER="${OWNER_REPO%%/*}" + +lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } + +# Logins of a GitHub org team. Members come back one per line; the caller turns +# them into exact-match terms. +team_members() { + local org="$1" slug="$2" + if [[ -n "$TEAM_FIXTURE" ]]; then + [[ -f "$TEAM_FIXTURE" ]] \ + || error "PR_REVIEW_TEAM_FIXTURE not found: $TEAM_FIXTURE" + # A row whose key is present but whose login is empty models a real team + # with no visible members (warn, exit 0); an absent key models a bad slug + # and must fail, like the live `gh api` call does. + awk -F'\t' -v key="${org}/${slug}" \ + '$1 == key { found = 1; if ($2 != "") print $2 } + END { exit(found ? 0 : 1) }' "$TEAM_FIXTURE" + return + fi + gh api --paginate "orgs/${org}/teams/${slug}/members" --jq '.[].login' +} + +# --- resolve --author terms --------------------------------------------------- +# Each raw term keeps its own row so an unmatched term can be reported using the +# text the user typed rather than whatever it expanded to. Parallel arrays stand +# in for the associative array bash 3.2 does not have: +# TERM_LOGINS[i] - "|login|login|" for @me and @org/team terms, else empty +# TERM_FREE[i] - lowercased free text for every other term, else empty +# TERM_HIT[i] - 1 once the term has kept at least one PR +TERM_LOGINS=() +TERM_FREE=() +TERM_HIT=() + +if [[ ${#RAW_TERMS[@]} -gt 0 ]]; then + for RAW in "${RAW_TERMS[@]}"; do + LOGINS="" + FREE="" + case "$RAW" in + @me) + ME="$(gh api user --jq .login)" \ + || error "could not resolve @me; check 'gh auth status'" + LOGINS="|$(lower "$ME")|" + ;; + @*) + SPEC="${RAW#@}" + if [[ "$SPEC" == */* ]]; then + ORG="${SPEC%%/*}" + SLUG="${SPEC#*/}" + else + ORG="$REPO_OWNER" + SLUG="$SPEC" + fi + [[ -n "$ORG" && -n "$SLUG" ]] \ + || error "could not parse team from '${RAW}'; expected @org/team-slug" + # A failure here is a bad slug or a token without read:org -- + # a configuration problem, not an empty result -- so stop. + MEMBERS="$(team_members "$ORG" "$SLUG")" \ + || error "could not list members of ${ORG}/${SLUG}; check the slug and that your gh token has the read:org scope" + LOGINS="|" + COUNT=0 + while IFS= read -r MEMBER; do + [[ -n "$MEMBER" ]] || continue + LOGINS="${LOGINS}$(lower "$MEMBER")|" + COUNT=$((COUNT + 1)) + done <<< "$MEMBERS" + # A successful but empty response is an unmatched term, not a + # configuration error: with no logins and no free text the term + # keeps nothing and the per-term warning below reports it. + if [[ "$COUNT" -eq 0 ]]; then + warn "team ${ORG}/${SLUG} has no visible members; check the slug and that your gh token has the read:org scope" + LOGINS="" + else + info "Team ${ORG}/${SLUG}: ${COUNT} member(s)" + fi + ;; + *) + FREE="$(lower "$RAW")" + ;; + esac + TERM_LOGINS+=("$LOGINS") + TERM_FREE+=("$FREE") + TERM_HIT+=("0") + done +fi + +# Only free-text terms can ever need commit-author data, which the PR list query +# does not carry. @me and @org/team terms are answered entirely by that query, so +# this flag short-circuits the per-PR escalation when every term is one of those. +HAS_FREE="false" +if [[ ${#TERM_FREE[@]} -gt 0 ]]; then + for FREE in "${TERM_FREE[@]}"; do + if [[ -n "$FREE" ]]; then + HAS_FREE="true" + break + fi + done +fi + +# --- query -------------------------------------------------------------------- +# GitHub search distinguishes: +# user-review-requested:@me - only direct, user-level review requests +# review-requested:@me - direct requests PLUS requests to a team you are on +# Default to the narrower user-level filter; --include-team broadens it. +if [[ "$INCLUDE_TEAM" == "true" ]]; then + SEARCH="review-requested:@me" +else + SEARCH="user-review-requested:@me" +fi +info "Scanning open PRs in ${OWNER_REPO} where review is requested of you (limit ${LIMIT})" + +# Columns: number, branch, author login, title, a draft flag, then two +# lowercased match blobs. +# exact blob - "|login|...|", for terms that must match a whole field +# name blob - "|display name|...|", for substring matching +# `.author` carries both the login and the profile display name, so filtering by +# either costs no extra request. Commit authors are deliberately NOT requested +# here: `commits` on a 50-PR list expands to more than GitHub's 500,000-node +# GraphQL ceiling, so they are looked up per PR, lazily, further down. Drafts are +# returned rather than filtered here so the loop can count every row the query +# actually spent its --limit on. Both blobs are internal -- they never reach the +# table. +JQ_ROWS='.[] + | (.author.login // "") as $login + | ([$login] | map(select(. != "") | ascii_downcase) | unique) as $exact + | ([.author.name // empty] + | map(select(. != "") | ascii_downcase) | unique) as $names + | [.number, .headRefName, $login, .title, + (if .isDraft then "1" else "0" end), + "|" + ($exact | join("|")) + "|", + "|" + ($names | join("|")) + "|"] | @tsv' + +# Same two blobs, built from one PR's commit authors. +JQ_COMMITS='([.commits[]?.authors[]?.login // empty] + + [.commits[]?.authors[]?.email // empty] + | map(select(. != "") | ascii_downcase) | unique) as $exact + | ([.commits[]?.authors[]?.name // empty] + | map(select(. != "") | ascii_downcase) | unique) as $names + | ["|" + ($exact | join("|")) + "|", + "|" + ($names | join("|")) + "|"] | @tsv' + +if [[ -n "$PR_FIXTURE" ]]; then + # Test seam: a canned `gh pr list --json` array stands in for the live query + # so the filtering below can be exercised offline. The same jq programs run + # either way, so the blobs under test are the real ones. + [[ -f "$PR_FIXTURE" ]] || error "PR_REVIEW_PR_FIXTURE not found: $PR_FIXTURE" + command -v jq >/dev/null 2>&1 || error "jq is required when PR_REVIEW_PR_FIXTURE is set" + PR_ROWS="$(jq -r "$JQ_ROWS" < "$PR_FIXTURE")" \ + || error "could not parse PR fixture: $PR_FIXTURE" + info "PR fixture: ${PR_FIXTURE}" +else + PR_ROWS="$(gh pr list --repo "$OWNER_REPO" --state open --limit "$LIMIT" \ + --search "$SEARCH" \ + --json number,headRefName,author,title,isDraft \ + --jq "$JQ_ROWS")" \ + || error "gh pr list failed for ${OWNER_REPO}" +fi + +# Commit authors for one PR, as "exact_blob<TAB>name_blob". Only reached when a +# term needs data the list query does not carry. Returns non-zero when the lookup +# fails, so the caller can report incomplete results rather than silently +# treating a failed lookup as "this PR does not match". +commit_blobs() { + local number="$1" out + if [[ -n "$PR_FIXTURE" ]]; then + # Test seam: the fixture path never touches gh, so this lists the PR + # numbers whose lookup should fail, making the failure path reachable + # offline. + case " ${COMMIT_FAIL_NUMBERS} " in + *" ${number} "*) + warn "could not read commit authors for PR #${number}: forced by PR_REVIEW_COMMIT_FAIL" + return 1 + ;; + esac + jq -r --argjson n "$number" \ + ".[] | select(.number == \$n) | ${JQ_COMMITS}" < "$PR_FIXTURE" + return + fi + if ! out="$(gh pr view "$number" --repo "$OWNER_REPO" --json commits \ + --jq "$JQ_COMMITS" 2>&1)"; then + warn "could not read commit authors for PR #${number}: ${out}" + return 1 + fi + printf '%s\n' "$out" +} + +# An empty queue needs no guard: `while ... <<< ""` yields one empty line, which +# the loop's empty-NUMBER check skips, so the loop is a no-op and control still +# reaches the per-term warnings and the zero-match message below. + +# --- filter and report -------------------------------------------------------- +# Quoted case patterns match literally, so a term containing *, ? or [ cannot +# turn into a glob and over-match. +term_matches() { + local index="$1" exact_blob="$2" name_blob="$3" + local logins="${TERM_LOGINS[index]}" + local free="${TERM_FREE[index]}" + + if [[ -n "$logins" ]]; then + local login + local -a parts=() + IFS='|' read -r -a parts <<< "$logins" + if [[ ${#parts[@]} -gt 0 ]]; then + for login in "${parts[@]}"; do + [[ -n "$login" ]] || continue + case "$exact_blob" in *"|${login}|"*) return 0 ;; esac + done + fi + fi + + if [[ -n "$free" ]]; then + # A login or email has to match a whole field; a display name is allowed + # to match part of one, so "Bob" finds "Bob Brown". + case "$exact_blob" in *"|${free}|"*) return 0 ;; esac + case "$name_blob" in *"${free}"*) return 0 ;; esac + fi + + return 1 +} + +printf 'PR\tBRANCH\tAUTHOR\tTITLE\n' >&2 + +MATCHES=() +# FETCHED counts every row the query returned, drafts included, so the truncation +# check below reflects what --limit actually consumed. SEEN counts the reviewable +# (non-draft) PRs the filter had to judge. +SEEN=0 +FETCHED=0 +COMMIT_LOOKUP_FAILED="false" +while IFS=$'\t' read -r NUMBER BRANCH AUTHOR TITLE IS_DRAFT EXACT_BLOB NAME_BLOB; do + [[ -z "$NUMBER" ]] && continue + FETCHED=$((FETCHED + 1)) + [[ "$IS_DRAFT" == "1" ]] && continue + SEEN=$((SEEN + 1)) + + if [[ ${#RAW_TERMS[@]} -gt 0 ]]; then + KEEP="false" + # Per-term record of whether the list query alone settled this PR, so the + # escalation below can tell "already matched here" from "still unknown". + MATCHED_HERE=() + INDEX=0 + while [[ $INDEX -lt ${#RAW_TERMS[@]} ]]; do + if term_matches "$INDEX" "$EXACT_BLOB" "$NAME_BLOB"; then + KEEP="true" + TERM_HIT[INDEX]="1" + MATCHED_HERE+=("1") + else + MATCHED_HERE+=("0") + fi + INDEX=$((INDEX + 1)) + done + + # Escalate to this PR's commit authors while any free-text term is still + # unsettled for it -- either it did not match here (so KEEP may be wrong) + # or it has matched no PR at all yet (so its warning may be wrong). Commit + # authors are the one thing the list query cannot answer, so ANY free-text + # term -- login, email, or display name -- can cost one request per PR it + # has not already matched; only @me and @org/team terms are always free. + # That is the deliberate price of not missing a commit author whose name + # differs from the PR author's profile name. + NEED_COMMITS="false" + if [[ "$HAS_FREE" == "true" ]]; then + INDEX=0 + while [[ $INDEX -lt ${#RAW_TERMS[@]} ]]; do + if [[ -n "${TERM_FREE[INDEX]}" ]] \ + && { [[ "${MATCHED_HERE[INDEX]}" == "0" ]] \ + || [[ "${TERM_HIT[INDEX]}" == "0" ]]; }; then + NEED_COMMITS="true" + break + fi + INDEX=$((INDEX + 1)) + done + fi + + # </dev/null keeps gh off the loop's own stdin. The failure flag has to + # live out here: the command substitution runs in a subshell, so a flag + # set inside it would be discarded. + if [[ "$NEED_COMMITS" == "true" ]]; then + if ! COMMIT_ROW="$(commit_blobs "$NUMBER" </dev/null)"; then + COMMIT_LOOKUP_FAILED="true" + COMMIT_ROW="" + fi + if [[ -n "$COMMIT_ROW" ]]; then + IFS=$'\t' read -r C_EXACT C_NAMES <<< "$COMMIT_ROW" + INDEX=0 + while [[ $INDEX -lt ${#RAW_TERMS[@]} ]]; do + if term_matches "$INDEX" "${C_EXACT:-||}" "${C_NAMES:-||}"; then + KEEP="true" + TERM_HIT[INDEX]="1" + fi + INDEX=$((INDEX + 1)) + done + fi + fi + + [[ "$KEEP" == "true" ]] || continue + fi + + MATCHES+=("$NUMBER") + printf '%s\t%s\t%s\t%s\n' "$NUMBER" "$BRANCH" "${AUTHOR:-unknown}" "$TITLE" >&2 +done <<< "$PR_ROWS" + +if [[ ${#RAW_TERMS[@]} -gt 0 ]]; then + TERM_LIST="" + for RAW in "${RAW_TERMS[@]}"; do + [[ -n "$TERM_LIST" ]] && TERM_LIST="${TERM_LIST}, " + TERM_LIST="${TERM_LIST}${RAW}" + done + if [[ "$SEEN" -gt 0 ]]; then + info "Author filter: ${TERM_LIST} — kept ${#MATCHES[@]} of ${SEEN} PR(s)" + fi + INDEX=0 + while [[ $INDEX -lt ${#RAW_TERMS[@]} ]]; do + if [[ "${TERM_HIT[INDEX]}" == "0" ]]; then + warn "no PRs awaiting your review match '${RAW_TERMS[$INDEX]}'" + fi + INDEX=$((INDEX + 1)) + done + # The author filter runs after the query, so a truncated queue can hide + # matching PRs entirely. Drafts count here: they still occupy a --limit slot. + if [[ "$FETCHED" -ge "$LIMIT" ]]; then + warn "your review queue hit the limit of ${LIMIT} before filtering; raise --limit to widen the search" + fi +fi + +# Reported last, after the table and every warning, so a partial result is still +# visible -- but non-zero, because a PR may have been dropped for want of data. +if [[ "$COMMIT_LOOKUP_FAILED" == "true" ]]; then + error "commit-author lookup failed for one or more PRs; results are incomplete" +fi + +if [[ ${#MATCHES[@]} -eq 0 ]]; then + info "No open, non-draft PRs request your review in ${OWNER_REPO}" + exit 0 +fi + +info "Matched ${#MATCHES[@]} PR(s)" +printf '%s\n' "${MATCHES[@]}" diff --git a/skills/ess/pr-review/scripts/lib.sh b/skills/ess/ess-pr-review/scripts/lib.sh similarity index 98% rename from skills/ess/pr-review/scripts/lib.sh rename to skills/ess/ess-pr-review/scripts/lib.sh index c563167..3e4dbdf 100644 --- a/skills/ess/pr-review/scripts/lib.sh +++ b/skills/ess/ess-pr-review/scripts/lib.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# lib.sh - shared helpers for the pr-review skill scripts. +# lib.sh - shared helpers for the ess-pr-review skill scripts. # # Sourced (not executed) by the other scripts in this directory. It sets no # shell options and owns no `set -e/-u`: each script keeps its own so that the diff --git a/skills/ess/pr-review/scripts/lint_python.sh b/skills/ess/ess-pr-review/scripts/lint-python.sh similarity index 98% rename from skills/ess/pr-review/scripts/lint_python.sh rename to skills/ess/ess-pr-review/scripts/lint-python.sh index 9b0cf6a..89b8c45 100755 --- a/skills/ess/pr-review/scripts/lint_python.sh +++ b/skills/ess/ess-pr-review/scripts/lint-python.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# lint_python.sh <out_dir> +# lint-python.sh <out_dir> # # Runs ruff, bandit, and a duplicate-code + perflint pylint pass over the changed # Python files listed in <out_dir>/py_files.txt, writing machine-readable output: @@ -19,7 +19,7 @@ _LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib.sh . "${_LIB_DIR}/lib.sh" -OUT_DIR="${1:?usage: lint_python.sh <out_dir>}" +OUT_DIR="${1:?usage: lint-python.sh <out_dir>}" STATUS_FILE="${OUT_DIR}/tools.tsv" # Read the changed-file list into FILES (bash 3.2 compatible). diff --git a/skills/ess/pr-review/scripts/lint_ts.sh b/skills/ess/ess-pr-review/scripts/lint-ts.sh similarity index 98% rename from skills/ess/pr-review/scripts/lint_ts.sh rename to skills/ess/ess-pr-review/scripts/lint-ts.sh index 476520b..fedb7a9 100755 --- a/skills/ess/pr-review/scripts/lint_ts.sh +++ b/skills/ess/ess-pr-review/scripts/lint-ts.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# lint_ts.sh <out_dir> +# lint-ts.sh <out_dir> # # Best-effort ESLint pass (with eslint-plugin-sonarjs when the repo config # enables it) over the changed TypeScript files listed in <out_dir>/ts_files.txt. @@ -26,7 +26,7 @@ _LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib.sh . "${_LIB_DIR}/lib.sh" -OUT_DIR="${1:?usage: lint_ts.sh <out_dir>}" +OUT_DIR="${1:?usage: lint-ts.sh <out_dir>}" STATUS_FILE="${OUT_DIR}/tools.tsv" ROOT="$(repo_root)" diff --git a/skills/ess/pr-review/scripts/report.py b/skills/ess/ess-pr-review/scripts/report.py similarity index 98% rename from skills/ess/pr-review/scripts/report.py rename to skills/ess/ess-pr-review/scripts/report.py index ef3b332..2977311 100644 --- a/skills/ess/pr-review/scripts/report.py +++ b/skills/ess/ess-pr-review/scripts/report.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Merge and rank linter output for the pr-review skill. +"""Merge and rank linter output for the ess-pr-review skill. Reads the raw linter JSON written by the scan scripts into an input directory and emits two artifacts next to them: @@ -186,7 +186,7 @@ def load_pylint(path: Path) -> list[Finding]: def load_eslint(input_dir: Path) -> list[Finding]: """Parse every ``eslint*.json`` part in ``input_dir`` into findings. - ESLint is often installed per-app in a monorepo, so ``lint_ts.sh`` may run it + ESLint is often installed per-app in a monorepo, so ``lint-ts.sh`` may run it once per app and emit one part file per group; they are merged here. """ findings: list[Finding] = [] @@ -380,7 +380,7 @@ def load_all(input_dir: Path) -> tuple[list[Finding], list[dict], dict]: def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Merge pr-review linter output.") + parser = argparse.ArgumentParser(description="Merge ess-pr-review linter output.") parser.add_argument( "--input-dir", required=True, diff --git a/skills/ess/ess-pr-review/scripts/scan-disables.sh b/skills/ess/ess-pr-review/scripts/scan-disables.sh new file mode 100755 index 0000000..279d28e --- /dev/null +++ b/skills/ess/ess-pr-review/scripts/scan-disables.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# scan-disables.sh <out_dir> <base> <head> +# +# Scans the ADDED lines of the diff for suppression comments the PR introduces: +# Python: # pylint: disable=... # noqa # type: ignore +# TypeScript: // @ts-ignore // @ts-expect-error +# +# Writes <out_dir>/disables.json as a JSON array of {path, line, text}, where +# line is the new-file line number. The LLM evaluates whether each suppression +# is justified (see references/pylint-disables.md) -- this script only locates +# them. Uses only git + awk (no jq / python dependency). + +set -uo pipefail + +_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "${_LIB_DIR}/lib.sh" + +OUT_DIR="${1:?usage: scan-disables.sh <out_dir> <base> <head>}" +BASE="${2:?missing base ref}" +HEAD="${3:?missing head ref}" + +# Names come from a NUL-delimited diff so spaces and Git quoting stay intact. +# Each file's diff is parsed separately; the path is passed in, not read from +# the +++ header. +NAMES="$(mktemp)" +FINDINGS="$(mktemp)" +cleanup_scan() { rm -f "$NAMES" "$FINDINGS"; } +trap cleanup_scan EXIT + +if ! git diff -z --name-only --diff-filter=ACMR "${BASE}...${HEAD}" -- \ + '*.py' '*.pyi' '*.ts' '*.tsx' >"$NAMES" 2>/dev/null; then + error "scan-disables.sh: git diff failed for ${BASE}...${HEAD}" +fi + +while IFS= read -r -d '' path; do + [[ -n "$path" ]] || continue + if ! FILE_DIFF="$(git diff --no-color "${BASE}...${HEAD}" -- "$path" 2>/dev/null)"; then + error "scan-disables.sh: git diff failed for ${path}" + fi + SCAN_PATH="$path" awk ' +function esc(s) { + gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); gsub(/\t/, " ", s); sub(/\r$/, "", s) + return s +} +BEGIN { path = ENVIRON["SCAN_PATH"] } +/^--- / { next } +/^\+\+\+ / { next } +/^@@ / { h = $3; sub(/^\+/, "", h); split(h, a, ","); ln = a[1] + 0; next } +{ + c = substr($0, 1, 1); rest = substr($0, 2) + if (c == "+") { + if (rest ~ /pylint:[[:space:]]*disable/ \ + || rest ~ /(#|\/\/)[[:space:]]*noqa/ \ + || rest ~ /type:[[:space:]]*ignore/ \ + || rest ~ /@ts-(ignore|expect-error)/) { + body = rest; sub(/^[[:space:]]+/, "", body) + printf "{\"path\": \"%s\", \"line\": %d, \"text\": \"%s\"}\n", esc(path), ln, esc(body) + } + ln++ + } else if (c == " ") { + ln++ + } +} +' <<<"$FILE_DIFF" >>"$FINDINGS" +done <"$NAMES" + +if [[ ! -s "$FINDINGS" ]]; then + printf '[]\n' >"${OUT_DIR}/disables.json" +else + { + printf '[\n' + first=1 + while IFS= read -r finding; do + if [[ "$first" -eq 1 ]]; then + first=0 + else + printf ',\n' + fi + printf ' %s' "$finding" + done <"$FINDINGS" + printf '\n]\n' + } >"${OUT_DIR}/disables.json" +fi diff --git a/skills/ess/pr-review/scripts/scan-pr.sh b/skills/ess/ess-pr-review/scripts/scan-pr.sh similarity index 76% rename from skills/ess/pr-review/scripts/scan-pr.sh rename to skills/ess/ess-pr-review/scripts/scan-pr.sh index 8a54ab6..7ab74ae 100755 --- a/skills/ess/pr-review/scripts/scan-pr.sh +++ b/skills/ess/ess-pr-review/scripts/scan-pr.sh @@ -138,6 +138,9 @@ case "$OUTPUT_DIR" in */) error "output dir must not end with '/': '${OUTPUT_DIR}'" ;; esac rm -rf "$OUTPUT_DIR"; mkdir -p "$OUTPUT_DIR" +# The linters and report run from the pinned checkout, so a relative --output +# must already point at the caller's directory before anything changes into it. +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" : > "${OUTPUT_DIR}/tools.tsv" info "diff range: ${BASE}...${HEAD}" info "output dir: ${OUTPUT_DIR}" @@ -151,9 +154,62 @@ PY_COUNT="$(wc -l < "${OUTPUT_DIR}/py_files.txt" | tr -d ' ')" TS_COUNT="$(wc -l < "${OUTPUT_DIR}/ts_files.txt" | tr -d ' ')" info "changed files: ${PY_COUNT} python, ${TS_COUNT} typescript" +# Lint the pinned head, not whatever checkout invoked this script. The file +# list above is commit-accurate; ruff, pylint, and bandit also read the files +# and the repo config from the working tree. +LINT_WORKTREE="" +cleanup_lint_worktree() { + if [[ -n "${LINT_WORKTREE}" ]]; then + git worktree remove --force "$LINT_WORKTREE" >/dev/null 2>&1 || rm -rf "$LINT_WORKTREE" + fi +} +trap cleanup_lint_worktree EXIT +LINT_WORKTREE="$(mktemp -d "${TMPDIR:-/tmp}/pr-review-lint.XXXXXX")" +git worktree add --detach --quiet "$LINT_WORKTREE" "$HEAD" \ + || error "could not check out ${HEAD} to lint that commit" + +# ESLint resolves its binary, plugins, and shared configs from node_modules next +# to each config and its ancestors, and a fresh checkout has none. Link the +# caller's installed tree, or the main worktree's when the caller is itself a +# fresh worktree, at the repo root and above every changed TypeScript file. +link_node_modules() { + local caller_root main_root source_root="" candidate dir file + caller_root="$(repo_root)" + main_root="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" + main_root="${main_root%/.git}" + for candidate in "$caller_root" "$main_root"; do + if [[ -n "$candidate" && -d "${candidate}/node_modules" ]]; then + source_root="$candidate"; break + fi + done + if [[ -z "$source_root" ]]; then + warn "no node_modules in ${caller_root} or the main worktree; eslint may not run" + return 0 + fi + while IFS= read -r file; do + [[ -n "$file" ]] || continue + dir="$(dirname "$file")" + while :; do + echo "$dir" + [[ "$dir" == "." ]] && break + dir="$(dirname "$dir")" + done + done < "${OUTPUT_DIR}/ts_files.txt" | sort -u > "${OUTPUT_DIR}/node_modules_dirs.txt" + while IFS= read -r dir; do + if [[ -d "${source_root}/${dir}/node_modules" && -d "${LINT_WORKTREE}/${dir}" \ + && ! -e "${LINT_WORKTREE}/${dir}/node_modules" ]]; then + ln -s "${source_root}/${dir}/node_modules" "${LINT_WORKTREE}/${dir}/node_modules" + fi + done < "${OUTPUT_DIR}/node_modules_dirs.txt" +} +if [[ "$TS_COUNT" -gt 0 ]]; then + link_node_modules +fi + # --- run the linters (each sub-script appends its own status to tools.tsv) ----- if [[ "$PY_COUNT" -gt 0 ]]; then - bash "${SCRIPT_DIR}/lint_python.sh" "$OUTPUT_DIR" || warn "python lint step failed" + ( cd "$LINT_WORKTREE" && bash "${SCRIPT_DIR}/lint-python.sh" "$OUTPUT_DIR" ) \ + || warn "python lint step failed" else printf 'ruff\tnot run (no python files)\n' >> "${OUTPUT_DIR}/tools.tsv" printf 'pylint\tnot run (no python files)\n' >> "${OUTPUT_DIR}/tools.tsv" @@ -161,13 +217,14 @@ else fi if [[ "$TS_COUNT" -gt 0 ]]; then - bash "${SCRIPT_DIR}/lint_ts.sh" "$OUTPUT_DIR" || warn "typescript lint step failed" + ( cd "$LINT_WORKTREE" && bash "${SCRIPT_DIR}/lint-ts.sh" "$OUTPUT_DIR" ) \ + || warn "typescript lint step failed" else printf 'eslint\tnot run (no typescript files)\n' >> "${OUTPUT_DIR}/tools.tsv" fi # --- suppression comments added by the diff ----------------------------------- -bash "${SCRIPT_DIR}/scan_disables.sh" "$OUTPUT_DIR" "$BASE" "$HEAD" || warn "disable scan failed" +bash "${SCRIPT_DIR}/scan-disables.sh" "$OUTPUT_DIR" "$BASE" "$HEAD" || warn "disable scan failed" # --- scan-meta.json ----------------------------------------------------------- { @@ -186,13 +243,9 @@ bash "${SCRIPT_DIR}/scan_disables.sh" "$OUTPUT_DIR" "$BASE" "$HEAD" || warn "dis PYRUN="$(py_runner)" [[ -n "$PYRUN" ]] || error "no python interpreter found to build the report" # report.py relativizes eslint's absolute paths against its cwd, so run it from -# the SCANNED repo's root -- otherwise invoking scan-pr.sh from a subdirectory -# leaks absolute paths into the report. repo_root() resolves the cwd's repo (the -# tree being scanned), not the skill's. Pass an absolute OUTPUT_DIR since we cd. -REPO_ROOT="$(repo_root)" -OUTPUT_DIR_ABS="$(cd "$OUTPUT_DIR" && pwd)" +# the pinned checkout the linters just used. # shellcheck disable=SC2086 -( cd "$REPO_ROOT" && $PYRUN "${SCRIPT_DIR}/report.py" --input-dir "$OUTPUT_DIR_ABS" ) \ +( cd "$LINT_WORKTREE" && $PYRUN "${SCRIPT_DIR}/report.py" --input-dir "$OUTPUT_DIR" ) \ || error "report.py failed" info "report ready: ${OUTPUT_DIR}/report.md" diff --git a/skills/ess/ess-pr-review/scripts/test_find_review_requests.sh b/skills/ess/ess-pr-review/scripts/test_find_review_requests.sh new file mode 100755 index 0000000..4b7af42 --- /dev/null +++ b/skills/ess/ess-pr-review/scripts/test_find_review_requests.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# +# test_find_review_requests.sh +# Exercise find-review-requests.sh's --author filtering. Runs entirely offline: +# the PR list comes from a JSON fixture via PR_REVIEW_PR_FIXTURE and org team +# membership from a TSV fixture via PR_REVIEW_TEAM_FIXTURE, so no GitHub access +# (and no gh CLI) is needed. +# +# Usage: test_find_review_requests.sh [--keep] +# --keep - leave the fixture directory in place for inspection +# +# Exits non-zero if any assertion failed. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "${SCRIPT_DIR}/lib.sh" + +KEEP="false" +[[ "${1:-}" == "--keep" ]] && KEEP="true" + +PASS=0 +FAIL=0 + +# --- assertions --------------------------------------------------------------- +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + PASS=$((PASS + 1)) + echo " ok ${label}" + else + FAIL=$((FAIL + 1)) + echo " FAIL ${label}: expected '${expected}', got '${actual}'" + fi +} + +assert_contains() { + local label="$1" needle="$2" haystack="$3" + if printf '%s' "$haystack" | grep -qF -- "$needle"; then + PASS=$((PASS + 1)) + echo " ok ${label}" + else + FAIL=$((FAIL + 1)) + echo " FAIL ${label}: '${needle}' not found in output" + fi +} + +assert_not_contains() { + local label="$1" needle="$2" haystack="$3" + if printf '%s' "$haystack" | grep -qF -- "$needle"; then + FAIL=$((FAIL + 1)) + echo " FAIL ${label}: '${needle}' unexpectedly found in output" + else + PASS=$((PASS + 1)) + echo " ok ${label}" + fi +} + +assert_fails() { + local label="$1"; shift + if "$@" >/dev/null 2>&1; then + FAIL=$((FAIL + 1)) + echo " FAIL ${label}: command unexpectedly succeeded" + else + PASS=$((PASS + 1)) + echo " ok ${label}" + fi +} + +# An empty result and a crash both produce no stdout, so any assertion expecting +# "" must also prove the run succeeded. +assert_ok() { + local label="$1"; shift + if run_raw "$@" >/dev/null 2>&1; then + PASS=$((PASS + 1)) + echo " ok ${label}" + else + FAIL=$((FAIL + 1)) + echo " FAIL ${label}: expected exit 0, got $?" + fi +} + +# --- fixtures ----------------------------------------------------------------- +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/pr-review-find-test.XXXXXX")" +cleanup() { + if [[ "$KEEP" == "true" ]]; then + info "Fixtures kept at ${TMP_ROOT}" + else + rm -rf "$TMP_ROOT" + fi +} +trap cleanup EXIT + +PRS="${TMP_ROOT}/prs.json" +EMPTY_PRS="${TMP_ROOT}/empty-prs.json" +TEAMS="${TMP_ROOT}/teams.tsv" + +# Shape mirrors `gh pr list --json number,headRefName,author,title,isDraft,commits`. +# PR 104 is a draft and must never appear. PR 105's commit author has an empty +# login and email, covering the blob builder's empty-field guard. PR 103's commit +# author name ("Caroline Chen") deliberately differs from its profile name +# ("Carol Chen"), so a term can only match it via the commit lookup. +cat > "$PRS" <<'JSON' +[ + { "number": 101, "headRefName": "feat-a", "isDraft": false, + "title": "Add widget cache", + "author": { "login": "alice", "name": "Alice Anderson" }, + "commits": [ { "authors": [ + { "login": "alice", "name": "Alice Anderson", "email": "alice@example.com" } ] } ] }, + + { "number": 102, "headRefName": "feat-b", "isDraft": false, + "title": "Fix pagination", + "author": { "login": "bob_corp", "name": "Bob Brown" }, + "commits": [ { "authors": [ + { "login": "", "name": "Bob Brown", "email": "bob.brown@corp.example.com" } ] } ] }, + + { "number": 103, "headRefName": "feat-c", "isDraft": false, + "title": "Tidy imports", + "author": { "login": "carol", "name": "Carol Chen" }, + "commits": [ { "authors": [ + { "login": "carol", "name": "Caroline Chen", "email": "carol@example.com" } ] } ] }, + + { "number": 104, "headRefName": "feat-draft", "isDraft": true, + "title": "WIP do not review", + "author": { "login": "alice", "name": "Alice Anderson" }, + "commits": [ { "authors": [ + { "login": "alice", "name": "Alice Anderson", "email": "alice@example.com" } ] } ] }, + + { "number": 105, "headRefName": "feat-e", "isDraft": false, + "title": "Bump docs", + "author": { "login": "dave", "name": "Dave Davis" }, + "commits": [ { "authors": [ + { "login": "", "name": "", "email": "" } ] } ] }, + + { "number": 106, "headRefName": "feat-f", "isDraft": false, + "title": "Rename helper", + "author": { "login": "erin", "name": "" }, + "commits": [ { "authors": [ + { "login": "erin", "name": "Erin Evans", "email": "erin@example.com" } ] } ] } +] +JSON + +# An empty review queue, for the "the query returned nothing" path. +printf '[]\n' > "$EMPTY_PRS" + +# Columns: org/slug<TAB>login +{ + printf 'acme/reviewers\talice\n' + printf 'acme/reviewers\tcarol\n' + printf 'other/outsiders\tbob_corp\n' + # A "login" that is only ever a fragment of a display name. Team members are + # matched exactly, so this must select nothing. + printf 'acme/fragment\tali\n' + # Key present, login empty: a real team with no visible members. Distinct from + # an absent key, which models a bad slug. + printf 'acme/empty\t\n' +} > "$TEAMS" + +# --- runner ------------------------------------------------------------------- +# Returns the PR numbers on stdout, comma-joined, with stderr discarded. +run_filter() { + local out + out="$(PR_REVIEW_PR_FIXTURE="$PRS" PR_REVIEW_TEAM_FIXTURE="$TEAMS" \ + "${SCRIPT_DIR}/find-review-requests.sh" --repo acme/widgets "$@" 2>/dev/null)" + printf '%s' "$out" | tr '\n' ',' | sed 's/,$//' +} + +# Returns stderr only, for warning assertions. +run_stderr() { + PR_REVIEW_PR_FIXTURE="$PRS" PR_REVIEW_TEAM_FIXTURE="$TEAMS" \ + "${SCRIPT_DIR}/find-review-requests.sh" --repo acme/widgets "$@" 2>&1 >/dev/null +} + +run_raw() { + PR_REVIEW_PR_FIXTURE="$PRS" PR_REVIEW_TEAM_FIXTURE="$TEAMS" \ + "${SCRIPT_DIR}/find-review-requests.sh" --repo acme/widgets "$@" +} + +# Returns stderr only, with the commit-author lookup forced to fail for the given +# space-separated PR numbers. The run is expected to exit non-zero. +run_stderr_commit_fail() { + local numbers="$1"; shift + PR_REVIEW_PR_FIXTURE="$PRS" PR_REVIEW_TEAM_FIXTURE="$TEAMS" \ + PR_REVIEW_COMMIT_FAIL="$numbers" \ + "${SCRIPT_DIR}/find-review-requests.sh" --repo acme/widgets "$@" 2>&1 >/dev/null \ + || true +} + +# Same as run_raw, for a caller-supplied PR fixture. +run_raw_with_prs() { + local prs="$1"; shift + PR_REVIEW_PR_FIXTURE="$prs" PR_REVIEW_TEAM_FIXTURE="$TEAMS" \ + "${SCRIPT_DIR}/find-review-requests.sh" --repo acme/widgets "$@" +} + +echo +echo "No filter" +assert_eq "returns every non-draft PR" "101,102,103,105,106" "$(run_filter)" + +echo +echo "Login terms" +assert_eq "exact login" "101" "$(run_filter --author alice)" +assert_eq "login is case-insensitive" "101" "$(run_filter --author ALICE)" +assert_eq "draft stays excluded when filtering" "101" "$(run_filter --author alice)" + +echo +echo "Display-name terms" +assert_eq "full display name" "101" "$(run_filter --author 'Alice Anderson')" +assert_eq "first name only (substring)" "103" "$(run_filter --author Carol)" +assert_eq "name is case-insensitive" "103" "$(run_filter --author 'CAROL CHEN')" +assert_eq "profile display name" "102" "$(run_filter --author 'Bob Brown')" +# PR 106's author has no profile name, so this can only match via its commits. +assert_eq "falls back to commit author name" "106" "$(run_filter --author 'Erin Evans')" +# PR 103's author DOES have a profile name; its commit author name just differs +# from it, so the escalation must not be gated on an empty profile name. +assert_eq "commit author name differing from the profile name" "103" \ + "$(run_filter --author Caroline)" + +echo +echo "Email terms" +assert_eq "commit author email" "102" "$(run_filter --author bob.brown@corp.example.com)" +assert_eq "email matches whole field only" "" "$(run_filter --author corp.example.com)" +assert_ok "partial email exits 0 rather than erroring" --author corp.example.com +# PR 105's commit author fields are all empty; escalating must not break. +assert_ok "empty commit-author fields survive escalation" --author nobody@example.com + +echo +echo "Team terms" +assert_eq "org/slug expands to members" "101,103" \ + "$(run_filter --author @acme/reviewers)" +assert_eq "bare slug uses the repo's org" "101,103" \ + "$(run_filter --author @reviewers)" +assert_eq "team from another org" "102" "$(run_filter --author @other/outsiders)" +assert_eq "team members match logins exactly, not name fragments" "" \ + "$(run_filter --author @acme/fragment)" +assert_ok "a team matching nothing exits 0 rather than erroring" --author @acme/fragment +# A team that exists but has no visible members is an unmatched term, not a +# configuration error, so it warns and still exits 0. +assert_eq "an empty team yields no PRs" "" "$(run_filter --author @acme/empty)" +assert_ok "an empty team exits 0 rather than erroring" --author @acme/empty +assert_contains "an empty team warns" "has no visible members" \ + "$(run_stderr --author @acme/empty)" +assert_contains "an empty team still reports the unmatched term" "match '@acme/empty'" \ + "$(run_stderr --author @acme/empty)" + +echo +echo "Multiple terms" +assert_eq "two logins union" "101,103" "$(run_filter --author alice --author carol)" +assert_eq "team plus a login unions" "101,102,103" \ + "$(run_filter --author @acme/reviewers --author bob_corp)" +assert_eq "overlapping terms do not duplicate a PR" "101" \ + "$(run_filter --author alice --author 'Alice Anderson')" +# 'alice' is settled by the list query; 'Caroline' can only be settled by PR 103's +# commit authors. An early match must not stop the later term from being resolved, +# or from being recorded as a hit. +assert_eq "a term matching only via commits still keeps its PR" "101,103" \ + "$(run_filter --author alice --author Caroline)" +assert_not_contains "a term matched only via commits does not warn" "match 'Caroline'" \ + "$(run_stderr --author alice --author Caroline)" + +echo +echo "Unmatched and malformed terms" +assert_eq "unmatched term yields no PRs" "" "$(run_filter --author nobody)" +if run_raw --author nobody >/dev/null 2>&1; then + PASS=$((PASS + 1)); echo " ok unmatched term still exits 0" +else + FAIL=$((FAIL + 1)); echo " FAIL unmatched term should exit 0" +fi +assert_contains "unmatched term warns" "match 'nobody'" "$(run_stderr --author nobody)" +assert_contains "filter reports how many were kept" "kept 1 of 5" \ + "$(run_stderr --author alice)" +assert_fails "unknown team exits non-zero" \ + env PR_REVIEW_PR_FIXTURE="$PRS" PR_REVIEW_TEAM_FIXTURE="$TEAMS" \ + "${SCRIPT_DIR}/find-review-requests.sh" --repo acme/widgets --author @acme/missing +assert_fails "--author with no value exits non-zero" \ + env PR_REVIEW_PR_FIXTURE="$PRS" PR_REVIEW_TEAM_FIXTURE="$TEAMS" \ + "${SCRIPT_DIR}/find-review-requests.sh" --repo acme/widgets --author +assert_contains "truncated queue warns" "raise --limit" \ + "$(run_stderr --limit 5 --author alice)" +# The fixture holds 6 rows, one of them a draft. The draft still consumed a +# --limit slot, so a limit of 6 is a truncated queue even though only 5 PRs are +# reviewable. +assert_contains "drafts count toward the limit" "raise --limit" \ + "$(run_stderr --limit 6 --author alice)" + +echo +echo "Empty review queue" +assert_eq "empty queue yields no PRs" "" \ + "$(run_raw_with_prs "$EMPTY_PRS" --author nobody 2>/dev/null | tr '\n' ',' | sed 's/,$//')" +if run_raw_with_prs "$EMPTY_PRS" --author nobody >/dev/null 2>&1; then + PASS=$((PASS + 1)); echo " ok empty queue exits 0" +else + FAIL=$((FAIL + 1)); echo " FAIL empty queue should exit 0" +fi +assert_contains "empty queue still warns about the unmatched term" "match 'nobody'" \ + "$(run_raw_with_prs "$EMPTY_PRS" --author nobody 2>&1 >/dev/null)" + +echo +echo "Commit-lookup failures" +# A failed lookup must not be indistinguishable from "this PR does not match": +# it warns per PR, prints everything it does know, then exits non-zero. +COMMIT_FAIL_OUT="$(run_stderr_commit_fail 106 --author 'Erin Evans')" +assert_contains "a failed commit lookup warns for that PR" \ + "could not read commit authors for PR #106" "$COMMIT_FAIL_OUT" +assert_contains "a failed commit lookup reports incomplete results" \ + "results are incomplete" "$COMMIT_FAIL_OUT" +assert_contains "a failed commit lookup still prints the table" "feat-a" \ + "$(run_stderr_commit_fail 106 --author alice)" +assert_fails "a failed commit lookup exits non-zero" \ + env PR_REVIEW_PR_FIXTURE="$PRS" PR_REVIEW_TEAM_FIXTURE="$TEAMS" \ + PR_REVIEW_COMMIT_FAIL=106 \ + "${SCRIPT_DIR}/find-review-requests.sh" --repo acme/widgets --author 'Erin Evans' + +echo +if [[ "$FAIL" -eq 0 ]]; then + echo "All ${PASS} assertions passed." + exit 0 +fi +echo "${FAIL} of $((PASS + FAIL)) assertions failed." +exit 1 diff --git a/skills/ess/pr-review/scripts/test_report.py b/skills/ess/ess-pr-review/scripts/test_report.py similarity index 99% rename from skills/ess/pr-review/scripts/test_report.py rename to skills/ess/ess-pr-review/scripts/test_report.py index 4243797..d173d8b 100644 --- a/skills/ess/pr-review/scripts/test_report.py +++ b/skills/ess/ess-pr-review/scripts/test_report.py @@ -1,4 +1,4 @@ -"""Tests for the pr-review linter merge/rank logic.""" +"""Tests for the ess-pr-review linter merge/rank logic.""" from __future__ import annotations diff --git a/skills/ess/ess-pr-review/scripts/test_scan_checkout.sh b/skills/ess/ess-pr-review/scripts/test_scan_checkout.sh new file mode 100644 index 0000000..e504d15 --- /dev/null +++ b/skills/ess/ess-pr-review/scripts/test_scan_checkout.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Linters must read the pinned head, not the checkout that invoked scan-pr.sh. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); } + +ROOT=$(mktemp -d) +BIN=$(mktemp -d) +OUT=$(mktemp -d) +LINKED=$(mktemp -d) +SEEN="$OUT/ruff-seen.txt" +ESLINT_SEEN="$OUT/eslint-seen.txt" +trap 'rm -rf "$ROOT" "$BIN" "$OUT" "$LINKED"' EXIT + +cat >"$BIN/ruff" <<EOF +#!/bin/bash +if [[ "\${1:-}" == "--version" ]]; then exit 0; fi +for arg in "\$@"; do + case "\$arg" in + *.py) + if [[ -f "\$arg" ]]; then + cat "\$arg" >>"$SEEN" + else + printf 'missing %s\n' "\$arg" >>"$SEEN" + fi + ;; + esac +done +printf '[]\n' +EOF +cat >"$BIN/bandit" <<'EOF' +#!/bin/bash +[[ "${1:-}" == "--version" ]] && exit 0 +printf '{"results":[]}\n' +EOF +cat >"$BIN/pylint" <<'EOF' +#!/bin/bash +[[ "${1:-}" == "--version" ]] && exit 0 +printf '[]\n' +EOF +chmod +x "$BIN/ruff" "$BIN/bandit" "$BIN/pylint" + +if command -v uv >/dev/null 2>&1; then + real_uv=$(command -v uv) + cat >"$BIN/uv" <<EOF +#!/bin/bash +if [[ "\${1:-}" == run && "\${2:-}" == ruff ]]; then exit 1; fi +if [[ "\${1:-}" == run && "\${2:-}" == bandit ]]; then exit 1; fi +if [[ "\${1:-}" == run && "\${2:-}" == pylint ]]; then exit 1; fi +exec "$real_uv" "\$@" +EOF + chmod +x "$BIN/uv" +fi + +git -C "$ROOT" init -q -b main +git -C "$ROOT" config user.email test@example.com +git -C "$ROOT" config user.name test +printf 'print("base")\n' >"$ROOT/keep.py" +git -C "$ROOT" add keep.py +git -C "$ROOT" commit -q -m base +BASE=$(git -C "$ROOT" rev-parse HEAD) +printf 'UNIQUE_HEAD_ONLY = 1\n' >"$ROOT/only_on_head.py" +mkdir -p "$ROOT/web" +printf 'export default [];\n' >"$ROOT/web/eslint.config.mjs" +printf 'export const value = 1;\n' >"$ROOT/web/app.ts" +git -C "$ROOT" add only_on_head.py web +git -C "$ROOT" commit -q -m head +HEAD_SHA=$(git -C "$ROOT" rev-parse HEAD) +git -C "$ROOT" checkout -q "$BASE" + +# Installed deps are untracked, so only the caller's checkout has them. +mkdir -p "$ROOT/node_modules/.bin" +cat >"$ROOT/node_modules/.bin/eslint" <<EOF +#!/bin/bash +pwd >>"$ESLINT_SEEN" +printf '[]\n' +EOF +chmod +x "$ROOT/node_modules/.bin/eslint" + +( + cd "$ROOT" + PATH="$BIN:$PATH" bash "$SCRIPT_DIR/scan-pr.sh" \ + --base "$BASE" --head "$HEAD_SHA" --repo acme/widgets --output "$OUT/scan" +) + +if [[ ! -f "$ROOT/only_on_head.py" ]]; then + pass "the caller checkout stays on the older commit" +else + fail "the caller checkout gained only_on_head.py" +fi +if grep -q UNIQUE_HEAD_ONLY "$SEEN"; then + pass "ruff read the file from the pinned head" +else + fail "ruff did not see UNIQUE_HEAD_ONLY: $(cat "$SEEN" 2>/dev/null || echo missing)" +fi +if [[ -s "$ESLINT_SEEN" ]] && grep -q $'^eslint\tok$' "$OUT/scan/tools.tsv"; then + pass "eslint ran from the caller's node_modules" +else + fail "eslint did not run: $(grep eslint "$OUT/scan/tools.tsv" 2>/dev/null || echo 'no status')" +fi + +# A fresh linked worktree has no node_modules, and --output is relative to it. +rm -f "$SEEN" "$ESLINT_SEEN" +git -C "$ROOT" worktree add -q --detach "$LINKED/caller" "$BASE" +( + cd "$LINKED/caller" + PATH="$BIN:$PATH" bash "$SCRIPT_DIR/scan-pr.sh" \ + --base "$BASE" --head "$HEAD_SHA" --repo acme/widgets --output relative-scan +) + +if grep -q UNIQUE_HEAD_ONLY "$SEEN" && [[ -f "$LINKED/caller/relative-scan/report.md" ]]; then + pass "a relative --output stays in the caller's directory" +else + fail "relative --output lost the file lists: $(cat "$SEEN" 2>/dev/null || echo missing)" +fi +if [[ -s "$ESLINT_SEEN" ]]; then + pass "eslint fell back to the main worktree's node_modules" +else + fail "eslint did not run from the linked worktree" +fi + +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] diff --git a/skills/ess/ess-pr-review/scripts/test_scan_disables.sh b/skills/ess/ess-pr-review/scripts/test_scan_disables.sh new file mode 100644 index 0000000..f0a6b67 --- /dev/null +++ b/skills/ess/ess-pr-review/scripts/test_scan_disables.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Filenames with spaces or characters Git quotes must stay whole paths. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); } + +ROOT=$(mktemp -d) +trap 'rm -rf "$ROOT"' EXIT +git -C "$ROOT" init -q -b main +git -C "$ROOT" config user.email test@example.com +git -C "$ROOT" config user.name test +git -C "$ROOT" commit -q --allow-empty -m base +BASE=$(git -C "$ROOT" rev-parse HEAD) + +mkdir -p "$ROOT/src" +printf 'x = 1 # noqa\n' >"$ROOT/src/my module.py" +printf 'y = 1 # noqa\n' >"$ROOT/src/quote\"file.py" +git -C "$ROOT" add -- "src/my module.py" "src/quote\"file.py" +git -C "$ROOT" commit -q -m add +HEAD_SHA=$(git -C "$ROOT" rev-parse HEAD) + +OUT=$(mktemp -d) +git -C "$ROOT" config core.quotePath true +( + cd "$ROOT" + bash "$SCRIPT_DIR/scan-disables.sh" "$OUT" "$BASE" "$HEAD_SHA" +) + +python3 - "$OUT/disables.json" <<'PY' +import json +import sys + +found = {(item["path"], item["text"]) for item in json.load(open(sys.argv[1], encoding="utf-8"))} +expected = { + ("src/my module.py", "x = 1 # noqa"), + ('src/quote"file.py', "y = 1 # noqa"), +} +missing = expected - found +if missing: + raise SystemExit(f"missing {missing}; got {found}") +PY +pass "spaced and quoted paths stay intact" + +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] diff --git a/skills/ess/ess-summarize-change-log/SKILL.md b/skills/ess/ess-summarize-change-log/SKILL.md index ed60d0e..47fc311 100644 --- a/skills/ess/ess-summarize-change-log/SKILL.md +++ b/skills/ess/ess-summarize-change-log/SKILL.md @@ -33,7 +33,7 @@ Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0. - Format: `<type>[optional scope]: <description>` — imperative mood, lowercase type. - Types: `feat`, `fix`, `docs`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`. - **Entire subject ≤ 50 characters** (type, scope, colon, space, description). -- Scope matches the primary area (`langsmith-client`, `azure-ai`, `hello-agent`). +- Scope matches the primary area (`api-client`, `cli`, `web`). ### What changed, not process @@ -41,9 +41,9 @@ Every subject must state **what changed**. Ban vague/process subjects: | Bad | Good | | --- | --- | -| `address PR #513 review comments` | `fix(langsmith-client): defer delete confirm` | -| `port docs from #510` | `docs(langsmith-client): document --deployment` | -| `follow up on review` | `fix(hello-agent): align listener id messaging` | +| `address PR #123 review comments` | `fix(cli): defer delete confirm` | +| `port docs from #120` | `docs(api-client): document --deployment` | +| `follow up on review` | `fix(web): align listener id messaging` | ## Consolidation (>5 input commits) diff --git a/skills/ess/ess-summarize-change-log/examples-good.md b/skills/ess/ess-summarize-change-log/examples-good.md index abb6622..5dd2268 100644 --- a/skills/ess/ess-summarize-change-log/examples-good.md +++ b/skills/ess/ess-summarize-change-log/examples-good.md @@ -1,5 +1,5 @@ -- **fix(langsmith-client): centralize deploy naming** — Delegate `<service>-<env>` resolution to `langsmith-client deploy docker`, add `--deployment` for explicit base names, and resolve live deployments for idempotent delete. -- **fix(azure-ai): align prod NAT gateway IPs** — Prod Azure OpenAI firewall rules were allowlisting dev EKS egress; prod and aoh-prod stacks now use the aoh-langsmith-hybrid-prod NAT IP. -- **fix(langsmith-client): defer delete confirm** — Fix `deploy_docker` docstring, defer delete confirmation until a target exists, and remove erroneous `.env` gates from deploy scripts. -- **docs(langsmith-client): document --deployment** — Clarify `DEPLOYMENT_NAME` in `push_secrets.py` and document `--deployment` in `docker-deployment.md`. -- **fix(hello-agent): align listener id messaging** — Use `resolve_deploy_base` consistently, fix deploy docs/env references, and align `LANGSMITH_LISTENER_ID` messaging with the script requirement. +- **fix(api-client): centralize deploy naming** — Delegate `<service>-<env>` resolution to `api-client deploy docker`, add `--deployment` for explicit base names, and resolve live deployments for idempotent delete. +- **fix(infra): pin prod egress to NAT gateway** — Prod firewall rules were allowlisting dev cluster egress; both prod stacks now use the prod NAT gateway IP. +- **fix(cli): defer delete confirm** — Fix `deploy_docker` docstring, defer delete confirmation until a target exists, and remove erroneous `.env` gates from deploy scripts. +- **docs(api-client): document --deployment** — Clarify `DEPLOYMENT_NAME` in `push_config.py` and document `--deployment` in `docker-deployment.md`. +- **fix(web): align listener id messaging** — Use `resolve_deploy_base` consistently, fix deploy docs/env references, and align `LISTENER_ID` messaging with the script requirement. diff --git a/skills/ess/ess-summarize-change-log/examples.md b/skills/ess/ess-summarize-change-log/examples.md index 5e8c663..b656563 100644 --- a/skills/ess/ess-summarize-change-log/examples.md +++ b/skills/ess/ess-summarize-change-log/examples.md @@ -3,21 +3,21 @@ ## Before (verbose input) ``` -* fix(langsmith-client): centralize deployment naming in deploy CLI +* fix(api-client): centralize deployment naming in deploy CLI Shell scripts were building deployment names and passing pre-suffixed --name -values, which produced double-encoded names like hello-agent-prod-dev. ... +values, which produced double-encoded names like my-service-prod-dev. ... -* fix(azure-ai): align prod NAT gateway IPs with LangSmith hybrid cluster +* fix(infra): align prod NAT gateway IPs with the prod cluster ... -* fix(langsmith-client): address PR #513 review comments +* fix(api-client): address PR #123 review comments ... -* docs(langsmith-client): port deployment naming docs from #510 +* docs(api-client): port deployment naming docs from #120 ... -* fix(langsmith-client,hello-agent): address PR #513 review comments +* fix(api-client,web): address PR #123 review comments ... ``` @@ -29,20 +29,20 @@ Subject lengths (all ≤50): | Subject | Length | | --- | --- | -| `fix(langsmith-client): centralize deploy naming` | 47 | -| `fix(azure-ai): align prod NAT gateway IPs` | 41 | -| `fix(langsmith-client): defer delete confirm` | 43 | -| `docs(langsmith-client): document --deployment` | 45 | -| `fix(hello-agent): align listener id messaging` | 45 | +| `fix(api-client): centralize deploy naming` | 41 | +| `fix(infra): pin prod egress to NAT gateway` | 42 | +| `fix(cli): defer delete confirm` | 30 | +| `docs(api-client): document --deployment` | 39 | +| `fix(web): align listener id messaging` | 37 | ## Bad vs good subjects | Bad (process/meta) | Good (outcome) | | --- | --- | -| `fix: address PR #513 review comments` | `fix(langsmith-client): defer delete confirm` | -| `docs: port deployment naming docs from #510` | `docs(langsmith-client): document --deployment` | -| `chore: follow up on review` | `fix(hello-agent): align listener id messaging` | -| `fix: cherry-pick doc improvements` | `docs(langsmith-client): document --deployment` | +| `fix: address PR #123 review comments` | `fix(cli): defer delete confirm` | +| `docs: port deployment naming docs from #120` | `docs(api-client): document --deployment` | +| `chore: follow up on review` | `fix(web): align listener id messaging` | +| `fix: cherry-pick doc improvements` | `docs(api-client): document --deployment` | ## Validate the good example diff --git a/skills/ess/ess-summarize-change-log/scripts/test_validate_summary.py b/skills/ess/ess-summarize-change-log/scripts/test_validate_summary.py index 344615a..3981b9d 100644 --- a/skills/ess/ess-summarize-change-log/scripts/test_validate_summary.py +++ b/skills/ess/ess-summarize-change-log/scripts/test_validate_summary.py @@ -73,7 +73,7 @@ def test_is_vague_subject_detects_port_from_inside_support() -> None: def test_is_vague_subject_detects_port_from_phrase() -> None: - assert is_vague_subject("docs: port deployment naming docs from #510") + assert is_vague_subject("docs: port deployment naming docs from #120") @patch("validate_summary.run_commitizen_check") diff --git a/skills/ess/mcp-hide-secrets/scripts/lib.sh b/skills/ess/mcp-hide-secrets/scripts/lib.sh deleted file mode 100755 index 89761cf..0000000 --- a/skills/ess/mcp-hide-secrets/scripts/lib.sh +++ /dev/null @@ -1,19 +0,0 @@ -# Shared paths for mcp-hide-secrets scripts. From sibling scripts: -# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# # shellcheck source=lib.sh -# source "$SCRIPT_DIR/lib.sh" - -MCP_HIDE_SECRETS_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -USERNAME="${USERNAME:-$(whoami)}" -LABEL="local.${USERNAME}.cursor-mcp-env" -GLOBAL_MCP_JSON="${GLOBAL_MCP_JSON:-$HOME/.cursor/mcp.json}" -PROJECT_MCP_JSON="${PROJECT_MCP_JSON:-}" -MCP_DATA_DIR="${MCP_DATA_DIR:-$HOME/.local/share/cursor-mcp}" -MCP_ENV="$MCP_DATA_DIR/mcp.env" -LOADER_DEST="$MCP_DATA_DIR/load-mcp-env.sh" -PLIST_DEST="$HOME/Library/LaunchAgents/${LABEL}.plist" -LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" -GUI_DOMAIN="gui/$(id -u)" -LOG_FILE="/tmp/cursor-mcp-env.log" -CHECK_INLINE_SECRETS="$MCP_HIDE_SECRETS_LIB_DIR/check-inline-secrets.py" diff --git a/skills/ess/mcp-hide-secrets/scripts/run.sh b/skills/ess/mcp-hide-secrets/scripts/run.sh deleted file mode 100755 index 5a501cb..0000000 --- a/skills/ess/mcp-hide-secrets/scripts/run.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/bash -# Entry point for /mcp-hide-secrets — run this script only; do not improvise checks in the agent. -# -# Usage: -# USERNAME=$(whoami) ./run.sh # install (migrate if needed, then LaunchAgent) -# USERNAME=$(whoami) ./run.sh migrate # ~/.cursor/mcp.json -# USERNAME=$(whoami) ./run.sh migrate .cursor/mcp.json -# USERNAME=$(whoami) ./run.sh status -# USERNAME=$(whoami) ./run.sh preflight -# ./run.sh fix-launchagents -# -# Optional: -# PROJECT_MCP_JSON=/path/to/repo/.cursor/mcp.json ./run.sh -# -# See ../SKILL.md - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=lib.sh -source "$SCRIPT_DIR/lib.sh" - -chmod +x "$SCRIPT_DIR"/*.sh "$SCRIPT_DIR/check-inline-secrets.py" 2>/dev/null || true - -if [ -z "${USERNAME:-}" ]; then - echo "ERROR: set USERNAME (your macOS username)." >&2 - echo "Example: USERNAME=\$(whoami) $0" >&2 - exit 1 -fi - -MODE="${1:-default}" -shift || true - -mcp_json_paths_need_migrate() { - local path - for path in "$@"; do - [ -f "$path" ] || continue - if python3 "$CHECK_INLINE_SECRETS" "$path" >/dev/null 2>&1; then - continue - fi - echo "$path" - done -} - -case "$MODE" in - status) - exec "$SCRIPT_DIR/status.sh" - ;; - preflight) - exec "$SCRIPT_DIR/preflight.sh" - ;; - fix-launchagents) - exec "$SCRIPT_DIR/fix-launchagents-dir.sh" - ;; - migrate) - MCP_JSON="${1:-$GLOBAL_MCP_JSON}" - "$SCRIPT_DIR/preflight.sh" - "$SCRIPT_DIR/install.sh" --migrate-only "$MCP_JSON" - if ! "$SCRIPT_DIR/fix-launchagents-dir.sh"; then - echo "" - echo "Next: run $SCRIPT_DIR/fix-launchagents-dir.sh in Terminal (sudo), then:" - echo " USERNAME=$USERNAME $SCRIPT_DIR/run.sh install" - exit 1 - fi - "$SCRIPT_DIR/install.sh" - "$SCRIPT_DIR/verify.sh" - ;; - install) - "$SCRIPT_DIR/preflight.sh" - if ! "$SCRIPT_DIR/fix-launchagents-dir.sh"; then - echo "" - echo "Next: run $SCRIPT_DIR/fix-launchagents-dir.sh in Terminal (sudo), then:" - echo " USERNAME=$USERNAME $SCRIPT_DIR/run.sh install" - exit 1 - fi - "$SCRIPT_DIR/install.sh" - "$SCRIPT_DIR/verify.sh" - ;; - default) - "$SCRIPT_DIR/preflight.sh" - - paths=("$GLOBAL_MCP_JSON") - if [ -n "$PROJECT_MCP_JSON" ] && [ -f "$PROJECT_MCP_JSON" ]; then - paths+=("$PROJECT_MCP_JSON") - fi - - while IFS= read -r dirty_path; do - [ -n "$dirty_path" ] || continue - echo "" - echo "== migrate $dirty_path ==" - "$SCRIPT_DIR/install.sh" --migrate-only "$dirty_path" - done < <(mcp_json_paths_need_migrate "${paths[@]}") - - if ! "$SCRIPT_DIR/fix-launchagents-dir.sh"; then - echo "" - echo "Secrets migrated (if any). LaunchAgent step needs sudo in Terminal:" - echo " $SCRIPT_DIR/fix-launchagents-dir.sh" - echo " USERNAME=$USERNAME $SCRIPT_DIR/run.sh install" - "$SCRIPT_DIR/status.sh" - exit 1 - fi - - echo "" - echo "== install LaunchAgent ==" - "$SCRIPT_DIR/install.sh" - "$SCRIPT_DIR/verify.sh" - "$SCRIPT_DIR/status.sh" - ;; - *) - echo "ERROR: unknown mode: $MODE" >&2 - echo "Usage: $0 [default|migrate|install|status|preflight|fix-launchagents] [mcp.json]" >&2 - exit 1 - ;; -esac diff --git a/skills/ess/pr-review/references/agent-conventions.md b/skills/ess/pr-review/references/agent-conventions.md deleted file mode 100644 index 329abbe..0000000 --- a/skills/ess/pr-review/references/agent-conventions.md +++ /dev/null @@ -1,43 +0,0 @@ -# LangChain / LangGraph conventions (framework-specific) - -Apply this only when the PR touches LangChain/LangGraph agent code. These are -conventions the linters do not enforce, so they belong to the LLM judgment pass. - -## `@tool` checklist - -For each changed `*/tools.py` (or tool definition): - -- [ ] `@tool` decorator used correctly. -- [ ] `@handle_tool_error` decorator present. -- [ ] `RunnableConfig` parameter included. -- [ ] `should_fetch` parameter for cached data (where applicable). -- [ ] ROUTING hints in the docstring (so the LLM can select the tool). -- [ ] Docstring under 1024 chars (validation-enforced). -- [ ] Citation constant defined. -- [ ] Error messages are user-friendly. - -## AGENTS.md rules worth quoting - -When the diff violates one of these, quote the exact rule in the finding: - -| Rule | Check | -| --- | --- | -| Max 5 parameters | "Max 5 parameters per function — group related params into dataclasses/config objects" | -| Tool docstring format | "Docstrings must include ROUTING hints for LLM tool selection" | -| Tool docstring length | "Max 1024 chars for @tool docstrings (validation enforced)" | -| Type hints | "Python 3.11+ with type hints on all functions" | -| DRY | "DRY principle — consolidate duplicated logic into reusable functions" | -| Early returns | "Early returns — use guard clauses to reduce nesting depth" | -| No nested ternaries | "No nested ternaries — use if/else or match/case" | -| Descriptive names | "Descriptive names — use customer_data not d" | -| Dependency workflow | "Follow the uv workflow; don't hand-edit uv.lock" | - -Note: several of these (max parameters, nesting, complexity, naming) are also -enforced mechanically by ruff and will appear in `report.md` — quote the AGENTS.md -rule only when adding context the linter finding lacks, and don't double-post. - -## Scope a rule before flagging - -- Root `AGENTS.md` applies to all files. -- A directory-specific `AGENTS.md` applies only to files in that directory (or - its children). Don't apply an unrelated directory's rules to a file outside it. diff --git a/skills/ess/pr-review/scripts/find_review_requests.sh b/skills/ess/pr-review/scripts/find_review_requests.sh deleted file mode 100755 index edc1a3e..0000000 --- a/skills/ess/pr-review/scripts/find_review_requests.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -# -# find_review_requests.sh -# Discover the current repo's open PRs where a review is requested of you -# (review-requested:@me). Use when /pr-review is invoked with no PR refs. -# -# Usage: find_review_requests.sh [--repo owner/repo] [--limit N] [--include-team] -# --repo owner/repo - Repo to query (default: current remote) -# --limit N - Max PRs to return (default: 50) -# --include-team - Also include PRs requested from a team you belong to -# (default only counts direct, user-level requests) -# -# Output: -# stdout - matching PR numbers, one per line (for the skill to loop over) -# stderr - a human-readable table (number, branch, author, title) -# -# Drafts are skipped. Exits 0 with empty stdout when nothing matches. - -set -euo pipefail - -_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=lib.sh -. "${_LIB_DIR}/lib.sh" - -command -v gh >/dev/null 2>&1 || error "gh CLI not found on PATH" - -OWNER_REPO="" -LIMIT="50" -INCLUDE_TEAM="false" - -while [[ $# -gt 0 ]]; do - case "$1" in - --repo) OWNER_REPO="${2:-}"; shift 2 ;; - --limit) LIMIT="${2:-}"; shift 2 ;; - --include-team) INCLUDE_TEAM="true"; shift ;; - -h|--help) sed -n '2,17p' "$0"; exit 0 ;; - *) error "Unknown argument: $1" ;; - esac -done - -if [[ -z "$OWNER_REPO" ]]; then - OWNER_REPO="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" \ - || error "Could not determine repo from current remote; pass --repo owner/repo" -fi - -# GitHub search distinguishes: -# user-review-requested:@me - only direct, user-level review requests -# review-requested:@me - direct requests PLUS requests to a team you are on -# Default to the narrower user-level filter; --include-team broadens it. -if [[ "$INCLUDE_TEAM" == "true" ]]; then - SEARCH="review-requested:@me" -else - SEARCH="user-review-requested:@me" -fi -info "Scanning open PRs in ${OWNER_REPO} where review is requested of you (limit ${LIMIT})" - -# Numbers + metadata as TSV (number<TAB>branch<TAB>author<TAB>title), drafts out. -PR_ROWS="$(gh pr list --repo "$OWNER_REPO" --state open --limit "$LIMIT" \ - --search "$SEARCH" \ - --json number,headRefName,author,title,isDraft \ - --jq '.[] | select(.isDraft == false) - | [.number, .headRefName, (.author.login // ""), .title] | @tsv')" \ - || error "gh pr list failed for ${OWNER_REPO}" - -if [[ -z "$PR_ROWS" ]]; then - info "No open PRs currently request your review in ${OWNER_REPO}" - exit 0 -fi - -printf 'PR\tBRANCH\tAUTHOR\tTITLE\n' >&2 - -MATCHES=() -while IFS=$'\t' read -r NUMBER BRANCH AUTHOR TITLE; do - [[ -z "$NUMBER" ]] && continue - MATCHES+=("$NUMBER") - printf '%s\t%s\t%s\t%s\n' "$NUMBER" "$BRANCH" "${AUTHOR:-unknown}" "$TITLE" >&2 -done <<< "$PR_ROWS" - -if [[ ${#MATCHES[@]} -eq 0 ]]; then - info "No open, non-draft PRs request your review in ${OWNER_REPO}" - exit 0 -fi - -info "Matched ${#MATCHES[@]} PR(s)" -printf '%s\n' "${MATCHES[@]}" diff --git a/skills/ess/pr-review/scripts/scan_disables.sh b/skills/ess/pr-review/scripts/scan_disables.sh deleted file mode 100755 index 47428b5..0000000 --- a/skills/ess/pr-review/scripts/scan_disables.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# -# scan_disables.sh <out_dir> <base> <head> -# -# Scans the ADDED lines of the diff for suppression comments the PR introduces: -# Python: # pylint: disable=... # noqa # type: ignore -# TypeScript: // @ts-ignore // @ts-expect-error -# -# Writes <out_dir>/disables.json as a JSON array of {path, line, text}, where -# line is the new-file line number. The LLM evaluates whether each suppression -# is justified (see references/pylint-disables.md) -- this script only locates -# them. Uses only git + awk (no jq / python dependency). - -set -uo pipefail - -_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=lib.sh -. "${_LIB_DIR}/lib.sh" - -OUT_DIR="${1:?usage: scan_disables.sh <out_dir> <base> <head>}" -BASE="${2:?missing base ref}" -HEAD="${3:?missing head ref}" - -# Capture the diff first so a git failure (bad refs, missing commits) is caught -# and surfaced to the caller, rather than feeding awk an empty stream and writing -# a silently incomplete disables list. -if ! DIFF="$(git diff --no-color "${BASE}...${HEAD}" -- '*.py' '*.pyi' '*.ts' '*.tsx' 2>/dev/null)"; then - error "scan_disables.sh: git diff failed for ${BASE}...${HEAD}" -fi - -printf '%s\n' "$DIFF" | awk ' -function esc(s) { - gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); gsub(/\t/, " ", s); sub(/\r$/, "", s) - return s -} -BEGIN { printf "["; first = 1 } -/^\+\+\+ / { f = $2; sub(/^b\//, "", f); next } -/^--- / { next } -/^@@ / { h = $3; sub(/^\+/, "", h); split(h, a, ","); ln = a[1] + 0; next } -{ - c = substr($0, 1, 1); rest = substr($0, 2) - if (c == "+") { - if (rest ~ /pylint:[[:space:]]*disable/ \ - || rest ~ /(#|\/\/)[[:space:]]*noqa/ \ - || rest ~ /type:[[:space:]]*ignore/ \ - || rest ~ /@ts-(ignore|expect-error)/) { - body = rest; sub(/^[[:space:]]+/, "", body) - if (first) { first = 0 } else { printf "," } - printf "\n {\"path\": \"%s\", \"line\": %d, \"text\": \"%s\"}", esc(f), ln, esc(body) - } - ln++ - } else if (c == " ") { - ln++ - } -} -END { if (first) printf "]\n"; else printf "\n]\n" } -' > "${OUT_DIR}/disables.json" diff --git a/uv.lock b/uv.lock index bf98f1f..59d1167 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ members = [ "ess-dirs", "ess-hello-jwt-auth-code", "ess-langsmith-client", + "ess-load-test", "ess-messages", "ess-outlook", "ess-passwords", @@ -181,6 +182,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, ] +[[package]] +name = "bidict" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/f2/8d2dd8276ca05e1f5157b6a0d34efb2f585f47a0fbed61e8aad04b221f0b/bidict-0.24.1.tar.gz", hash = "sha256:4dca6c17f0b01700e9f24359daa5ebabf7be022d99f4cb2a257b6af2a5076c88", size = 30818, upload-time = "2026-08-25T23:45:52.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/53/2a3c7d562271ec6b6e38e7216b3104899f8aa4180c0713cb8aaf69e29cd5/bidict-0.24.1-py3-none-any.whl", hash = "sha256:fd3eaa737917d8a14f4baa391670c433c4e3f6f5fd2cd99d4bf436437f432364", size = 36175, upload-time = "2026-08-25T23:45:51.096Z" }, +] + [[package]] name = "black" version = "25.12.0" @@ -203,6 +213,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "boto3" version = "1.42.85" @@ -231,6 +250,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/f3/c1fbaff4c509c616fd01f44357283a8992f10b3a05d932b22e602aa3a221/botocore-1.42.85-py3-none-any.whl", hash = "sha256:828b67722caeb7e240eefedee74050e803d1fa102958ead9c4009101eefd5381", size = 14839741, upload-time = "2026-04-07T19:40:40.733Z" }, ] +[[package]] +name = "brotli" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/81/1546c983678a9b4e77cb4718eaedd1b2caf24a6cdbf1eb8775938d22aff7/brotli-1.2.1.tar.gz", hash = "sha256:02b2f7b5a83485efe448a44ddf7983def05c3bb1dcf917d4f7154070260e13de", size = 7389447, upload-time = "2026-10-22T14:00:12.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/5d/dcf82117cbb506cfe0b75a00c34f2f7e850e5d933d80c0e50e2a191f1fee/brotli-1.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3975fb11f63e212b87a087e4a910187b522c6969a4989ca362d6f85f9dc36acd", size = 861663, upload-time = "2026-10-22T13:57:54.196Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/59d6445882214a47eab34e763aacc8dce18c4e7f9c5e2d9c95df9a7f7a3d/brotli-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de499e2492c8106a5e163d3cf5b6726e2797f9234cfbc6055fbb5f9d8f198f39", size = 442271, upload-time = "2026-10-22T13:57:55.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/7b/55b0c895c17dbe747b63e442d2c3a163cc838d116bf5560414a13cc284c9/brotli-1.2.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1f65a91f3576949793b8720b12a47e7ea06debc970a2da6d54ff8458053e2109", size = 1242808, upload-time = "2026-10-22T13:57:56.675Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/d8f2a81748b60733e8b03afd4bc1b70d3fb74a235b3b13dff0b2978e7f87/brotli-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:875975f91870682cca4371260522ad3f22d636e845b5c5355c52bc045c0f75c8", size = 1402417, upload-time = "2026-10-22T13:57:57.827Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b2/7addcc2c4965d0946e38a416b60af89700d54141845447f03fe4bff0293c/brotli-1.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c3f667dc60a875b12a5ecd7828062ccd97c0fb79c4adc6c8699d23d5ff2af7e1", size = 1424422, upload-time = "2026-10-22T13:57:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cc/b0b5b6b1ff4544bec3a702d5ad2e0badf22db296d16277a6ee59dabaffdc/brotli-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b9dd0173ca5e01d1eb307d3c184fe8c09bdc168ede560889b1018d216d38c61", size = 1420478, upload-time = "2026-10-22T13:58:00.484Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/1d9bdb8aa35c14ba45ce208768eb977810446b7537d9d418c2bcaae2ed20/brotli-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9307679936e86d7773eadfa444a4a4ee80f6f45230e93379875f4a6897a09a2b", size = 1485966, upload-time = "2026-10-22T13:58:01.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8e/86e64fc403888244e090a7a3d9fb35f6d6bb2219e789fc88064065464a3c/brotli-1.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb5df75d1b1c42d997adbb39eac61f2cdb48efa26304a0cb9c0b757c9bc20732", size = 1279161, upload-time = "2026-10-22T13:58:02.933Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ba/cbfa7d264d4cec16d5951071f1d04028e7024119c65a7bd909e77cafc9e6/brotli-1.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d6ef0035b6ae338a46951c144081c61fb044f4c1eb6d20acc5ba6277f666a4c0", size = 1594070, upload-time = "2026-10-22T13:58:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/7abc13404dde1665d2fe3cde3385e06d5f81aed0242c23979acc3f552afd/brotli-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:338856ae9ff63af4fc3dbbd6184a7276a6ae83df2243562e86e5ee5cf980cce7", size = 1489507, upload-time = "2026-10-22T13:58:05.591Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f6/8eacbc45a0a158897752fae9055a4d4a0cfdb1c8e15680d1815af3936da9/brotli-1.2.1-cp312-cp312-win32.whl", hash = "sha256:96e6966edcfae74ffac2522d2dd1a304450d18807a9791f5ed0a3232192c9437", size = 333017, upload-time = "2026-10-22T13:58:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/3e/82/b7f6c56393f2224ff4108cf1cc255921be1c0ae5962163edbe367c8a55bc/brotli-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:f5342b8256476554b4045db8b7bf4dc4044d0405485f220f0cd80764bc0d7faf", size = 371081, upload-time = "2026-10-22T13:58:08.082Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6d/eff775fb2de444f224aa52f79ba400ff963377436495c3f01ed52c3071dd/brotli-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:b0c32f0660bb21e810ffc1d3d04b6ea682e118b8141c6e12522a3dacadeb70ee", size = 380894, upload-time = "2026-10-22T13:58:09.157Z" }, + { url = "https://files.pythonhosted.org/packages/f5/29/62f7de3d08c53b09fe67b24b024b13ce3ec088fe0dd6940064719e61eba5/brotli-1.2.1-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:5f3da70516b1bd1b2aa183ec4ab5c02dfc6ad5c1184d47bb65ade25e710439b6", size = 416714, upload-time = "2026-10-22T14:00:06.054Z" }, + { url = "https://files.pythonhosted.org/packages/e2/66/3da6c4e928c55fc85f4cdaf0cbd41ebb1639ae70c71f9f26183818460d54/brotli-1.2.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:11aec6ea5c20cad61a33302e06baa48b0243bfa4d173bc2985dabd047e367d08", size = 399657, upload-time = "2026-10-22T14:00:07.095Z" }, + { url = "https://files.pythonhosted.org/packages/85/9d/626bb730a70507151667d950c9214003427b962a3876142c07ba52d96391/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00a50d063aae2e69c2eb28818ccf6dc3e51f2ea5b74e3c2082a47943ee23f399", size = 404727, upload-time = "2026-10-22T14:00:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/bb/34/fb0bbe56d822ee4c75b2383e518bdd579327f18f1cfedf1d779a585df01b/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37de1818ce469b3c37324c89d4d4dbb5e64461d9dc01c16f8abb2a17c465b132", size = 398927, upload-time = "2026-10-22T14:00:09.739Z" }, + { url = "https://files.pythonhosted.org/packages/4d/3f/43ea0a58dcb50c820465a0d62ccbbf6464c2e2cc4ebf582e64225dd67b4b/brotli-1.2.1-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:06a22fcbcae059c0679098232779095fc50a735a3fea02db5c79cab10b84d877", size = 457800, upload-time = "2026-10-22T14:00:10.978Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -242,25 +287,25 @@ wheels = [ [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, ] [[package]] @@ -318,6 +363,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "configargparse" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/d9/c591a80cf3335de7820c6762f8ee9298a43da5319803d319e16657b6fdf2/configargparse-1.9.0.tar.gz", hash = "sha256:48d9c22affe4ac419b4dd0691fa929d759e1f99574be41826785f2dbf3da63a5", size = 83875, upload-time = "2026-10-27T23:44:25.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/de/f7fe2171f2b650ef7dd455c4c7f6dbaf53cc942430a2e0ff6b2450e728b5/configargparse-1.9.0-py3-none-any.whl", hash = "sha256:e4ecc54d821ca97c43e6152403f40e158aff79d809c3447746fa92b136dc1e31", size = 37381, upload-time = "2026-10-27T23:44:24.04Z" }, +] + [[package]] name = "core-github" version = "0.1.0" @@ -565,6 +619,40 @@ requires-dist = [ ] provides-extras = ["agent-test"] +[[package]] +name = "ess-load-test" +version = "0.1.0" +source = { editable = "packages/python/ess-load-test" } +dependencies = [ + { name = "boto3" }, + { name = "click" }, + { name = "ess-auth" }, + { name = "ess-browser" }, + { name = "locust" }, + { name = "pulumi" }, + { name = "pulumi-aws" }, + { name = "python-dotenv" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", specifier = ">=1.35.0" }, + { name = "click", specifier = ">=8.1" }, + { name = "ess-auth", editable = "packages/python/ess-auth" }, + { name = "ess-browser", editable = "packages/python/ess-browser" }, + { name = "locust", specifier = ">=2.32,<3" }, + { name = "pulumi", specifier = ">=3.0.0" }, + { name = "pulumi-aws", specifier = ">=6.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, +] +provides-extras = ["dev"] + [[package]] name = "ess-messages" version = "0.1.0" @@ -594,6 +682,7 @@ dependencies = [ { name = "click" }, { name = "ess-dirs" }, { name = "msal" }, + { name = "python-decouple" }, { name = "python-dotenv" }, { name = "requests" }, ] @@ -608,6 +697,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1" }, { name = "ess-dirs", editable = "packages/python/ess-dirs" }, { name = "msal", specifier = ">=1.28.0" }, + { name = "python-decouple", specifier = ">=3.8" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "requests", specifier = ">=2.31.0" }, ] @@ -722,6 +812,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-cors" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" }, +] + +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -764,6 +897,53 @@ requires-dist = [ { name = "pulumi-gcp", specifier = ">=8.38.0" }, ] +[[package]] +name = "gevent" +version = "26.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/ac/dd3137ae695aef399373088c84c66398f3eac597fba542f0a22280bc21d6/gevent-26.9.0.tar.gz", hash = "sha256:4dd4703d71737a456c1c9df5cd43a82934e5b10c87549caa02495f487d1ef0b1", size = 6718097, upload-time = "2026-09-16T18:05:35.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/90/2f09ad04b52ad8888fe6a0a4a543c5445b27c78ccbde8f3104ee3ac618f8/gevent-26.9.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:979caf5b96f5806cb5b66fd2c7972f1043cc4069d1ee8b2998c42cb0b39dc445", size = 3078823, upload-time = "2026-09-16T16:16:12.412Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7f/1068c8eef85f04bb9d8490140f6adba47c0676d95e66a2d9549bdad0c22c/gevent-26.9.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0b3f0ad9dc8e2ba585e0f6498c96b78ba61b1214f5b2e17081839c93b69a58c3", size = 1861416, upload-time = "2026-09-16T17:23:55.662Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7a/c237d66fe48e0391d88f03448576ad127befc9d30ff0f9e3269272e15d1c/gevent-26.9.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:83c51ffa0ef9c960fe3b6bc0a9de8997cd04a9476ff5d4e682c0c62481ef3924", size = 1965629, upload-time = "2026-09-16T17:09:24.075Z" }, + { url = "https://files.pythonhosted.org/packages/8a/95/7bcd42a2aaceb7ad464f66fdd2be8df640c288713fd3b932f86f22e0fa86/gevent-26.9.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:ab1db9defde9ea9bd1825057fd90474148f74dcc57d104ddc62343092eaa256f", size = 1912220, upload-time = "2026-09-16T17:10:08.2Z" }, + { url = "https://files.pythonhosted.org/packages/05/89/c07717de442a898229a5e8ec6fbaf878e4d328868362c905fe14c5a72521/gevent-26.9.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c59d95daacf71dfb763824b85a89b06ca4faa74b2e7df926714d439d5a47ee26", size = 2201889, upload-time = "2026-09-16T16:39:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/df/23/fad2ba73045e4ee0dccf2e35a6fe19908309bd6176d1e5e3a18bb780e96b/gevent-26.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f91b87ca2ac3af502f7ee806c266ba6f64e4d1591e2e29456ed7cc538e5473ec", size = 1875679, upload-time = "2026-09-16T17:24:45.124Z" }, + { url = "https://files.pythonhosted.org/packages/a2/73/a4414d7e95be1287b3dbe6310331c2658395bd4ada69a19f98c3aecba4c9/gevent-26.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:810cd040eda484e8ce73d649fa994a4fc247b427023db52d4daaa10e8fd2f4aa", size = 2230243, upload-time = "2026-09-16T16:47:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6a/d5e9de5e2dbe5a58814d7a04ada307d7aca145c40484aa30894edda7cc7b/gevent-26.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:44a0d58301a333608aad5fef0c19ca8122eb7753484416f000c1f00b4b407697", size = 1725939, upload-time = "2026-09-16T16:19:41.956Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4b/525d4da671e7b6d21dceaca33fa65edc13917189b80e9b3a30318e6345bd/gevent-26.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:f9ff7c692028c577937ad00bdd1183371a086f7d6908c7c1f18f1c51ccf8caac", size = 1596884, upload-time = "2026-09-16T16:20:44.976Z" }, +] + +[[package]] +name = "geventhttpclient" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "brotli" }, + { name = "certifi" }, + { name = "gevent" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/e4/2835286913c2901f3d2246843deb8d99ba33de2b3a2ef8c4bb4c1a99f314/geventhttpclient-2.6.1.tar.gz", hash = "sha256:b15f0b0b959e75f4b8d97f3652e07db6d1050308027e1081eecddfbaa8412c92", size = 220365, upload-time = "2026-10-09T19:06:29.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/e2/9baf3b256db893bcd63ab0dc3f0288b3574e17cd908f7b1d6d23867413f0/geventhttpclient-2.6.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8018b4b32201e27215b331e9c85fed02184294cf0381a18250e8a798ae97893a", size = 110147, upload-time = "2026-10-09T19:05:33.395Z" }, + { url = "https://files.pythonhosted.org/packages/a9/28/51a6336cccb1438cfe2a40bcfdd478aae11e7033b969be896161b2f622f7/geventhttpclient-2.6.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c407752b4a71940f4a6d64d1c743a5803e8a61f916087d056c957f555177f56", size = 84870, upload-time = "2026-10-09T19:05:34.496Z" }, + { url = "https://files.pythonhosted.org/packages/58/bb/bc2b8b3adc3588d0e1dd21de774a2c1449d1e2611b98f7b7ae2f9ad4b305/geventhttpclient-2.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eb026327840fa587440d3a37d7c68e20f0957ffc87ab08a455fced82d49f64e8", size = 87042, upload-time = "2026-10-09T19:05:35.794Z" }, + { url = "https://files.pythonhosted.org/packages/42/f2/15b224b8bf7a4c7ce9f883b503b1ed11b2f3c2825f773f6743b4783beb65/geventhttpclient-2.6.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d97aab3b322b9e6688edc7812ef003c4facc0a9dc2a4fa3a69704d5cefb93a9", size = 163072, upload-time = "2026-10-09T19:05:36.96Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7f/c36e976433e0a8a51966d43e4c6394ea6e9d03a739400e57054077151e26/geventhttpclient-2.6.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e169baacd73b7071082caefa36dff2108e0a08e52f9c4852308a4ae337484f1", size = 165631, upload-time = "2026-10-09T19:05:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f1/fdea55ae3487e7fba9056b30398f419d12c7b6ed0f9023afdbc7c4713f91/geventhttpclient-2.6.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a30d7d5891c66449c8a6bf36fa5fcb49c39085fad473ecee3826a7c7772055f3", size = 171758, upload-time = "2026-10-09T19:05:39.435Z" }, + { url = "https://files.pythonhosted.org/packages/01/a3/04fe787dcf816cd8382b9f54e97b7ce5440050afaeaccd96f5f9362d5246/geventhttpclient-2.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cbfffab6b037ecbc9e6680d48d2879c933b35da7e11480f86b2efa90f78bea37", size = 162198, upload-time = "2026-10-09T19:05:40.861Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e9/ad9869036b7be5d90f857a0541b6d2ea6f04ba0d85023f67aaf169b71f39/geventhttpclient-2.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2bece70bd88df38b48fa305416b05b7a56b0934a89372f2d19c63b475adbd899", size = 158725, upload-time = "2026-10-09T19:05:42.073Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a1/98996f6bb601095a5c6c4e46f7bc990d8bedad1ee1d8b09edbf4d528f7f0/geventhttpclient-2.6.1-cp312-cp312-win32.whl", hash = "sha256:435fba852bb92d4e51317e7efb93bb61c87b48c5beef88d5fa8791bf17c64e65", size = 83707, upload-time = "2026-10-09T19:05:43.28Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/eb7030e915533e1c09fc29b0d178fdc57b0fd145dc8920802a301d005b37/geventhttpclient-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:faa9084e105c972cb156f014c58364a15bfac5aa8b83bb743b5780ae700c9836", size = 82758, upload-time = "2026-10-09T19:05:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/90b0591c9b5da52b09bc55a76266e71064863539d324013ba8a81f7786df/geventhttpclient-2.6.1-cp312-cp312-win_arm64.whl", hash = "sha256:3d86ba0d608a22d99df510087220573b719c8d461c9a7cf2dae280a685dda166", size = 83439, upload-time = "2026-10-09T19:05:45.501Z" }, +] + [[package]] name = "google-api-core" version = "2.30.0" @@ -1105,6 +1285,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jmespath" version = "1.1.0" @@ -1247,6 +1448,32 @@ name = "langsmith-network" version = "0.1.0" source = { editable = "packages/python/langsmith-network" } +[[package]] +name = "locust" +version = "2.46.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configargparse" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-login" }, + { name = "gevent" }, + { name = "geventhttpclient" }, + { name = "msgpack" }, + { name = "psutil" }, + { name = "pytest" }, + { name = "python-engineio" }, + { name = "python-socketio", extra = ["client"] }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "pyzmq" }, + { name = "requests" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/de/45f598ddfa8f00889b10ffdf1f21e0e2bac5fe868c44b6ef7e98d0603f1e/locust-2.46.6.tar.gz", hash = "sha256:424833db016bbfa3e0ee83f4966f4577a39ea9d2d311368ff1313ee88f76b968", size = 1479271, upload-time = "2026-09-17T17:46:15.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/b2/ad46212711c4b818322eeca8cb82b85c7e93712d0c6916daca513f497393/locust-2.46.6-py3-none-any.whl", hash = "sha256:a029bf6973457a8567a163a891ca2e9491e4b45a3f52749c9b011a75246483c7", size = 1498929, upload-time = "2026-09-17T17:46:13.264Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1259,6 +1486,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -1291,6 +1541,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" }, ] +[[package]] +name = "msgpack" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/52/1ec7ead2c7528bf3d1db73e886fa4df10f9e6f0eb7854a448b7090cfcef7/msgpack-1.2.4.tar.gz", hash = "sha256:475c44e12f2a5de766a8448783f149c9b33f3f49b88953c6cd739a77d93c732c", size = 197764, upload-time = "2026-10-26T17:55:48.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/fd/45f6cd13cefbb659a8e0128ea64d51b349571163fec3c9c20f99792dc42a/msgpack-1.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:90cba4996b18ec1201fcaabd01ec48ff4b9755416bcd52105ded2f89fe145c49", size = 91903, upload-time = "2026-10-26T17:54:03.097Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/169c2a0b1ffeafe68a4f13d8d96b6ed1cd8d40d8262b3a6690851da7eda1/msgpack-1.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:112933726919f4239646cca9572e2c2fa5626358c670da8440aac09403100a48", size = 90573, upload-time = "2026-10-26T17:54:04.383Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/272a384f2a001cfced1baab0a8abdd912d6281432670f4609d1aab718d7b/msgpack-1.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ca1e78106675f2aed07dc37b842ce19be70010ab22c5c5c04688f0840d48539", size = 461057, upload-time = "2026-10-26T17:54:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d9/f59bd841fc2fd2b74dc50ce85860a6a25f58e588dd48946aeaf2862cf990/msgpack-1.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4d5bbdbea8f5669b3a734d6c226772804fb120848f99cbe63a0ddee63b049c9", size = 475652, upload-time = "2026-10-26T17:54:07.414Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/afd000e1b1e28fd535dc8c7bc21ebb88923bc1964a2429fe660fb313ace3/msgpack-1.2.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d9ff7f3aaffd84010f1c501b05406a497584f5cab2f732eb9d89bf960e12f6f", size = 427082, upload-time = "2026-10-26T17:54:09.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7a/e2890b272883c496faad777324ddb70e9cc4a6c63731962a4a39a6a40f44/msgpack-1.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa10dc04c03408f31948ac1896a942141fd8d7a954b3612edac79f19663e4076", size = 451956, upload-time = "2026-10-26T17:54:10.476Z" }, + { url = "https://files.pythonhosted.org/packages/c2/46/0c1b240b8a580af3e72139c33b018025440a1a42c2579e82e39b1195ee36/msgpack-1.2.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:472a25463678f2795143a7cb42e88a1b59cc2375e9881917e69a9323bde42114", size = 423833, upload-time = "2026-10-26T17:54:12.195Z" }, + { url = "https://files.pythonhosted.org/packages/80/8e/851415b1a9264e6abf64d8a9617d856769d59e247fb9470106f133aedbae/msgpack-1.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cbb8a677da3e845713925e9a203b8a120b1e9da9989836890ba4a4a187902701", size = 470951, upload-time = "2026-10-26T17:54:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/c9/74/d75bf61aab9cb88b84f7d3a8eca8dd92e19a742b77ded300a1177e88d031/msgpack-1.2.4-cp312-cp312-win32.whl", hash = "sha256:2b0b7dff5beddc10f16ecea6b28c6b98014d1135d7015bd83adff5037f35848d", size = 68786, upload-time = "2026-10-26T17:54:15.049Z" }, + { url = "https://files.pythonhosted.org/packages/81/c5/dd0e78eb04f9a0773a810d25ef2cd8f16bb941487567d0c63f814881133c/msgpack-1.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:6e38b123681df22373d545520f54bbeddc4706d9235a1161061f12bd6648497d", size = 77272, upload-time = "2026-10-26T17:54:16.491Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/b38f63ae82777c0077daa56c4b09af39d29dcb8927ab2ff59268094069c5/msgpack-1.2.4-cp312-cp312-win_arm64.whl", hash = "sha256:6795c588b210b2f13d62c8fcc23bf779b509b839ec5c197a7ab4f5191459e126", size = 71884, upload-time = "2026-10-26T17:54:17.995Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1553,6 +1822,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "pulumi" version = "3.224.0" @@ -1939,6 +2224,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-engineio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/91/b565807fa922620ed575b412bf978d032e4c5d79558b400fdbfe1008c95a/python_engineio-4.15.1.tar.gz", hash = "sha256:1a412e798a90c64fabe7b20798939ae6d4a03f34da8a33d5a21a25a8bbbc3612", size = 93104, upload-time = "2026-10-28T23:53:30.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/5c/552f1dce2443dc33ce6b2ee7f67599ba5c83ef5582e2235a9878ac239049/python_engineio-4.15.1-py3-none-any.whl", hash = "sha256:745cb3c07701c719a2906439a00b67e44ef042904036568530a2c3a8cf18c89a", size = 60775, upload-time = "2026-10-28T23:53:29.304Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/83/1c6865ac1fc15dc1315c34e57a0697045deb342ca450c96e499df9522336/python_socketio-5.18.0.tar.gz", hash = "sha256:81ec0fbc34b82009272834145a8fab82ccc36eb057eb2c3798dfe253fa47ed72", size = 131736, upload-time = "2026-11-01T20:39:06.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/bc/d9028117da0c952e61a0dda8f3deb028bcb50563540b5f13d408b58628c3/python_socketio-5.18.0-py3-none-any.whl", hash = "sha256:b443bce375a9c6a1071b298ef85ddf11bcee102b3823d587315635a87db3a14f", size = 82977, upload-time = "2026-11-01T20:39:04.721Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "requests" }, + { name = "websocket-client" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -1962,6 +2278,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1980,6 +2306,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, ] +[[package]] +name = "pyzmq" +version = "27.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -2079,6 +2427,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, ] +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -2200,6 +2560,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, +] + [[package]] name = "websockets" version = "16.0" @@ -2218,6 +2587,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + [[package]] name = "wxc-sdk" version = "1.34.0" @@ -2311,6 +2704,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] +[[package]] +name = "zope-event" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/ff/a1f0021a26033da0df223fd05a7455d6d2881b67daf2c6dc897b4fe0a427/zope_interface-8.7.tar.gz", hash = "sha256:0b47b62e8d0d99b24bcdd32f4f2120425e5019c3bee2ad69a0e1d75737487a96", size = 266343, upload-time = "2026-10-15T07:25:14.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/6f/4a4c37a69f30761b36ba8a3b18789c52e9dd166ceaec4c3f49a862947e74/zope_interface-8.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f70a3af6efb813b8d406a449a8afc800ef8e9e32a62d6d52e37e8cb10674b70f", size = 219048, upload-time = "2026-10-15T07:23:57.986Z" }, + { url = "https://files.pythonhosted.org/packages/cc/40/8fe168cff93670859815e78c6fc4c2e47f11b8e8277cf26a69363dcd5fdd/zope_interface-8.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85c30b18b8fd75ccd1b8ad202e9130ca6f8997a574ee2a7d1619e4138d3acb0a", size = 219127, upload-time = "2026-10-15T07:23:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/54/80/f1ddbfce94864624727c1c34e863c6108b35d9b7cc8407a0b961a99e4f26/zope_interface-8.7-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a52c56e7a53d884506b785248191cc50f1c69161aec93f7e6e79feddb1d06b7a", size = 278695, upload-time = "2026-10-15T07:24:01.849Z" }, + { url = "https://files.pythonhosted.org/packages/54/af/0eddc2dd0fcfa3da3a6256c4f58278729076c77296b6f00567b03718026d/zope_interface-8.7-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:90aef6e0a9924af18f60528895f2fc50cb634191939d65b10a96d9ced05030b5", size = 284199, upload-time = "2026-10-15T07:24:03.745Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0f/a25f7e0866e65db2a756ee7e444568796ddbf0ffb97d950a268835324228/zope_interface-8.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:383c04293dbcfee8ae8d24f85592291207d5bb6a703af437343e44ddb94fb68c", size = 283694, upload-time = "2026-10-15T07:24:05.334Z" }, + { url = "https://files.pythonhosted.org/packages/c2/54/5311f7d2605c3693b1c729c2c3b171c60b11a6126a5f41dc44047e7493fb/zope_interface-8.7-cp312-cp312-win_amd64.whl", hash = "sha256:68acf0f25707f9c6277552a3d10114405235385ea1f66bffc89612e0b84f6edd", size = 220958, upload-time = "2026-10-15T07:24:06.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fa/1809f8e709024046298bc8655e2291d5722a549d4e60e741fa8d34dcae01/zope_interface-8.7-cp312-cp312-win_arm64.whl", hash = "sha256:b5045f223dcfe8792ad78df2b9ce06797988df02912e832e3ee564af7c3ca9ca", size = 220864, upload-time = "2026-10-15T07:24:08.572Z" }, +] + [[package]] name = "zstandard" version = "0.25.0" From 59c20b9fbeb02053202d3ce3fd9a79afc5d4ca85 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 13:11:27 -0500 Subject: [PATCH 08/20] feat(ess-langsmith-client): keys create --dry-run keys create now confirms before creating a key, offers --dry-run, and takes --yes, which --format json and non-interactive runs require. ess-auth comments now state the general failure mode instead of a specific incident. --- .../python/ess-auth/src/ess_auth/cooldown.py | 6 +- .../ess-auth/src/ess_auth/rate_limit.py | 7 +- .../src/ess_auth/test_token_helper.py | 11 +-- .../ess-langsmith-client/docs/api-keys.md | 15 +++- .../src/ess_langsmith_client/tools/keys.py | 67 ++++++++++++++--- .../ess_langsmith_client/tools/test_keys.py | 75 +++++++++++++++++++ 6 files changed, 158 insertions(+), 23 deletions(-) diff --git a/packages/python/ess-auth/src/ess_auth/cooldown.py b/packages/python/ess-auth/src/ess_auth/cooldown.py index aa3d308..283adf7 100644 --- a/packages/python/ess-auth/src/ess_auth/cooldown.py +++ b/packages/python/ess-auth/src/ess_auth/cooldown.py @@ -8,9 +8,9 @@ That is a feedback loop rather than mere noise. Token endpoints commonly rate-limit per source IP, and a whole Kubernetes cluster egresses through a single NAT address, so retries escalate an intermittent denial into a -sustained one. A production deployment that hit this measured roughly ten -times its normal token-request rate, with more than twice as many denials as -successes over ten minutes, escalating to a total block. +sustained one: the request rate climbs far above normal, denials come to +outnumber successes, and the endpoint can end up blocking the address +outright. This is a circuit breaker, not a retry policy --------------------------------------------- diff --git a/packages/python/ess-auth/src/ess_auth/rate_limit.py b/packages/python/ess-auth/src/ess_auth/rate_limit.py index 754d9da..30b75fc 100644 --- a/packages/python/ess-auth/src/ess_auth/rate_limit.py +++ b/packages/python/ess-auth/src/ess_auth/rate_limit.py @@ -35,10 +35,9 @@ _REMAINING_HEADERS = ("x-rate-limit-remaining", "x-ratelimit-remaining") _RESET_HEADERS = ("x-rate-limit-reset", "x-ratelimit-reset") -# Headers worth keeping when a request fails. Identity-platform support will -# ask for a request or reference ID before it looks at a denial; without one, -# an escalation turns into days of exchanging evidence. Credentials and -# cookies are deliberately absent. +# Headers worth keeping when a request fails. Identity-platform support asks +# for a request or reference ID before it looks at a denial, and without one a +# denial is hard to trace. Credentials and cookies are deliberately absent. DIAGNOSTIC_HEADERS: tuple[str, ...] = ( "x-okta-request-id", "x-request-id", diff --git a/packages/python/ess-auth/src/ess_auth/test_token_helper.py b/packages/python/ess-auth/src/ess_auth/test_token_helper.py index e51f64f..108312d 100644 --- a/packages/python/ess-auth/src/ess_auth/test_token_helper.py +++ b/packages/python/ess-auth/src/ess_auth/test_token_helper.py @@ -1,9 +1,8 @@ """Tests for the sync token helper's cooldown and scope behaviour. -The cooldown exists because of an observed production incident: a denied -token request was not cached, so every subsequent caller retried immediately -against an endpoint that rate-limits per source IP, turning an intermittent -denial into a sustained one. +The cooldown exists because a denied token request is not cached: without it, +every subsequent caller retries immediately against an endpoint that +rate-limits per source IP, turning an intermittent denial into a sustained one. """ from __future__ import annotations @@ -48,7 +47,9 @@ def handler(request: httpx.Request) -> httpx.Response: def _helper(**kwargs: Any) -> TokenHelper: - return TokenHelper("cid", "sec", token_url="https://idp.example.com/token", **kwargs) + return TokenHelper( + "cid", "sec", token_url="https://idp.example.com/token", **kwargs + ) class TestCooldown: diff --git a/packages/python/ess-langsmith-client/docs/api-keys.md b/packages/python/ess-langsmith-client/docs/api-keys.md index b061a90..639ae0b 100644 --- a/packages/python/ess-langsmith-client/docs/api-keys.md +++ b/packages/python/ess-langsmith-client/docs/api-keys.md @@ -34,12 +34,25 @@ The table shows description, short key, age in days, expiry (with an `EXPIRED` m Create a new service API key. ```bash -uv run langsmith-client keys create "my-service-key" --format json +# Create a key after confirming at the prompt +uv run langsmith-client keys create "my-service-key" + +# Show the key that would be created, then stop +uv run langsmith-client keys create "my-service-key" --dry-run + +# Create without a prompt and print JSON, for scripts +uv run langsmith-client keys create "my-service-key" --format json --yes ``` - Argument: `DESCRIPTION` — the human-readable name shown in the LangSmith UI. - The **full key value is displayed once, at creation time only.** Copy it immediately. +| Option | Description | +| --- | --- | +| `--format` | `table` (default) or `json`. | +| `--dry-run` | Show the key that would be created, then exit without creating it. Wins over `--yes`. | +| `--yes`, `-y` | Skip the confirmation prompt. Required with `--format json` or when there is no terminal to prompt on. | + ## `keys delete` Delete one or more keys by exact description. diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py index 6ebf04d..9117f93 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py @@ -10,6 +10,7 @@ langsmith-client keys list --expired langsmith-client keys list --older-than 90 langsmith-client keys create "LangSmith Deployment: my-app" + langsmith-client keys create "LangSmith Deployment: my-app" --dry-run langsmith-client keys delete "my-old-key" "another-old-key" langsmith-client keys delete "duplicated-name" --all langsmith-client keys delete "my-old-key" --dry-run @@ -224,15 +225,22 @@ def _is_interactive() -> bool: return sys.stdin.isatty() -def _confirm_or_abort(key_count: int, *, skip: bool) -> None: - """Require explicit consent before deleting keys.""" +def _confirm_or_abort( + question: str, *, action: str, skip: bool, as_json: bool = False +) -> None: + """Require explicit consent before writing keys.""" if skip: return - # Without a terminal there is no one to answer the prompt, so make the - # caller opt in rather than reading an empty stdin as "no". + # JSON output is for scripts, and without a terminal there is no one to + # answer the prompt, so make the caller opt in rather than reading an + # empty stdin as "no". + if as_json: + raise click.UsageError( + f"--format json requires --yes, which confirms the {action}." + ) if not _is_interactive(): - raise click.UsageError("Not running interactively; pass --yes to delete.") - click.confirm(f"Delete {key_count} key(s)?", abort=True, err=True) + raise click.UsageError(f"Not running interactively; pass --yes to {action}.") + click.confirm(question, abort=True, err=True) def _common_options(func): @@ -312,25 +320,64 @@ def list_keys( default="table", help="Output format (default: table)", ) -def create( +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Show the key that would be created without creating it.", +) +@click.option( + "--yes", "-y", is_flag=True, default=False, help="Skip the confirmation prompt." +) +def create( # noqa: PLR0913 -- Click maps one parameter per option api_key: str | None, workspace_id: str | None, description: str, output_format: str, + dry_run: bool, + yes: bool, ): """Create a new service API key. The full key value is shown only once -- copy it immediately. + The command asks for confirmation before creating the key. Pass --yes to + skip the prompt, which is required with --format json or when not running + in a terminal. Pass --dry-run to show the key and stop; it wins over --yes. + \b Examples: langsmith-client keys create "LangSmith Deployment: hello-world-graph" - langsmith-client keys create "my-service-key" --format json + langsmith-client keys create "my-service-key" --dry-run + langsmith-client keys create "my-service-key" --format json --yes """ try: client = APIKeyClient(api_key, workspace_id) + except ValueError as e: + raise click.ClickException(str(e)) from e + + target = f"'{description}'" + if client.headers.get("X-Tenant-Id"): + target += f" in workspace {client.headers['X-Tenant-Id']}" + + if dry_run: + if output_format == "json": + plan = {"dry_run": True, "description": description} + click.echo(json.dumps(plan, indent=2)) + else: + click.echo(f"Dry run: would create key {target}; nothing changed.") + return + + _confirm_or_abort( + f"Create key {target}?", + action="create", + skip=yes, + as_json=output_format == "json", + ) + + try: result = client.create_key(description) - except (ValueError, RuntimeError) as e: + except RuntimeError as e: raise click.ClickException(str(e)) from e if output_format == "json": @@ -434,7 +481,7 @@ def delete( # noqa: PLR0913 -- Click maps one parameter per option click.echo(f"Dry run: {len(targets)} key(s) would be deleted; nothing changed.") return - _confirm_or_abort(len(targets), skip=yes) + _confirm_or_abort(f"Delete {len(targets)} key(s)?", action="delete", skip=yes) deleted = 0 failed = 0 diff --git a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py index 1ec4394..c8866ab 100644 --- a/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py +++ b/packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/test_keys.py @@ -272,3 +272,78 @@ def test_non_interactive_without_yes_refuses(self, monkeypatch): assert result.exit_code != 0 assert "--yes" in result.output assert deleted_ids == [] + + +class TestCreate: + def _run(self, monkeypatch, args, created, user_input=None): + def _record_create(self, description): + created.append(description) + return {"id": "id-new", "description": description, "key": "fake-value"} + + monkeypatch.setattr(keys.APIKeyClient, "create_key", _record_create) + return CliRunner().invoke( + cli, + ["create", "--api-key", "test-key", "new-key", *args], + input=user_input, + ) + + def test_dry_run_creates_nothing(self, monkeypatch): + created: list[str] = [] + result = self._run( + monkeypatch, ["--dry-run", "--workspace-id", "ws-1"], created + ) + assert result.exit_code == 0 + assert "would create key 'new-key' in workspace ws-1" in result.output + assert created == [] + + def test_dry_run_wins_over_yes(self, monkeypatch): + created: list[str] = [] + result = self._run(monkeypatch, ["--dry-run", "--yes"], created) + assert result.exit_code == 0 + assert created == [] + + def test_dry_run_json_needs_no_yes(self, monkeypatch): + created: list[str] = [] + result = self._run(monkeypatch, ["--dry-run", "--format", "json"], created) + assert result.exit_code == 0 + assert json.loads(result.output) == {"dry_run": True, "description": "new-key"} + assert created == [] + + def test_yes_skips_the_prompt(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + created: list[str] = [] + result = self._run(monkeypatch, ["--yes"], created) + assert result.exit_code == 0 + assert "Create key" not in result.output + assert created == ["new-key"] + + def test_prompt_goes_to_stderr(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + created: list[str] = [] + result = self._run(monkeypatch, [], created, user_input="y\n") + assert result.exit_code == 0 + assert "Create key 'new-key'?" in result.stderr + assert "Create key 'new-key'?" not in result.stdout + assert created == ["new-key"] + + def test_declining_the_prompt_creates_nothing(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + created: list[str] = [] + result = self._run(monkeypatch, [], created, user_input="n\n") + assert result.exit_code != 0 + assert created == [] + + def test_non_interactive_without_yes_refuses(self, monkeypatch): + created: list[str] = [] + result = self._run(monkeypatch, [], created) + assert result.exit_code != 0 + assert "pass --yes to create" in result.output + assert created == [] + + def test_json_without_yes_refuses(self, monkeypatch): + monkeypatch.setattr(keys, "_is_interactive", lambda: True) + created: list[str] = [] + result = self._run(monkeypatch, ["--format", "json"], created) + assert result.exit_code != 0 + assert "--format json requires --yes" in result.output + assert created == [] From 69f3e171115eb53b31b4d3ebd75fa02d25724248 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 13:17:41 -0500 Subject: [PATCH 09/20] build: bump ruff-pre-commit to v0.15.6 v0.8.6 formats asserts differently from v0.15.6, so files synced from a repo on the newer version were reformatted on push. The ruff hook id is now ruff-check. --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30bc4d1..248272b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,9 @@ repos: # Ruff for Python linting and formatting - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.6 + rev: v0.15.6 hooks: - - id: ruff + - id: ruff-check args: [--fix, --exit-non-zero-on-fix] exclude: ^.*/migrations/ stages: [pre-push] From 394b2858379e73c3e4a5f00a491fa2da9f98311a Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 13:17:42 -0500 Subject: [PATCH 10/20] style(ess): keep lint waivers in the files Long strings are wrapped, and the remaining ruff waivers are noqa comments in the files rather than per-file ignores in a root config, so the package lints the same wherever it is copied. --- .../ess-load-test/src/ess_load_test/cli/_helpers.py | 10 ++++++++-- .../ess-load-test/src/ess_load_test/cli/build_cli.py | 12 +++++++++--- .../src/ess_load_test/cluster/runner.py | 4 ++++ .../src/ess_load_test/infra/instances.py | 2 +- skills/ess/ess-my-groups/scripts/test_my_groups.py | 3 ++- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py b/packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py index f722af4..ec2cc38 100644 --- a/packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py +++ b/packages/python/ess-load-test/src/ess_load_test/cli/_helpers.py @@ -88,7 +88,10 @@ def decorator(func): func = click.option( "--browser-auth", is_flag=True, - help="Authenticate via system Chrome SSO instead of OAuth client credentials.", + help=( + "Authenticate via system Chrome SSO instead of OAuth client " + "credentials." + ), )(func) func = click.option( "--headed", @@ -145,7 +148,10 @@ def decorator(func): "--fetch-token/--no-fetch-token", default=True, show_default=True, - help="Fetch OAuth token when credentials are set and --auth-header is omitted.", + help=( + "Fetch OAuth token when credentials are set and --auth-header " + "is omitted." + ), )(func) func = click.option( "--stack", diff --git a/packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py b/packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py index cc04dac..b834348 100644 --- a/packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py +++ b/packages/python/ess-load-test/src/ess_load_test/cli/build_cli.py @@ -38,7 +38,9 @@ ) -def build_cli(app_config: AppConfig) -> click.Command: +# One closure per command, all sharing app_config, so the size and branch +# count grow with the command list rather than with any one command. +def build_cli(app_config: AppConfig) -> click.Command: # noqa: C901, PLR0915 """Build a Click command group for load-test operations.""" @click.group() @@ -243,7 +245,10 @@ def ui_check_port_command(local_port: int, expect: str) -> None: help="AWS region.", ) def ui_reset_cluster_command(stack: str, profile: str, region: str) -> None: - """Stop stale Locust processes on all EC2 instances (ports 5557–5558 and 8089).""" + """Stop stale Locust processes on all EC2 instances. + + Frees the master ports 5557–5558 and the web UI port 8089. + """ try: reset_cluster_locust( stack=stack, @@ -365,7 +370,8 @@ def fetch_token_command( # noqa: PLR0913 click.echo(header) if not write_env_file and not print_header: click.echo( - "Auth header fetched. Use --write-env-file or --print-header to export it." + "Auth header fetched. Use --write-env-file or --print-header " + "to export it." ) @click.command("run") diff --git a/packages/python/ess-load-test/src/ess_load_test/cluster/runner.py b/packages/python/ess-load-test/src/ess_load_test/cluster/runner.py index 6e68ba8..3f471be 100644 --- a/packages/python/ess-load-test/src/ess_load_test/cluster/runner.py +++ b/packages/python/ess-load-test/src/ess_load_test/cluster/runner.py @@ -1,5 +1,9 @@ """SSM orchestration for distributed Locust test runs.""" +# The remote shell scripts below are f-string templates, and a noqa comment +# inside one would become part of the script, so line length is waived here. +# ruff: noqa: E501 + import base64 import logging import os diff --git a/packages/python/ess-load-test/src/ess_load_test/infra/instances.py b/packages/python/ess-load-test/src/ess_load_test/infra/instances.py index 4ca111b..cba257c 100644 --- a/packages/python/ess-load-test/src/ess_load_test/infra/instances.py +++ b/packages/python/ess-load-test/src/ess_load_test/infra/instances.py @@ -128,7 +128,7 @@ def create_instance_profile( return profile -def create_instances( +def create_instances( # noqa: PLR0913 -- options after * are keyword-only resource_prefix: str, config: LoadTestInfraConfig, placement: NetworkPlacement, diff --git a/skills/ess/ess-my-groups/scripts/test_my_groups.py b/skills/ess/ess-my-groups/scripts/test_my_groups.py index 3a6de8c..04f2d5f 100644 --- a/skills/ess/ess-my-groups/scripts/test_my_groups.py +++ b/skills/ess/ess-my-groups/scripts/test_my_groups.py @@ -1023,7 +1023,8 @@ def test_hand_edited_prefixed_email_resolves_bare_and_warns(config: Path) -> Non assert _keys(_resolve("a")) == ["new@example.com"] _, warnings = my_groups.validate(my_groups.load_document(config)) assert warnings == [ - "Group 'a': no user has the email in 'user:new@example.com'; store the bare address." + "Group 'a': no user has the email in 'user:new@example.com'; " + "store the bare address." ] From 4964cbc7f944df2ec53f87ea54355aeb2433dfe4 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 13:30:16 -0500 Subject: [PATCH 11/20] fix(essentials-sync): read secretlint report file The JSON report embeds each file's source, and secretlint exited before a report over 64 KiB had drained from the stdout pipe on macOS. The cut-off JSON failed to parse and was read as a clean scan. The report is now written with --output and read from a file, and an unparseable report is an error. --- .../src/scanners/secretlint.ts | 89 ++++++++++++------- .../essentials-sync/tests/scanners.test.ts | 17 ++++ 2 files changed, 72 insertions(+), 34 deletions(-) diff --git a/tools/typescript/essentials-sync/src/scanners/secretlint.ts b/tools/typescript/essentials-sync/src/scanners/secretlint.ts index 49aed6b..d707915 100644 --- a/tools/typescript/essentials-sync/src/scanners/secretlint.ts +++ b/tools/typescript/essentials-sync/src/scanners/secretlint.ts @@ -1,4 +1,5 @@ import { promises as fs } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { execa } from "execa"; import type { Finding } from "../types.js"; @@ -50,41 +51,60 @@ async function runSecretlint( cwd: string, files: string[], ): Promise<Finding[]> { - let result; + // The JSON report embeds every file's source, and secretlint exits before a + // large report has drained from a stdout pipe on macOS. It writes --output + // synchronously, so the report is read from a file instead. + const reportDir = await fs.mkdtemp(path.join(os.tmpdir(), "essentials-sync-secretlint-")); + const reportPath = path.join(reportDir, "report.json"); try { - result = await execa( - "secretlint", - ["--secretlintrc", configPath, "--format", "json", "--maskSecrets", ...files], - { - reject: false, - cwd, - preferLocal: true, - localDir: packageRoot, - }, - ); - } catch (error) { - if (isMissingBinaryError(error)) { - throw new Error( - "secretlint not installed in this package. Run `npm install` first.", + let result; + try { + result = await execa( + "secretlint", + [ + "--secretlintrc", + configPath, + "--format", + "json", + "--maskSecrets", + "--output", + reportPath, + ...files, + ], + { + reject: false, + cwd, + preferLocal: true, + localDir: packageRoot, + }, ); + } catch (error) { + if (isMissingBinaryError(error)) { + throw new Error( + "secretlint not installed in this package. Run `npm install` first.", + ); + } + throw error; } - throw error; - } - // 0 is clean and 1 is findings. Anything else means secretlint never - // scanned, which must not read as a clean result. - if (result.exitCode !== 0 && result.exitCode !== 1) { - const reason = (result.stderr || result.stdout || "").trim().split("\n")[0]; - throw new Error(`secretlint exited ${result.exitCode}: ${reason}`); - } + // With --output, findings still exit 0. Anything else means secretlint + // never scanned, which must not read as a clean result. + if (result.exitCode !== 0) { + const reason = (result.stderr || result.stdout || "").trim().split("\n")[0]; + throw new Error(`secretlint exited ${result.exitCode}: ${reason}`); + } - // secretlint reports real paths, which differ from cwd under a symlinked - // directory such as macOS /tmp. - const realCwd = await fs.realpath(cwd); - return parseSecretlintOutput(result.stdout ?? "").map((finding) => ({ - ...finding, - file: path.join(cwd, path.relative(realCwd, finding.file)), - })); + // secretlint reports real paths, which differ from cwd under a symlinked + // directory such as macOS /tmp. + const realCwd = await fs.realpath(cwd); + const report = await fs.readFile(reportPath, "utf8"); + return parseSecretlintOutput(report).map((finding) => ({ + ...finding, + file: path.join(cwd, path.relative(realCwd, finding.file)), + })); + } finally { + await fs.rm(reportDir, { recursive: true, force: true }); + } } function isMissingBinaryError(error: unknown): boolean { @@ -95,15 +115,16 @@ function isMissingBinaryError(error: unknown): boolean { return false; } -function parseSecretlintOutput(stdout: string): Finding[] { - const trimmed = stdout.trim(); +function parseSecretlintOutput(report: string): Finding[] { + const trimmed = report.trim(); if (!trimmed) return []; let parsed: SecretlintJsonResult[]; try { parsed = JSON.parse(trimmed) as SecretlintJsonResult[]; - } catch { - return []; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`secretlint report is not valid JSON: ${reason}`); } const findings: Finding[] = []; for (const fileResult of parsed) { diff --git a/tools/typescript/essentials-sync/tests/scanners.test.ts b/tools/typescript/essentials-sync/tests/scanners.test.ts index 26654d2..fd0dca6 100644 --- a/tools/typescript/essentials-sync/tests/scanners.test.ts +++ b/tools/typescript/essentials-sync/tests/scanners.test.ts @@ -86,6 +86,23 @@ describe("secretlint scanner", () => { await fs.rm(dir, { recursive: true, force: true }); } }); + + // The JSON report embeds each file's source. Read from a pipe, it was cut + // off at the 64 KiB pipe buffer on macOS, and the unparseable rest was + // taken for a clean scan. + it("flags a private key when the report is larger than a pipe buffer", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "secretlint-test-")); + try { + await fs.writeFile(path.join(dir, "a-notes.md"), "plain text\n".repeat(20_000), "utf8"); + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + await fs.writeFile(path.join(dir, "key.pem"), pem, "utf8"); + const findings = await scanWithSecretlint(dir); + expect(findings.map((f) => f.term)).toEqual(["@secretlint/secretlint-rule-privatekey"]); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); }); async function emailFindings(line: string): Promise<number> { From 4f2005a05f518c14d1689931cd3054db36211616 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 13:31:13 -0500 Subject: [PATCH 12/20] test(ess-browser): mark fake URL credentials secretlint's basic-auth rule flagged the user:password URL that the _safe_origin test strips. The credentials are fake, so the line carries a secretlint-disable-next-line directive. --- packages/python/ess-browser/src/ess_browser/test_session.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/python/ess-browser/src/ess_browser/test_session.py b/packages/python/ess-browser/src/ess_browser/test_session.py index f444209..eff0ff9 100644 --- a/packages/python/ess-browser/src/ess_browser/test_session.py +++ b/packages/python/ess-browser/src/ess_browser/test_session.py @@ -290,6 +290,8 @@ def test_strips_query_and_path(self): ) def test_strips_userinfo(self): + # The credentials are fake; stripping them is what this test checks. + # secretlint-disable-next-line @secretlint/secretlint-rule-basicauth assert _safe_origin("https://alice:x@host.example.com/p") == ( "https://host.example.com" ) From 331c9defd720caf7b83b2a0d686d32fff6cab680 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 16:38:26 -0500 Subject: [PATCH 13/20] feat(essentials-sync): widen the built-in wordlist Internal team, platform, and tool names that a source repo kept in its own override file are now built in, so a source repo needs no override file to keep them out of synced items. --- .../essentials-sync/src/jargon-list.ts | 40 +++++++++++++++++++ .../essentials-sync/tests/scanners.test.ts | 10 +++++ 2 files changed, 50 insertions(+) diff --git a/tools/typescript/essentials-sync/src/jargon-list.ts b/tools/typescript/essentials-sync/src/jargon-list.ts index 8d07f6b..3e50dfc 100644 --- a/tools/typescript/essentials-sync/src/jargon-list.ts +++ b/tools/typescript/essentials-sync/src/jargon-list.ts @@ -41,6 +41,46 @@ export const DEFAULT_JARGON_PATTERNS: readonly JargonPattern[] = [ regex: wordBoundary("honeycomb"), message: "References the internal 'honeycomb' repo name.", }, + { + term: "ceto", + regex: wordBoundary("ceto"), + message: "References the internal 'ceto' org.", + }, + ...["sales-ai", "sales_ai"].map((term) => ({ + term, + regex: wordBoundary(term), + message: "References the internal 'sales-ai' team.", + })), + ...["agentic-ops-hub", "agentic ops hub", "aoh"].map((term) => ({ + term, + regex: wordBoundary(term), + message: "References the internal Agentic Ops Hub platform.", + })), + { + term: "edag", + regex: wordBoundary("edag"), + message: "References the internal 'edag' service.", + }, + { + term: "sspo", + regex: wordBoundary("sspo"), + message: "References the internal 'sspo' workspace prefix.", + }, + { + term: "runon", + regex: wordBoundary("runon"), + message: "References the internal RunOn platform.", + }, + { + term: "npi", + regex: wordBoundary("npi"), + message: "References the internal 'npi' program.", + }, + { + term: "aws-sso", + regex: wordBoundary("aws-sso"), + message: "References the internal 'aws-sso' login tool.", + }, ]; export interface JargonConfig { diff --git a/tools/typescript/essentials-sync/tests/scanners.test.ts b/tools/typescript/essentials-sync/tests/scanners.test.ts index fd0dca6..dbc100f 100644 --- a/tools/typescript/essentials-sync/tests/scanners.test.ts +++ b/tools/typescript/essentials-sync/tests/scanners.test.ts @@ -4,6 +4,7 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { findJargonTerms, loadJargonConfig } from "../src/jargon-list.js"; import { scanJargon } from "../src/scanners/jargon.js"; import { scanPii } from "../src/scanners/pii.js"; import { scanWithSecretlint } from "../src/scanners/secretlint.js"; @@ -25,6 +26,15 @@ describe("jargon scanner", () => { expect(findings.some((f) => f.term === "*.cisco.com")).toBe(true); }); + it.each([ + ["Deploy it to the AOH dev account.", "aoh"], + ["Owned by the Sales_AI team.", "sales_ai"], + ["Run aws-sso first.", "aws-sso"], + ["Seed the NPI scenarios.", "npi"], + ])("flags internal names without an override file: %s", (line, term) => { + expect(findJargonTerms(line, loadJargonConfig()).map((p) => p.term)).toContain(term); + }); + it("respects custom severity", async () => { const findings = await scanJargon(dirtyFixture, { severity: "warning" }); expect(findings.every((f) => f.severity === "warning")).toBe(true); From 240915792bd315e74ea5dbb5763a479c4a874fc1 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 16:38:27 -0500 Subject: [PATCH 14/20] fix(essentials-sync): never copy an item's LICENSE status already skipped each item's LICENSE, since the open-source copy carries the license text and a source repo may not. Sync copied it anyway, so a sync from this repo would add LICENSE files to the source. Both now share REPO_OWNED_ITEM_FILES. --- tools/typescript/essentials-sync/src/copy.ts | 5 ++++- tools/typescript/essentials-sync/src/ess-items.ts | 5 +++++ .../essentials-sync/src/status-command.ts | 8 ++------ .../typescript/essentials-sync/tests/copy.test.ts | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/tools/typescript/essentials-sync/src/copy.ts b/tools/typescript/essentials-sync/src/copy.ts index 1629937..238cec1 100644 --- a/tools/typescript/essentials-sync/src/copy.ts +++ b/tools/typescript/essentials-sync/src/copy.ts @@ -1,5 +1,6 @@ import { promises as fs } from "node:fs"; import path from "node:path"; +import { REPO_OWNED_ITEM_FILES } from "./ess-items.js"; import { listPublishableFiles } from "./file-set.js"; import { isUnscannableTextFile } from "./scanners/text-files.js"; @@ -13,7 +14,8 @@ export interface CopyTreeResult { } // Copies sourceAbs into targetAbs without deleting anything already in the -// target, so target-only files (LICENSE, NOTICE) survive a re-sync. +// target, so target-only files (LICENSE, NOTICE) survive a re-sync. The item's +// own LICENSE is never copied, in either direction (REPO_OWNED_ITEM_FILES). // // The file list is the same one the scanners read (listPublishableFiles). That // invariant is what makes the fast path safe -- every file this copies is a @@ -31,6 +33,7 @@ export async function copyTreeVerbatim( const symlinksSkipped: string[] = []; for (const relPath of relPaths) { + if (REPO_OWNED_ITEM_FILES.has(relPath)) continue; const from = path.join(sourceAbs, relPath); const to = path.join(targetAbs, relPath); diff --git a/tools/typescript/essentials-sync/src/ess-items.ts b/tools/typescript/essentials-sync/src/ess-items.ts index f819a01..136ed1b 100644 --- a/tools/typescript/essentials-sync/src/ess-items.ts +++ b/tools/typescript/essentials-sync/src/ess-items.ts @@ -1,5 +1,10 @@ import { execa } from "execa"; +// Files at an item's root that belong to the repo holding the item, not to +// the item: the open-source copy carries the license text, a source repo may +// not. Sync never copies them and status never compares them. +export const REPO_OWNED_ITEM_FILES: ReadonlySet<string> = new Set(["LICENSE"]); + // The ess-* item that contains repoRelPath (a repo-relative, "/"-separated // path). An item is either a directory named ess-* (packages/python/ess-auth) // or a file named ess-* directly inside a directory named ess diff --git a/tools/typescript/essentials-sync/src/status-command.ts b/tools/typescript/essentials-sync/src/status-command.ts index a66cc7b..ad1f39e 100644 --- a/tools/typescript/essentials-sync/src/status-command.ts +++ b/tools/typescript/essentials-sync/src/status-command.ts @@ -3,7 +3,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { Command } from "commander"; import chalk from "chalk"; -import { findEssItemRoot } from "./ess-items.js"; +import { findEssItemRoot, REPO_OWNED_ITEM_FILES } from "./ess-items.js"; import { listPublishableFiles } from "./file-set.js"; export type ItemState = "identical" | "differs" | "only in source" | "only in target"; @@ -15,10 +15,6 @@ export interface ItemStatus { files: Array<{ path: string; state: FileState }>; } -// Each item's own LICENSE is allowed to differ: the open-source copy carries -// the license text, the source repo may not. -const IGNORED_ITEM_FILES = new Set(["LICENSE"]); - // `essentials-sync status`: compares every ess-* item in two repos, file by // file. Read-only, so it never prompts and has no --dry-run. Returns the // process exit code. @@ -76,7 +72,7 @@ async function indexEssItems(repoRoot: string): Promise<Map<string, Map<string, const item = findEssItemRoot(relPath); if (!item) continue; const inner = relPath === item ? "" : relPath.slice(item.length + 1); - if (IGNORED_ITEM_FILES.has(inner)) continue; + if (REPO_OWNED_ITEM_FILES.has(inner)) continue; let files = items.get(item); if (!files) { diff --git a/tools/typescript/essentials-sync/tests/copy.test.ts b/tools/typescript/essentials-sync/tests/copy.test.ts index d13b318..3e88b02 100644 --- a/tools/typescript/essentials-sync/tests/copy.test.ts +++ b/tools/typescript/essentials-sync/tests/copy.test.ts @@ -27,6 +27,20 @@ afterEach(async () => { }); describe("copyTreeVerbatim", () => { + it("leaves the item's own LICENSE to each repo", async () => { + const source = await makeTree({ + "SKILL.md": "# Skill\n", + LICENSE: "source license\n", + "vendor/LICENSE": "vendored license\n", + }); + const target = await makeTree({ LICENSE: "target license\n" }); + + const result = await copyTreeVerbatim(source, target); + + expect(result.filesCopied.sort()).toEqual(["SKILL.md", "vendor/LICENSE"]); + expect(await fs.readFile(path.join(target, "LICENSE"), "utf8")).toBe("target license\n"); + }); + it("copies files byte-for-byte", async () => { const source = await makeTree({ "SKILL.md": "# Skill\nbody\n", From 1435d6d17ba62f086da3538eb6303be00db05646 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 21:19:13 -0500 Subject: [PATCH 15/20] docs(skills): declare Apache-2.0 in ess- skills Each ess- skill states its license in SKILL.md frontmatter as well as carrying the LICENSE file, so the source copies, which have no LICENSE, still name it. --- skills/ess/ess-django-docs/SKILL.md | 1 + skills/ess/ess-env-credentials/SKILL.md | 1 + skills/ess/ess-gh-copilot-review/SKILL.md | 1 + skills/ess/ess-gh-repo-mirror/SKILL.md | 1 + skills/ess/ess-git-commits/SKILL.md | 1 + skills/ess/ess-git-tidy/SKILL.md | 1 + skills/ess/ess-mcp-hide-secrets/SKILL.md | 1 + skills/ess/ess-my-groups/SKILL.md | 1 + skills/ess/ess-postgres-docs/SKILL.md | 1 + skills/ess/ess-pr-address-comments/SKILL.md | 1 + skills/ess/ess-pr-review/SKILL.md | 1 + skills/ess/ess-shell-flags-literal/SKILL.md | 1 + skills/ess/ess-summarize-change-log/SKILL.md | 1 + 13 files changed, 13 insertions(+) diff --git a/skills/ess/ess-django-docs/SKILL.md b/skills/ess/ess-django-docs/SKILL.md index b77c13e..7be35d6 100644 --- a/skills/ess/ess-django-docs/SKILL.md +++ b/skills/ess/ess-django-docs/SKILL.md @@ -4,6 +4,7 @@ description: >- Answer Django questions from current documentation through the Context7 MCP instead of training data. Use when the user asks about Django APIs, settings, the ORM, migrations, management commands, or Django REST Framework details. +license: Apache-2.0 compatibility: Requires the Context7 MCP server. --- diff --git a/skills/ess/ess-env-credentials/SKILL.md b/skills/ess/ess-env-credentials/SKILL.md index 4ebd062..da0753c 100644 --- a/skills/ess/ess-env-credentials/SKILL.md +++ b/skills/ess/ess-env-credentials/SKILL.md @@ -7,6 +7,7 @@ description: >- environment variable", "API key not found", "Authentication failed", "Credentials not configured", or "passphrase must be set with PULUMI_CONFIG_PASSPHRASE". +license: Apache-2.0 compatibility: Requires a POSIX shell (bash or zsh). --- diff --git a/skills/ess/ess-gh-copilot-review/SKILL.md b/skills/ess/ess-gh-copilot-review/SKILL.md index 408f59d..bbb2513 100644 --- a/skills/ess/ess-gh-copilot-review/SKILL.md +++ b/skills/ess/ess-gh-copilot-review/SKILL.md @@ -6,6 +6,7 @@ description: >- an explicit revision or range. Use when asked to "review this branch with Copilot", "get a Copilot review", "Copilot review since last time", or "have Copilot check HEAD~3..HEAD". +license: Apache-2.0 compatibility: >- Requires bash, git, and GitHub Copilot CLI (`copilot` on PATH). Must run outside any agent sandbox, because Copilot CLI spawns subprocesses that need PTY access. diff --git a/skills/ess/ess-gh-repo-mirror/SKILL.md b/skills/ess/ess-gh-repo-mirror/SKILL.md index 63711eb..92a9aed 100644 --- a/skills/ess/ess-gh-repo-mirror/SKILL.md +++ b/skills/ess/ess-gh-repo-mirror/SKILL.md @@ -5,6 +5,7 @@ description: >- owner or org, or sync an existing mirror. Use when asked to mirror, fork, copy, or sync a repo or action into another org, or when a workflow cannot use an Action from outside its own org. +license: Apache-2.0 compatibility: >- Requires git and an authenticated gh CLI with permission to create repos in the target owner. GitHub only. diff --git a/skills/ess/ess-git-commits/SKILL.md b/skills/ess/ess-git-commits/SKILL.md index e322aed..e52cf6b 100644 --- a/skills/ess/ess-git-commits/SKILL.md +++ b/skills/ess/ess-git-commits/SKILL.md @@ -6,6 +6,7 @@ description: >- commit and let a commit-msg hook strip the trailer Cursor injects. Use when committing, amending, creating or editing a pull request, or when a commit shows a "Co-authored-by: Cursor" trailer. +license: Apache-2.0 compatibility: >- Targets Cursor agents, which add the trailer. Requires git; the hook needs pre-commit with the commit-msg stage installed. diff --git a/skills/ess/ess-git-tidy/SKILL.md b/skills/ess/ess-git-tidy/SKILL.md index 5e68c96..e9d6580 100644 --- a/skills/ess/ess-git-tidy/SKILL.md +++ b/skills/ess/ess-git-tidy/SKILL.md @@ -8,6 +8,7 @@ description: >- fresh work starts and are never auto-deleted. Use for /ess-git-tidy, "clean up my branches", "which worktrees are stale", "what can I delete before I start", or a branch/worktree status review. Requires git; gh adds PR state. +license: Apache-2.0 compatibility: >- Any git repo, macOS or Linux, bash 3.2+. GitHub PR state needs gh authenticated against the repo's remote; without it the audit still runs and diff --git a/skills/ess/ess-mcp-hide-secrets/SKILL.md b/skills/ess/ess-mcp-hide-secrets/SKILL.md index 96312d7..2dc9684 100644 --- a/skills/ess/ess-mcp-hide-secrets/SKILL.md +++ b/skills/ess/ess-mcp-hide-secrets/SKILL.md @@ -6,6 +6,7 @@ description: >- (~/.local/share/cursor-mcp/ by default) and loading them via a LaunchAgent at login. Use when the user runs /ess-mcp-hide-secrets, asks to secure MCP config, externalize MCP secrets, set up mcp.env, or fix MCP auth after cleaning up mcp.json. macOS only — do not use on Windows or Linux. +license: Apache-2.0 compatibility: >- macOS only. Requires bash, python3, launchctl, and write access to ~/Library/LaunchAgents. Windows support is planned via scripts/windows/ (not yet diff --git a/skills/ess/ess-my-groups/SKILL.md b/skills/ess/ess-my-groups/SKILL.md index 0039f6f..93f8da4 100644 --- a/skills/ess/ess-my-groups/SKILL.md +++ b/skills/ess/ess-my-groups/SKILL.md @@ -9,6 +9,7 @@ description: >- when the user asks to add, rename, or remove a user or a group, or asks who is in a group. These are personal groups kept in a local file, not corporate directory groups. +license: Apache-2.0 compatibility: >- Requires uv. The first run fetches the pinned tomlkit package, which needs network access. diff --git a/skills/ess/ess-postgres-docs/SKILL.md b/skills/ess/ess-postgres-docs/SKILL.md index fdf58dd..6ae0d87 100644 --- a/skills/ess/ess-postgres-docs/SKILL.md +++ b/skills/ess/ess-postgres-docs/SKILL.md @@ -4,6 +4,7 @@ description: >- Answer PostgreSQL and psql questions from current documentation through the Context7 MCP instead of training data. Use when the user asks about SQL syntax, the psql CLI, PostgreSQL administration, or configuration settings. +license: Apache-2.0 compatibility: Requires the Context7 MCP server. --- diff --git a/skills/ess/ess-pr-address-comments/SKILL.md b/skills/ess/ess-pr-address-comments/SKILL.md index 29ca07a..9621b00 100644 --- a/skills/ess/ess-pr-address-comments/SKILL.md +++ b/skills/ess/ess-pr-address-comments/SKILL.md @@ -1,6 +1,7 @@ --- name: ess-pr-address-comments description: Address GitHub PR review comments, implement fixes on the PR branch, push, then reply to and resolve the addressed threads. Handles one PR or many in parallel, each in its own git worktree on the PR's branch. Auto-starts (look up PRs, create worktrees, fetch comments), then pauses to show a per-PR addressing plan for approval; after approval each PR is handled by a background subagent that implements, commits, pushes to the PR branch, and resolves threads. With no PR given, it discovers your own open PRs on the current repo's remote that have changes-requested or unresolved review comments and runs the same workflow. Use for /ess-pr-address-comments, when the user gives one or more PR URLs or numbers, or asks to address/fix review comments on their own open PRs on this repo without naming them. +license: Apache-2.0 --- # PR Address Comments diff --git a/skills/ess/ess-pr-review/SKILL.md b/skills/ess/ess-pr-review/SKILL.md index e7a4cf5..4714292 100644 --- a/skills/ess/ess-pr-review/SKILL.md +++ b/skills/ess/ess-pr-review/SKILL.md @@ -10,6 +10,7 @@ description: >- /ess-pr-review, "review PR <number/URL>", "review my requested PRs", "code review these pull requests", "review PRs from <person>", "review my teammate's PRs", "review the PRs from <team>". Requires gh and a git checkout. +license: Apache-2.0 metadata: version: "2.0" --- diff --git a/skills/ess/ess-shell-flags-literal/SKILL.md b/skills/ess/ess-shell-flags-literal/SKILL.md index fcdc08e..04a8918 100644 --- a/skills/ess/ess-shell-flags-literal/SKILL.md +++ b/skills/ess/ess-shell-flags-literal/SKILL.md @@ -6,6 +6,7 @@ description: >- writing shell commands in zsh, especially repeated gh, aws, or pulumi calls that share the same flags, or when a command fails with an unknown flag or a bad repo name. +license: Apache-2.0 compatibility: >- Written for zsh, which does not word-split unquoted variables. The rule also avoids quoting bugs in bash. diff --git a/skills/ess/ess-summarize-change-log/SKILL.md b/skills/ess/ess-summarize-change-log/SKILL.md index 47fc311..ebad5bf 100644 --- a/skills/ess/ess-summarize-change-log/SKILL.md +++ b/skills/ess/ess-summarize-change-log/SKILL.md @@ -6,6 +6,7 @@ description: >- Use for /ess-summarize-change-log, or when the user asks to summarize a git log, boil down commits, write a squash-merge message, or produce a five-bullet PR/merge summary. +license: Apache-2.0 compatibility: >- Requires uv. The validator fetches a pinned Commitizen on first run, which needs network access. From 23c439984abff34e7c133a9f4eb95c77f939ac16 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 21:23:50 -0500 Subject: [PATCH 16/20] feat(essentials-sync): allow reserved-TLD emails Names under .example, .invalid, and .test are reserved and never resolve, so they are as safe for placeholder emails as the example.* documentation domains. A reserved name must still end the address, so x@test.com is flagged. --- .../essentials-sync/src/scanners/pii.ts | 16 +++++++++------- .../essentials-sync/tests/scanners.test.ts | 9 +++++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/tools/typescript/essentials-sync/src/scanners/pii.ts b/tools/typescript/essentials-sync/src/scanners/pii.ts index 700d5c2..f5f5bc1 100644 --- a/tools/typescript/essentials-sync/src/scanners/pii.ts +++ b/tools/typescript/essentials-sync/src/scanners/pii.ts @@ -16,14 +16,16 @@ interface PiiPattern { const PII_PATTERNS: readonly PiiPattern[] = [ { // Allows the four domains IANA reserves for documentation (example.com, - // .net, .org, .edu) and any subdomain of them (bob@corp.example.com), in - // any case. The domain must end there, so a lookalike such as - // x@example.com.evil.org is still flagged. Other reserved names (.example, - // .invalid, .test) and registrable ones (example.io) are flagged on - // purpose: placeholders stick to the four. + // .net, .org, .edu) and any subdomain of them (bob@corp.example.com), plus + // any name under the reserved TLDs .example, .invalid, and .test, in any + // case. The reserved name must end the address, so a lookalike such as + // x@example.com.evil.org or x@test.com is still flagged, as is a + // registrable name such as example.io. name: "email", - regex: /\b[A-Za-z0-9._%+-]+@(?!(?:[A-Za-z0-9-]+\.)*example\.(?:com|org|net|edu)(?!\.?[A-Za-z0-9-]))[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi, - message: "Email address that is not on example.com, example.net, example.org, or example.edu.", + regex: /\b[A-Za-z0-9._%+-]+@(?!(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|org|net|edu)|example|invalid|test)(?!\.?[A-Za-z0-9-]))[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi, + message: + "Email address that is not on example.com, example.net, example.org, or example.edu," + + " or under the .example, .invalid, or .test TLDs.", }, { name: "phone", diff --git a/tools/typescript/essentials-sync/tests/scanners.test.ts b/tools/typescript/essentials-sync/tests/scanners.test.ts index dbc100f..2c66c31 100644 --- a/tools/typescript/essentials-sync/tests/scanners.test.ts +++ b/tools/typescript/essentials-sync/tests/scanners.test.ts @@ -60,17 +60,22 @@ describe("pii scanner", () => { "dean@registrar.example.edu", "Alice.Smith@Example.COM", "Contact bob@example.com.", + "bob@corp.example", + "test@example.invalid", + "ci@runner.test", + "Ops@Host.INVALID", ])("allows %s", async (line) => { expect(await emailFindings(line)).toBe(0); }); it.each([ - "bob@corp.example", - "x@example.invalid", "x@host.com", "x@example.com.evil.org", "x@notexample.com", "x@example.io", + "x@test.com", + "x@example.invalid.com", + "x@invalid.io", ])("flags %s", async (line) => { expect(await emailFindings(line)).toBe(1); }); From aa87c28fe3ca3299d0ab7ca46b083a122b7ff0a2 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 21:27:57 -0500 Subject: [PATCH 17/20] docs(ess): allow reserved TLDs in examples Names under .example, .invalid, and .test are reserved by RFC 2606 and never resolve, so they are as safe for placeholders as the example.* domains. .invalid suits a value that must never resolve, such as a throwaway git identity. --- .../ess/ess-docs-example-urls.instructions.md | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/instructions/ess/ess-docs-example-urls.instructions.md b/instructions/ess/ess-docs-example-urls.instructions.md index 998bd3e..282c236 100644 --- a/instructions/ess/ess-docs-example-urls.instructions.md +++ b/instructions/ess/ess-docs-example-urls.instructions.md @@ -1,17 +1,25 @@ --- description: >- - Use example.com, example.net, example.org, or example.edu, or a subdomain of - one, for every illustrative host, URL, and email in code, tests, comments, - and docs. + Use example.com, example.net, example.org, or example.edu, a subdomain of + one, or a name under the reserved .example, .invalid, or .test TLDs for every + illustrative host, URL, and email in code, tests, comments, and docs. applyTo: "**" --- # Example URLs -Placeholder hosts, URLs, and email addresses use one of four reserved domains, or a -subdomain of one: `example.com`, `example.net`, `example.org`, or `example.edu`. RFC 2606 -reserves the first three, and IANA holds all four. Each serves a page that reads: "This -domain is for use in documentation examples without needing permission." +Placeholder hosts, URLs, and email addresses use a reserved name, which no one can +register: + +| Reserved | Names | +| -------- | ----- | +| Documentation domains | `example.com`, `example.net`, `example.org`, `example.edu`, and their subdomains | +| Top-level domains | Any name under `.example`, `.invalid`, or `.test` | + +RFC 2606 reserves `example.com`, `example.net`, `example.org`, and the three top-level +domains. IANA holds all four documentation domains, and each serves a page that reads: +"This domain is for use in documentation examples without needing permission." The +top-level domains are never delegated, so names under them never resolve. | Placeholder for | Use | Not | | --------------- | --- | --- | @@ -19,14 +27,18 @@ domain is for use in documentation examples without needing permission." | An identity provider | `idp.example.com` | `login.vendor.com` | | A deployed app | `app.example.com` | `my-app.awsapprunner.com` | | A person | `ada@example.com` | an address at `x.com` or a real company | -| A second organization | `corp.example.net` | `corp.example`, `example.invalid` | +| A second organization | `corp.example.net` | `corp.example.io` | +| A host that must never resolve | `unreachable.invalid` | `nonexistent.com` | +| A throwaway test identity | `test@example.invalid` | an address at `test.com` | - **Default to `example.com`.** Use `example.net`, `example.org`, or `example.edu` when an example needs organizations that are visibly distinct. -- **Only these four.** Other `example.<tld>` names, such as `example.io` or `example.dev`, - are ordinary domains someone can register. The reserved TLDs `.example`, `.invalid`, and - `.test` are safe but off-convention: a short fixed list keeps placeholders searchable, - and secret and PII scanners can allow exactly that list. +- **Use a reserved top-level domain when its meaning helps.** `.invalid` marks a value + that must never resolve or be delivered, such as an unreachable host in a failure test + or a git identity in a throwaway repo. `.test` suits hosts on a local test network, and + `.example` reads as an illustration. +- **Only these.** Other `example.<tld>` names, such as `example.io` or `example.dev`, and + look-alikes such as `test.com`, are ordinary domains someone can register. - **Real endpoints stay real.** A host the code actually calls (`github.com`, `graph.microsoft.com`, `api.smith.langchain.com`) is not a placeholder; leave it. - **Exception: a domain the code enforces.** When the code under test accepts only a From e8f017eca17b55f4476e2b7797c0c08f78bad862 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 21:41:40 -0500 Subject: [PATCH 18/20] build(ess): declare Apache-2.0 in ess- packages Every ess- Python package now declares license = "Apache-2.0", as ess-langsmith-client already did, and requires hatchling>=1.27, which reads the SPDX expression (PEP 639). --- examples/python/ess-hello-jwt-auth-code/pyproject.toml | 4 ++-- examples/python/ess-messages/pyproject.toml | 3 ++- packages/python/ess-auth/pyproject.toml | 3 ++- packages/python/ess-browser/pyproject.toml | 3 ++- packages/python/ess-dirs/pyproject.toml | 3 ++- packages/python/ess-load-test/pyproject.toml | 3 ++- packages/python/ess-outlook/pyproject.toml | 3 ++- packages/python/ess-passwords/pyproject.toml | 3 ++- packages/python/ess-service-now-incident/pyproject.toml | 3 ++- packages/python/ess-webex/pyproject.toml | 3 ++- 10 files changed, 20 insertions(+), 11 deletions(-) diff --git a/examples/python/ess-hello-jwt-auth-code/pyproject.toml b/examples/python/ess-hello-jwt-auth-code/pyproject.toml index 3b13ad4..32dd837 100644 --- a/examples/python/ess-hello-jwt-auth-code/pyproject.toml +++ b/examples/python/ess-hello-jwt-auth-code/pyproject.toml @@ -3,8 +3,8 @@ name = "ess-hello-jwt-auth-code" version = "0.1.0" description = "Obtain a JWT access token from any OpenID Connect provider via the OAuth 2.0 Authorization Code Flow with PKCE" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" -license = { text = "Apache-2.0" } keywords = ["oauth2", "oidc", "jwt", "pkce", "authorization-code"] dependencies = [ "httpx>=0.28.0", @@ -18,7 +18,7 @@ ess-hello-jwt-auth-code = "ess_hello_jwt_auth_code.main:main" dev = ["ruff>=0.14.2", "pytest>=8.0.0"] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/examples/python/ess-messages/pyproject.toml b/examples/python/ess-messages/pyproject.toml index 044f0f2..ffa3271 100644 --- a/examples/python/ess-messages/pyproject.toml +++ b/examples/python/ess-messages/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-messages" version = "0.1.0" description = "Unified inbox -- aggregates email and Webex messages into Activity Streams 2.0" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "activitystreams2>=0.5.0", @@ -16,7 +17,7 @@ dependencies = [ ess-messages = "ess_messages.__main__:main" [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.uv.sources] diff --git a/packages/python/ess-auth/pyproject.toml b/packages/python/ess-auth/pyproject.toml index 49bea97..dbcb3f0 100644 --- a/packages/python/ess-auth/pyproject.toml +++ b/packages/python/ess-auth/pyproject.toml @@ -2,6 +2,7 @@ name = "ess-auth" version = "0.1.0" description = "OAuth2 client-credentials helper and JWT verification utilities" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = ["httpx>=0.27", "PyJWT>=2.0.0"] @@ -9,7 +10,7 @@ dependencies = ["httpx>=0.27", "PyJWT>=2.0.0"] jwks = ["PyJWT[crypto]>=2.0.0"] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/python/ess-browser/pyproject.toml b/packages/python/ess-browser/pyproject.toml index 0410570..780cd37 100644 --- a/packages/python/ess-browser/pyproject.toml +++ b/packages/python/ess-browser/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-browser" version = "0.1.0" description = "Shared Playwright browser session with SSO handling" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "playwright>=1.40.0", @@ -14,7 +15,7 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build] diff --git a/packages/python/ess-dirs/pyproject.toml b/packages/python/ess-dirs/pyproject.toml index 4600975..29980e2 100644 --- a/packages/python/ess-dirs/pyproject.toml +++ b/packages/python/ess-dirs/pyproject.toml @@ -2,11 +2,12 @@ name = "ess-dirs" version = "0.1.0" description = "Secure filesystem helpers (write files with restrictive permissions)." +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/python/ess-load-test/pyproject.toml b/packages/python/ess-load-test/pyproject.toml index d9bb4c8..ff67cd3 100644 --- a/packages/python/ess-load-test/pyproject.toml +++ b/packages/python/ess-load-test/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-load-test" version = "0.1.0" description = "Generic EC2 Locust load testing engine and CLI" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "boto3>=1.35.0", @@ -26,7 +27,7 @@ ess-auth = { workspace = true } ess-browser = { workspace = true } [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/python/ess-outlook/pyproject.toml b/packages/python/ess-outlook/pyproject.toml index 74dee91..f18893c 100644 --- a/packages/python/ess-outlook/pyproject.toml +++ b/packages/python/ess-outlook/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-outlook" version = "0.1.0" description = "CLI tool for reading and managing Microsoft Outlook emails via Microsoft Graph" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "click>=8.1", @@ -25,7 +26,7 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/python/ess-passwords/pyproject.toml b/packages/python/ess-passwords/pyproject.toml index df957ea..63a5474 100644 --- a/packages/python/ess-passwords/pyproject.toml +++ b/packages/python/ess-passwords/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-passwords" version = "0.1.0" description = "Readable password generation from a filtered EFF wordlist" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "click>=8.1", @@ -18,7 +19,7 @@ dev = [ ess-passwords = "ess_passwords.__main__:main" [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build] diff --git a/packages/python/ess-service-now-incident/pyproject.toml b/packages/python/ess-service-now-incident/pyproject.toml index 04ec17d..c42bce5 100644 --- a/packages/python/ess-service-now-incident/pyproject.toml +++ b/packages/python/ess-service-now-incident/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-service-now-incident" version = "0.1.0" description = "Fetch ServiceNow incident records via an authenticated browser session" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "ess-browser", @@ -21,7 +22,7 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/python/ess-webex/pyproject.toml b/packages/python/ess-webex/pyproject.toml index de449f4..3b5ab71 100644 --- a/packages/python/ess-webex/pyproject.toml +++ b/packages/python/ess-webex/pyproject.toml @@ -3,6 +3,7 @@ name = "ess-webex" version = "0.1.0" description = "CLI tool for interacting with Webex rooms, messages, teams, and meetings" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "click>=8.1", @@ -23,7 +24,7 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] From ca989d4c4ae20b372479108dbcf4517b5501f0f5 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Mon, 2 Nov 2026 21:41:41 -0500 Subject: [PATCH 19/20] build: ship a LICENSE with each package Each Python package and tool, docs-docusaurus, and essentials-sync now carries the Apache-2.0 LICENSE, so a built wheel or npm package includes the text, as the JavaScript packages already did. The non-ess Python projects also declare license = "Apache-2.0" and require hatchling>=1.27. --- .../python/ess-hello-jwt-auth-code/LICENSE | 202 ++++++++++++++++++ examples/python/ess-messages/LICENSE | 202 ++++++++++++++++++ .../javascript/cisco/docs-docusaurus/LICENSE | 202 ++++++++++++++++++ packages/python/azure-ai/LICENSE | 202 ++++++++++++++++++ packages/python/azure-ai/pyproject.toml | 3 +- packages/python/ess-auth/LICENSE | 202 ++++++++++++++++++ packages/python/ess-browser/LICENSE | 202 ++++++++++++++++++ packages/python/ess-dirs/LICENSE | 202 ++++++++++++++++++ packages/python/ess-langsmith-client/LICENSE | 202 ++++++++++++++++++ packages/python/ess-load-test/LICENSE | 202 ++++++++++++++++++ packages/python/ess-outlook/LICENSE | 202 ++++++++++++++++++ packages/python/ess-passwords/LICENSE | 202 ++++++++++++++++++ .../python/ess-service-now-incident/LICENSE | 202 ++++++++++++++++++ packages/python/ess-webex/LICENSE | 202 ++++++++++++++++++ packages/python/langsmith-network/LICENSE | 202 ++++++++++++++++++ .../python/langsmith-network/pyproject.toml | 3 +- tools/python/core_github/LICENSE | 202 ++++++++++++++++++ tools/python/core_github/pyproject.toml | 3 +- tools/python/gcp_gemini/LICENSE | 202 ++++++++++++++++++ tools/python/gcp_gemini/pyproject.toml | 3 +- tools/python/langsmith-hosting/LICENSE | 202 ++++++++++++++++++ tools/python/langsmith-hosting/pyproject.toml | 3 +- tools/python/pulumi-utils/LICENSE | 202 ++++++++++++++++++ tools/python/pulumi-utils/pyproject.toml | 3 +- tools/typescript/essentials-sync/LICENSE | 202 ++++++++++++++++++ 25 files changed, 3850 insertions(+), 6 deletions(-) create mode 100644 examples/python/ess-hello-jwt-auth-code/LICENSE create mode 100644 examples/python/ess-messages/LICENSE create mode 100644 packages/javascript/cisco/docs-docusaurus/LICENSE create mode 100644 packages/python/azure-ai/LICENSE create mode 100644 packages/python/ess-auth/LICENSE create mode 100644 packages/python/ess-browser/LICENSE create mode 100644 packages/python/ess-dirs/LICENSE create mode 100644 packages/python/ess-langsmith-client/LICENSE create mode 100644 packages/python/ess-load-test/LICENSE create mode 100644 packages/python/ess-outlook/LICENSE create mode 100644 packages/python/ess-passwords/LICENSE create mode 100644 packages/python/ess-service-now-incident/LICENSE create mode 100644 packages/python/ess-webex/LICENSE create mode 100644 packages/python/langsmith-network/LICENSE create mode 100644 tools/python/core_github/LICENSE create mode 100644 tools/python/gcp_gemini/LICENSE create mode 100644 tools/python/langsmith-hosting/LICENSE create mode 100644 tools/python/pulumi-utils/LICENSE create mode 100644 tools/typescript/essentials-sync/LICENSE diff --git a/examples/python/ess-hello-jwt-auth-code/LICENSE b/examples/python/ess-hello-jwt-auth-code/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/examples/python/ess-hello-jwt-auth-code/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/python/ess-messages/LICENSE b/examples/python/ess-messages/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/examples/python/ess-messages/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/javascript/cisco/docs-docusaurus/LICENSE b/packages/javascript/cisco/docs-docusaurus/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/javascript/cisco/docs-docusaurus/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/azure-ai/LICENSE b/packages/python/azure-ai/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/azure-ai/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/azure-ai/pyproject.toml b/packages/python/azure-ai/pyproject.toml index e3895a9..ec6770a 100644 --- a/packages/python/azure-ai/pyproject.toml +++ b/packages/python/azure-ai/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "Azure OpenAI GPT-5 deployment with Pulumi" authors = [{ name = "Matt Norris", email = "matt@mattnorris.dev" }] readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "pulumi>=3.202.0,<4.0.0", @@ -15,7 +16,7 @@ dependencies = [ pulumi-utils = { workspace = true } [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/python/ess-auth/LICENSE b/packages/python/ess-auth/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/ess-auth/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-browser/LICENSE b/packages/python/ess-browser/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/ess-browser/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-dirs/LICENSE b/packages/python/ess-dirs/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/ess-dirs/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-langsmith-client/LICENSE b/packages/python/ess-langsmith-client/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/ess-langsmith-client/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-load-test/LICENSE b/packages/python/ess-load-test/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/ess-load-test/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-outlook/LICENSE b/packages/python/ess-outlook/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/ess-outlook/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-passwords/LICENSE b/packages/python/ess-passwords/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/ess-passwords/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-service-now-incident/LICENSE b/packages/python/ess-service-now-incident/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/ess-service-now-incident/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/ess-webex/LICENSE b/packages/python/ess-webex/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/ess-webex/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/langsmith-network/LICENSE b/packages/python/langsmith-network/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/python/langsmith-network/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/langsmith-network/pyproject.toml b/packages/python/langsmith-network/pyproject.toml index 272d0c8..8d18d42 100644 --- a/packages/python/langsmith-network/pyproject.toml +++ b/packages/python/langsmith-network/pyproject.toml @@ -2,11 +2,12 @@ name = "langsmith-network" version = "0.1.0" description = "LangSmith Cloud NAT gateway IP addresses for firewall rules" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/tools/python/core_github/LICENSE b/tools/python/core_github/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/tools/python/core_github/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/python/core_github/pyproject.toml b/tools/python/core_github/pyproject.toml index 524c6df..5ef6cae 100644 --- a/tools/python/core_github/pyproject.toml +++ b/tools/python/core_github/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "" authors = [{ name = "Matt Norris", email = "matt@mattnorris.dev" }] readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.11" dependencies = [ "click>=8.1.0,<9.0.0", @@ -14,7 +15,7 @@ dependencies = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/tools/python/gcp_gemini/LICENSE b/tools/python/gcp_gemini/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/tools/python/gcp_gemini/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/python/gcp_gemini/pyproject.toml b/tools/python/gcp_gemini/pyproject.toml index 1cd7bc8..ffd8987 100644 --- a/tools/python/gcp_gemini/pyproject.toml +++ b/tools/python/gcp_gemini/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "" authors = [{ name = "Matt Norris", email = "matt@mattnorris.dev" }] readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.11" dependencies = [ "pulumi>=3.182.0", @@ -12,7 +13,7 @@ dependencies = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/tools/python/langsmith-hosting/LICENSE b/tools/python/langsmith-hosting/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/tools/python/langsmith-hosting/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/python/langsmith-hosting/pyproject.toml b/tools/python/langsmith-hosting/pyproject.toml index a05a106..667e22a 100644 --- a/tools/python/langsmith-hosting/pyproject.toml +++ b/tools/python/langsmith-hosting/pyproject.toml @@ -3,6 +3,7 @@ name = "langsmith-hosting" version = "0.1.0" description = "LangSmith Hybrid infrastructure on AWS provisioned with Pulumi" readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = [ "boto3==1.42.85", @@ -22,7 +23,7 @@ dependencies = [ langsmith-network = { workspace = true } [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/tools/python/pulumi-utils/LICENSE b/tools/python/pulumi-utils/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/tools/python/pulumi-utils/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/python/pulumi-utils/pyproject.toml b/tools/python/pulumi-utils/pyproject.toml index c821b7f..3085cb9 100644 --- a/tools/python/pulumi-utils/pyproject.toml +++ b/tools/python/pulumi-utils/pyproject.toml @@ -4,11 +4,12 @@ version = "0.1.0" description = "Common utilities for Pulumi projects" authors = [{ name = "Matt Norris", email = "matt@mattnorris.dev" }] readme = "README.md" +license = "Apache-2.0" requires-python = ">=3.12,<3.13" dependencies = ["pulumi>=3.202.0,<4.0.0"] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/tools/typescript/essentials-sync/LICENSE b/tools/typescript/essentials-sync/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/tools/typescript/essentials-sync/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From a52c7448bc375fca659721e31689634e5a2e0d50 Mon Sep 17 00:00:00 2001 From: Matt Norris <matt@mattnorris.dev> Date: Tue, 3 Nov 2026 09:05:56 -0500 Subject: [PATCH 20/20] refactor(essentials-sync): share one jargon message Every default jargon pattern now reports "References company systems." instead of a sentence describing each internal system, so the published source no longer explains what each term is. Findings still name the matched term. The compound org term is dropped because its two parts already match. --- .../essentials-sync/src/jargon-list.ts | 92 +++++-------------- .../essentials-sync/tests/scanners.test.ts | 13 ++- 2 files changed, 34 insertions(+), 71 deletions(-) diff --git a/tools/typescript/essentials-sync/src/jargon-list.ts b/tools/typescript/essentials-sync/src/jargon-list.ts index 3e50dfc..2de9f46 100644 --- a/tools/typescript/essentials-sync/src/jargon-list.ts +++ b/tools/typescript/essentials-sync/src/jargon-list.ts @@ -10,77 +10,29 @@ const wordBoundary = (word: string): RegExp => const hostnameSuffix = (suffix: string): RegExp => new RegExp(`[a-z0-9-]+\\.${suffix.replace(/\./g, "\\.")}`, "i"); +export const COMPANY_MESSAGE = "References company systems."; + +const COMPANY_TERMS = [ + "cisco", + "myid", + "cec", + "honeycomb", + "ceto", + "sales-ai", + "sales_ai", + "agentic-ops-hub", + "agentic ops hub", + "aoh", + "edag", + "sspo", + "runon", + "npi", + "aws-sso", +] as const; + export const DEFAULT_JARGON_PATTERNS: readonly JargonPattern[] = [ - { - term: "cisco", - regex: wordBoundary("cisco"), - message: "Contains the company name 'cisco' (case-insensitive).", - }, - { - term: "*.cisco.com", - regex: hostnameSuffix("cisco.com"), - message: "Contains an internal Cisco hostname.", - }, - { - term: "myid", - regex: wordBoundary("myid"), - message: "References 'myid' identity service.", - }, - { - term: "cec", - regex: wordBoundary("cec"), - message: "References 'cec' (Cisco employee credential).", - }, - { - term: "cisco-ceto", - regex: wordBoundary("cisco-ceto"), - message: "References internal cisco-ceto org.", - }, - { - term: "honeycomb", - regex: wordBoundary("honeycomb"), - message: "References the internal 'honeycomb' repo name.", - }, - { - term: "ceto", - regex: wordBoundary("ceto"), - message: "References the internal 'ceto' org.", - }, - ...["sales-ai", "sales_ai"].map((term) => ({ - term, - regex: wordBoundary(term), - message: "References the internal 'sales-ai' team.", - })), - ...["agentic-ops-hub", "agentic ops hub", "aoh"].map((term) => ({ - term, - regex: wordBoundary(term), - message: "References the internal Agentic Ops Hub platform.", - })), - { - term: "edag", - regex: wordBoundary("edag"), - message: "References the internal 'edag' service.", - }, - { - term: "sspo", - regex: wordBoundary("sspo"), - message: "References the internal 'sspo' workspace prefix.", - }, - { - term: "runon", - regex: wordBoundary("runon"), - message: "References the internal RunOn platform.", - }, - { - term: "npi", - regex: wordBoundary("npi"), - message: "References the internal 'npi' program.", - }, - { - term: "aws-sso", - regex: wordBoundary("aws-sso"), - message: "References the internal 'aws-sso' login tool.", - }, + { term: "*.cisco.com", regex: hostnameSuffix("cisco.com"), message: COMPANY_MESSAGE }, + ...COMPANY_TERMS.map((term) => ({ term, regex: wordBoundary(term), message: COMPANY_MESSAGE })), ]; export interface JargonConfig { diff --git a/tools/typescript/essentials-sync/tests/scanners.test.ts b/tools/typescript/essentials-sync/tests/scanners.test.ts index 2c66c31..9762a44 100644 --- a/tools/typescript/essentials-sync/tests/scanners.test.ts +++ b/tools/typescript/essentials-sync/tests/scanners.test.ts @@ -4,7 +4,12 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { findJargonTerms, loadJargonConfig } from "../src/jargon-list.js"; +import { + COMPANY_MESSAGE, + DEFAULT_JARGON_PATTERNS, + findJargonTerms, + loadJargonConfig, +} from "../src/jargon-list.js"; import { scanJargon } from "../src/scanners/jargon.js"; import { scanPii } from "../src/scanners/pii.js"; import { scanWithSecretlint } from "../src/scanners/secretlint.js"; @@ -35,6 +40,12 @@ describe("jargon scanner", () => { expect(findJargonTerms(line, loadJargonConfig()).map((p) => p.term)).toContain(term); }); + it("gives every default pattern the same generic message", () => { + expect(new Set(DEFAULT_JARGON_PATTERNS.map((p) => p.message))).toEqual( + new Set([COMPANY_MESSAGE]), + ); + }); + it("respects custom severity", async () => { const findings = await scanJargon(dirtyFixture, { severity: "warning" }); expect(findings.every((f) => f.severity === "warning")).toBe(true);