@maccesar/aiskills 1.18.1 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -8
- package/commands/release.md +25 -5
- package/lib/config.js +1 -0
- package/package.json +1 -1
- package/skills/humaniza/SKILL.md +1 -0
- package/skills/npm-supply-chain/SKILL.md +102 -0
- package/skills/npm-supply-chain/assets/publish.yml +51 -0
- package/skills/npm-supply-chain/references/authentication.md +57 -0
- package/skills/npm-supply-chain/references/install-defaults.md +43 -0
- package/skills/npm-supply-chain/references/migration.md +57 -0
- package/skills/npm-supply-chain/references/trusted-publishing.md +79 -0
- package/skills/npm-supply-chain/references/verification.md +84 -0
- package/skills/npm-supply-chain/scripts/auditar_npm.py +509 -0
- package/skills/refactoring-ui/SKILL.md +3 -1
- package/skills/seo-launch/SKILL.md +1 -0
- package/skills/seo-launch/scripts/__pycache__/auditar_seo.cpython-312.pyc +0 -0
- package/skills/stitch-showcase/SKILL.md +1 -0
- package/skills/vscode-extension-dev/SKILL.md +4 -2
package/README.md
CHANGED
|
@@ -74,6 +74,7 @@ All three platforms use the same Agent Skills format: a `SKILL.md` file with YAM
|
|
|
74
74
|
| stitch-showcase | Design Tools | Google Stitch export workflow | 16 files |
|
|
75
75
|
| session-log | Project | Convention + 3 A/B rounds | 2 files |
|
|
76
76
|
| seo-launch | Web / SEO | Head tags, share cards, server files | 5 files |
|
|
77
|
+
| npm-supply-chain | npm / CI | npm and GitHub changelogs, 2025–2026 | 5 files |
|
|
77
78
|
|
|
78
79
|
Use `aiskills list` to see available skills from the command line. Pull requests are welcome.
|
|
79
80
|
|
|
@@ -104,7 +105,7 @@ Example prompts:
|
|
|
104
105
|
```
|
|
105
106
|
|
|
106
107
|
How it works:
|
|
107
|
-
1. **Detect** — reads git status, last tag, existing commits since the tag, version file, `CHANGELOG.md`, `README.md`,
|
|
108
|
+
1. **Detect** — reads git status, last tag, existing commits since the tag, version file, `CHANGELOG.md`, `README.md`, `gh` availability, and `.github/workflows/` to see what a pushed tag will set off. Secondary version files count: a repo carrying `.claude-plugin/plugin.json` gets it bumped to the same number in the same commit, because Claude Code compares that field to invalidate its plugin cache and marketplace users otherwise keep running the old code.
|
|
108
109
|
2. **Group the working tree** — reads each modified/untracked file's diff, infers intent, and groups files into N proposed semantic commits (`feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `build`, `ci`). Excludes screenshots in repo root, scratch files, suspicious binaries — and lists them so you can override.
|
|
109
110
|
3. **Infer bump** — across the union of (existing commits since tag) + (proposed semantic commits): `BREAKING CHANGE` / `!:` → major, any `feat:` → minor, otherwise patch. An argument overrides.
|
|
110
111
|
4. **Compose CHANGELOG** — promotes `[Unreleased]` if present, or generates a Keep-a-Changelog entry from the union of all commits being shipped.
|
|
@@ -493,6 +494,48 @@ How to invoke it — in whatever words you'd use anyway:
|
|
|
493
494
|
|
|
494
495
|
---
|
|
495
496
|
|
|
497
|
+
### npm-supply-chain
|
|
498
|
+
|
|
499
|
+
Both directions of the registry, after everything about them changed between November 2025 and July 2026: how a package gets published, and what happens on the machine installing one.
|
|
500
|
+
|
|
501
|
+
On the publishing side, the long-lived credential is gone. Classic tokens were removed in November 2025 and revoked in December; `npm login` now opens a **two-hour session** rather than writing a token, which is why publishing by hand asks for credentials every time. The remaining token type caps at 90 days for write access, lost account and package management on 31 July 2026, and loses direct publish around January 2027. The one path without an expiry is **trusted publishing**: GitHub Actions identifies itself to npm with a short-lived OIDC credential, so there is no secret to store and npm attaches provenance to the publish automatically.
|
|
502
|
+
|
|
503
|
+
On the installing side, **npm v12** turns off three things that used to happen on their own: dependency lifecycle scripts and implicit node-gyp builds, git dependencies, and remote tarballs. The skill covers what breaks, how `npm approve-scripts` works, and why the resulting allowlist gets committed.
|
|
504
|
+
|
|
505
|
+
`scripts/auditar_npm.py` measures the repo instead of asking about it — standard-library Python, nothing to install, writes nothing:
|
|
506
|
+
|
|
507
|
+
```bash
|
|
508
|
+
python3 <SKILL_DIR>/scripts/auditar_npm.py # the repo in the current directory
|
|
509
|
+
python3 <SKILL_DIR>/scripts/auditar_npm.py ~/code/foo
|
|
510
|
+
python3 <SKILL_DIR>/scripts/auditar_npm.py --no-network
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
It reports tokens sitting in `~/.npmrc` or the project `.npmrc`, Actions secrets that look like npm credentials **and whether any workflow still references them** (one that does not is an orphaned live credential), how each publishing workflow authenticates, `package.json` and `.claude-plugin/plugin.json` versions that have drifted apart, shields.io badges pointing at a package name that does not exist, dependencies that would prompt for approval on npm v12, and the local npm version. Secret *names* are all it reads; a value is never printed.
|
|
514
|
+
|
|
515
|
+
The badge check earns its place from this repo's own history: three badges asked for `aiskills` while the package publishes as `@maccesar/aiskills`, so shields.io rendered "package not found" instead of an error and the downloads badge hid a real 568/month for months.
|
|
516
|
+
|
|
517
|
+
| Reference file | Covers |
|
|
518
|
+
| --- | --- |
|
|
519
|
+
| `authentication.md` | the timeline, session-based login, the three token types, the 2FA-bypass phases with dates, the npmjs.com banner, and which paths remain |
|
|
520
|
+
| `trusted-publishing.md` | the publisher form field by field, `id-token: write`, the Node/npm minimums, provenance, the version guard, and the mistakes that break a registration |
|
|
521
|
+
| `install-defaults.md` | npm v12 defaults, `npm approve-scripts`, the committed allowlist, which packages break, and the `ignore-scripts` trap |
|
|
522
|
+
| `migration.md` | the once-per-project procedure in order, what only the package owner can do, and the cleanup the old flow leaves behind |
|
|
523
|
+
| `verification.md` | the command behind each claim — including why `npm view` reports a stale version and what npmjs.com shows when OIDC worked |
|
|
524
|
+
|
|
525
|
+
`assets/publish.yml` is the workflow template, commented line by line.
|
|
526
|
+
|
|
527
|
+
How to invoke it — in whatever words you'd use anyway:
|
|
528
|
+
|
|
529
|
+
```
|
|
530
|
+
"¿por qué me pide login cada vez que publico?"
|
|
531
|
+
"I want to publish from GitHub Actions instead of my laptop"
|
|
532
|
+
"¿qué es esto del bypass 2FA que me sale en npmjs?"
|
|
533
|
+
"npm install stopped running postinstall after I upgraded"
|
|
534
|
+
"cómo quito el NPM_TOKEN de este repo"
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
496
539
|
## CLI reference
|
|
497
540
|
|
|
498
541
|
### aiskills list
|
|
@@ -688,19 +731,21 @@ Skills are plain Markdown files. To add a new one:
|
|
|
688
731
|
|
|
689
732
|
### Skill frontmatter format
|
|
690
733
|
|
|
734
|
+
Only the six fields the [agentskills.io specification](https://agentskills.io/specification) defines: `name` and `description` are required; `license`, `compatibility`, `metadata` and `allowed-tools` are optional. Anything else is ignored by some agents and rejected by others.
|
|
735
|
+
|
|
691
736
|
```yaml
|
|
692
737
|
---
|
|
693
738
|
name: skill-name
|
|
694
|
-
description:
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
- Bullet list of trigger conditions
|
|
698
|
-
source: "Book title, documentation name, or other authoritative source"
|
|
699
|
-
anti_hallucination_note: >
|
|
700
|
-
What the AI should NOT do (invent, supplement, guess).
|
|
739
|
+
description: 'What this skill does, the words a user would actually say when they need it, and — after "Not for:" — the neighbouring tasks it should not answer.'
|
|
740
|
+
allowed-tools: Read, Grep, Glob, Bash # only if the skill needs more than reading
|
|
741
|
+
compatibility: Requires Python 3 # only if it has real environment requirements
|
|
701
742
|
---
|
|
702
743
|
```
|
|
703
744
|
|
|
745
|
+
The `description` is the only part an assistant reads when deciding whether to load the skill, so every trigger belongs in it. A `## When to use` section in the body cannot affect that decision — it loads after the decision was made — and costs context on every invocation.
|
|
746
|
+
|
|
747
|
+
**The `description` must stay under 1024 characters** and the `name` under 64, both enforced by `test/manifest.test.js`. Past those limits some agents fail to load the skill at all. The block as a whole has no limit, so an optional field costs nothing against the description's budget.
|
|
748
|
+
|
|
704
749
|
### Guidelines
|
|
705
750
|
- Every skill must cite a specific source (book, official docs, specification)
|
|
706
751
|
- No invented numbers, rules, or advice not found in the source
|
package/commands/release.md
CHANGED
|
@@ -77,6 +77,9 @@ Do all of this **internally**. Do not print a status summary or any "Step 1" hea
|
|
|
77
77
|
- `*.podspec` → CocoaPods (edit `s.version`)
|
|
78
78
|
- None → **versionless** mode (commit + push + optional tag).
|
|
79
79
|
|
|
80
|
+
**Then check for secondary version files.** They accompany the primary one, they do not replace it, and they must reach the **same** number in the **same** commit:
|
|
81
|
+
- `.claude-plugin/plugin.json` → a Claude Code plugin manifest. Claude Code compares this `"version"` field to decide whether to invalidate its cached copy of the plugin, so a repo that ships both to npm and to a plugin marketplace can publish new code while marketplace users keep running the old one indefinitely. This is not hypothetical: a project shipped 2.6.0 to npm with `plugin.json` still reading 3.0.0, and the marketplace announced a version that did not exist. If the repo also has a CI job that compares the tag against its version files, an unbumped `plugin.json` fails the release outright.
|
|
82
|
+
|
|
80
83
|
2. **Detect CHANGELOG strategy:** if `CHANGELOG.md` exists and has `## [Unreleased]` → **promote mode**; if exists without it → **generate mode**; if missing → **skip CHANGELOG entirely** (do not create one without asking).
|
|
81
84
|
|
|
82
85
|
3. **Detect README version references:** grep `README.md` for the current version string and `v\d+\.\d+` badge patterns. Note locations that need updating, or note that none exist.
|
|
@@ -143,7 +146,12 @@ Do all of this **internally**. Do not print a status summary or any "Step 1" hea
|
|
|
143
146
|
|
|
144
147
|
In `private` mode these flags are **not** raised — tag and GitHub release are skipped by default anyway, so there is nothing to confirm.
|
|
145
148
|
|
|
146
|
-
9. **
|
|
149
|
+
9. **Detect what the pushed tag will trigger.** Read `.github/workflows/*.yml` (if the directory exists) and look for a workflow whose `on:` block includes `push:` with `tags:` matching `v*` **and** whose steps publish (`npm publish`, `gh release`, `cargo publish`, `twine upload`, or similar).
|
|
150
|
+
|
|
151
|
+
- **A publishing workflow exists** → Phase 3's `git push origin vX.Y.Z` is not just a label, it **starts a publication**. Hold the workflow's filename for Step 4 and Phase 5; the user is entitled to know that confirming ships the package, not just tags it.
|
|
152
|
+
- **The project is npm (a `package.json` with a `name` and no `"private": true`) and no such workflow exists** → hold a one-line recommendation for Step 4. Publishing then still requires an interactive `npm login`, which since December 2025 opens a two-hour session rather than writing a durable token. If the `npm-supply-chain` skill is available in this session, name it as the way to set trusted publishing up. **Do not invoke it and do not create the workflow here** — that is a once-per-project migration that also needs the package owner's 2FA on npmjs.com, and it has no business happening in the middle of a release the user is about to confirm.
|
|
153
|
+
|
|
154
|
+
10. **Note genuine anomalies** for Step 4: no remote, no `gh`, README/CHANGELOG languages disagree, an excluded file is borderline. **Do not** flag a dirty working tree itself — that's expected. **Do not** flag "branch is not main" as an anomaly either — handle it via the dedicated merge prompt below. **Do not** flag "no prior tags" or "no prior releases" as an anomaly on a private repo — in private mode we're not creating either anyway, so it's not a question. On public mode the `first-tag` / `first-release` flags from Step 1.8 are surfaced as their own ⚠️ lines (see Step 4 Part C), not in the generic anomalies list.
|
|
147
155
|
|
|
148
156
|
---
|
|
149
157
|
|
|
@@ -277,8 +285,10 @@ CHANGELOG entry:
|
|
|
277
285
|
- ...
|
|
278
286
|
|
|
279
287
|
README updates (gaps found): <one-line per gap, e.g. "add /release row to Available commands table + new section"> ← omit this line if no gaps
|
|
280
|
-
Release commit: bumps <version-file> A.B.C→X.Y.Z, inserts CHANGELOG section, applies README updates. Subject: `<exact line>`.
|
|
288
|
+
Release commit: bumps <version-file> A.B.C→X.Y.Z (+ <secondary version file> to the same number), inserts CHANGELOG section, applies README updates. Subject: `<exact line>`.
|
|
281
289
|
Push: release commit to <branch>.
|
|
290
|
+
Publishing: pushing the tag triggers `<workflow>.yml`, which publishes to <registry>. ← include ONLY when Step 1.9 found a tag-triggered publishing workflow; localize to user's language
|
|
291
|
+
Sin publicación automática: este repo publica a mano (`npm login` abre una sesión de dos horas). El skill `npm-supply-chain` monta trusted publishing si te interesa — no lo toco aquí. ← include ONLY when Step 1.9 found an npm project with no publishing workflow; localize to user's language
|
|
282
292
|
<one of the three rendering modes below — pick by visibility>
|
|
283
293
|
• Public / internal repo: `Tag + GitHub release: vX.Y.Z to <branch> + release with CHANGELOG notes.`
|
|
284
294
|
• Private repo (default): `Repo privado → omitiendo tag y GitHub release. (responde "con tag" si quieres crear el tag de todos modos)` — localize to user's language.
|
|
@@ -331,7 +341,7 @@ After Phase 1, the working tree should be clean except for files explicitly list
|
|
|
331
341
|
|
|
332
342
|
**Phase 2 — Release commit.**
|
|
333
343
|
|
|
334
|
-
1. **Bump the version file
|
|
344
|
+
1. **Bump the version file** (and every secondary version file found in Step 1.1, to the identical number — `.claude-plugin/plugin.json` with `Edit`):
|
|
335
345
|
- npm: `npm version <patch|minor|major> --no-git-tag-version`
|
|
336
346
|
- tiapp.xml: `Edit` only the **top-level** `<version>` element. Verify no `<module version=...>` matches in your edit.
|
|
337
347
|
- composer.json: `Edit` `"version"` if present; skip if absent.
|
|
@@ -345,7 +355,7 @@ After Phase 1, the working tree should be clean except for files explicitly list
|
|
|
345
355
|
- The documentation gap patches (new command rows, new sections, updated tables) so the README reflects the user-visible surface being shipped in this release.
|
|
346
356
|
- Plus any version badge / install snippet that referenced the old version. Apply with `Edit` per location. Skip only if Step 1.6 found nothing.
|
|
347
357
|
|
|
348
|
-
4. **Stage exactly the release-commit files** (the bumped version file + lockfile if `npm version` updated it + `CHANGELOG.md` + `README.md` if changed). Use explicit `git add <paths>`, not `git add -A`.
|
|
358
|
+
4. **Stage exactly the release-commit files** (the bumped version file + any secondary version file + lockfile if `npm version` updated it + `CHANGELOG.md` + `README.md` if changed). Use explicit `git add <paths>`, not `git add -A`.
|
|
349
359
|
|
|
350
360
|
5. **Commit:** `git commit -m "chore(release): vX.Y.Z"` (or the project's release-commit convention).
|
|
351
361
|
|
|
@@ -381,7 +391,15 @@ If `mode=none` (default), skip this phase entirely.
|
|
|
381
391
|
|
|
382
392
|
If a Phase 4 step fails, the release itself is already shipped (Phases 1–3 succeeded). Surface the merge/PR failure as a non-fatal note in the final report — do not unwind the release.
|
|
383
393
|
|
|
384
|
-
**Phase 5 —
|
|
394
|
+
**Phase 5 — Wait for the publish, then report (one line plus URL).**
|
|
395
|
+
|
|
396
|
+
If Step 1.9 found a tag-triggered publishing workflow and the tag was pushed, the release is not finished when `git push` returns — the workflow is what publishes, and it can still fail on a version guard, on tests, or on a registry credential that was never configured. Wait for it before reporting:
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
gh run watch --exit-status # or: gh run list --workflow=<workflow>.yml --limit 1
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Report its outcome in the final line. If it failed, say so plainly with the reason and the run URL — the commits and the tag are already landed, so this is information, not something to unwind. Skip this entirely when no such workflow exists or `gh` is unavailable.
|
|
385
403
|
|
|
386
404
|
```
|
|
387
405
|
✓ vX.Y.Z → <release URL>
|
|
@@ -389,6 +407,8 @@ If a Phase 4 step fails, the release itself is already shipped (Phases 1–3 suc
|
|
|
389
407
|
```
|
|
390
408
|
|
|
391
409
|
The "optional note" only appears if something was non-routine — examples:
|
|
410
|
+
- `published by <workflow>.yml` (when the tag triggered a publish and the run went green)
|
|
411
|
+
- `<workflow>.yml failed: <reason> — <run URL>` (the tag and commits are landed; the publish is not)
|
|
392
412
|
- `merged to main; now on <main-branch>` (when Phase 4 ran successfully with mode=merge; include the new main HEAD short hash)
|
|
393
413
|
- `PR opened: <pr-url>` (when Phase 4 ran with mode=pr)
|
|
394
414
|
- `merge to main aborted: main has diverged — resolve manually`
|
package/lib/config.js
CHANGED
package/package.json
CHANGED
package/skills/humaniza/SKILL.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: humaniza
|
|
3
3
|
description: 'Editor de estilo para textos en español (especialmente es-MX): quita los tics de escritura de IA y devuelve prosa natural, concreta y directa, sin cambiar el contenido. Úsalo siempre que alguien quiera revisar, pulir o reescribir un texto en español —emails, documentación, marketing, soporte, posts, textos técnicos— aunque nunca diga "humanizar": "esto suena a ChatGPT", "quítale lo robótico", "hazlo más natural", "que no parezca IA", "sonó muy acartonado", "límale el tono", "revísame este correo antes de mandarlo". También cuando el texto mismo trae las señales: rayas por todas partes, "no es X, sino Y", "cabe destacar", listas de tres, abridores como "la verdad es que". No es para: traducir, corregir solo ortografía o gramática, escribir un texto desde cero, ni editar textos en inglés.'
|
|
4
4
|
allowed-tools: Read, Write, Edit, Grep, Glob, Bash, AskUserQuestion
|
|
5
|
+
compatibility: Requires Python 3 (standard library only) for the pattern checker.
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
# Humaniza
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: npm-supply-chain
|
|
3
|
+
description: 'Publishing to npm and installing from it after the 2025–2026 supply-chain changes: why `npm login` now expires after two hours, classic tokens being gone, granular tokens capped at 90 days and losing direct publish around January 2027, and trusted publishing (OIDC) from GitHub Actions — no stored secret, provenance for free. Also the npm v12 install defaults, where lifecycle scripts, git dependencies and remote tarballs stay off until approved. Use when someone asks why npm demands a login on every publish, wants to publish from CI or GitHub Actions, asks about an NPM_TOKEN, a 2FA-bypass banner on npmjs.com, an OTP prompt, provenance, an `npm install` that broke or stopped running postinstall after an upgrade, or a tag that pushed and never published. Not for: choosing the version number, writing the CHANGELOG, or the release flow itself — that is `/release`.'
|
|
4
|
+
allowed-tools: Read, Grep, Glob, Bash, Edit, Write, AskUserQuestion
|
|
5
|
+
compatibility: Requires Python 3 (standard library only). The audit reads more when git and the gh CLI are available, and degrades cleanly without them.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# npm Supply Chain
|
|
9
|
+
|
|
10
|
+
How a package gets onto npm and what happens when one comes off it. Two halves of the same registry, both rewritten between November 2025 and July 2026: the credentials that used to sit in a file forever are gone, and the install that used to run a stranger's code by default no longer does.
|
|
11
|
+
|
|
12
|
+
Ground every answer in the reference files. This is the area where remembered knowledge is most likely to be a year out of date — the token type you remember may have been revoked, and the flag you remember may now default the other way.
|
|
13
|
+
|
|
14
|
+
Respond in the user's language. This skill is written in English for portability; the report should match whatever language the user is writing in.
|
|
15
|
+
|
|
16
|
+
## Required workflow (read before responding)
|
|
17
|
+
|
|
18
|
+
This SKILL.md is an **index**. The dates, the exact fields, the commands and the failure modes live in the reference files. **Reading this file alone is not enough.**
|
|
19
|
+
|
|
20
|
+
### Step 1 — Open the relevant reference files
|
|
21
|
+
|
|
22
|
+
| Task involves | Required reading |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `npm login` expiring, session-based auth, classic tokens, granular access tokens, the 90-day cap, 2FA-bypass deprecation, the npmjs.com banner, which actions require interactive 2FA | [references/authentication.md](references/authentication.md) |
|
|
25
|
+
| Publishing from GitHub Actions, OIDC, the trusted-publisher form, `id-token: write`, Node/npm minimums, provenance, `NODE_AUTH_TOKEN` | [references/trusted-publishing.md](references/trusted-publishing.md) |
|
|
26
|
+
| npm v12 install behavior, `allowScripts`, `--allow-git`, `--allow-remote`, `npm approve-scripts`, the committed allowlist, packages that break | [references/install-defaults.md](references/install-defaults.md) |
|
|
27
|
+
| Moving a package off token auth: the order of operations, testing the guard, deleting orphaned secrets, cleaning `~/.npmrc` | [references/migration.md](references/migration.md) |
|
|
28
|
+
| Proving any of the above instead of assuming it: `gh api`, `gh secret list`, reading provenance, querying the registry directly, what npmjs.com shows when OIDC worked | [references/verification.md](references/verification.md) |
|
|
29
|
+
|
|
30
|
+
### Step 2 — Output contract
|
|
31
|
+
|
|
32
|
+
Every date, command, field name, version minimum, or registry behavior you cite carries its citation inline:
|
|
33
|
+
|
|
34
|
+
`[source: references/<file>.md]`
|
|
35
|
+
|
|
36
|
+
The citation is what separates a value you read from one that merely sounded right — written down, the two look identical, and the reader has no way to tell them apart. Cite while writing rather than collecting sources at the end, because by then you are reconstructing where something came from instead of recording it.
|
|
37
|
+
|
|
38
|
+
Example: *"Trusted publishing needs Node ≥ 22.14.0 and npm ≥ 11.5.1 on the runner [source: references/trusted-publishing.md]"*
|
|
39
|
+
|
|
40
|
+
### Step 3 — If you must answer from memory
|
|
41
|
+
|
|
42
|
+
If you write a claim without having read the reference that backs it, prepend `FROM_MEMORY (unverified):` to that claim. Do not hide it. Dates in this domain moved three times in eighteen months, and a confidently wrong one sends someone to build a credential that no longer works.
|
|
43
|
+
|
|
44
|
+
### Banned behaviors
|
|
45
|
+
|
|
46
|
+
- **Inventing a date, a version minimum, or a CLI flag that is not in the references.** The whole point of a dated reference is that "npm requires…" was true of a different npm; a plausible answer here costs the user a broken release, not a wrong opinion.
|
|
47
|
+
- **Recommending a long-lived publish token as the default.** It is the shape the registry is actively retiring, and pointing someone at it builds something they have to dismantle within months.
|
|
48
|
+
- **Reading a secret's value, or writing one into a file, a commit or a log.** You may list secret *names* and check whether anything references them; the value is never yours to handle. A token that appears in a transcript is a token that must be revoked.
|
|
49
|
+
- **Changing a package's npm settings on the user's behalf.** Registering a trusted publisher, editing token settings and changing package access all require an interactive 2FA challenge by design — hand over the exact steps and let the user do them.
|
|
50
|
+
- **Marking the answer complete without listing which reference files you read.** The list is what lets the reader tell a grounded answer from a remembered one.
|
|
51
|
+
|
|
52
|
+
## Measure the repo before advising it
|
|
53
|
+
|
|
54
|
+
Run the auditor rather than asking the user what their setup is — most of what matters is readable from disk and from `gh`:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python3 <SKILL_DIR>/scripts/auditar_npm.py # the repo in the current directory
|
|
58
|
+
python3 <SKILL_DIR>/scripts/auditar_npm.py ~/code/foo # somewhere else
|
|
59
|
+
python3 <SKILL_DIR>/scripts/auditar_npm.py --no-network
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Replace `<SKILL_DIR>` with the absolute "Base directory for this skill" from the system message that loaded this skill — the working directory here is the user's project, not this one, so a relative `scripts/…` resolves to nothing. The path also differs by install type (`~/.claude/plugins/cache/<plugin>/<version>/skills/npm-supply-chain` for a plugin install, `~/.agents/skills/npm-supply-chain` standalone), so read it rather than assuming it.
|
|
63
|
+
|
|
64
|
+
It reports: whether `~/.npmrc` and the project `.npmrc` carry a token and whether the registry still accepts it, Actions secrets that look like npm credentials and whether any workflow uses them, which workflows publish and how they authenticate, whether `package.json` and `.claude-plugin/plugin.json` agree on a version, shields.io badges pointing at a package name that does not exist, dependencies with install-time scripts, and the local npm version against v12.
|
|
65
|
+
|
|
66
|
+
It writes nothing. Everything it finds is a proposal for the user to approve, the same separation `seo-launch` keeps between auditing and installing.
|
|
67
|
+
|
|
68
|
+
## The shape of the answer
|
|
69
|
+
|
|
70
|
+
1. **Report what is actually there** — the auditor's output, not a generic checklist.
|
|
71
|
+
2. **Name the deadline that applies**, with its date and source. "This stops working" is only actionable with a when.
|
|
72
|
+
3. **Propose one path**, not a menu. For automated publishing the path is trusted publishing; say so and give the reason rather than laying out three options of which two are dead ends [source: references/authentication.md].
|
|
73
|
+
4. **Split the work by who can do it.** Everything in the repo is yours; everything on npmjs.com needs the user's interactive 2FA [source: references/migration.md].
|
|
74
|
+
5. **Verify afterwards against the registry**, not against the local CLI, and say which command proved it [source: references/verification.md].
|
|
75
|
+
|
|
76
|
+
## Anti-Patterns
|
|
77
|
+
|
|
78
|
+
**Credentials**
|
|
79
|
+
|
|
80
|
+
- Storing a publish token in `~/.npmrc` and treating it as permanent — classic tokens were removed in November 2025 and revoked in December, so the file usually holds a dead string that produces a confusing 401 long before any 2FA prompt [source: references/authentication.md]
|
|
81
|
+
- Reaching for a 2FA-bypass granular token to "stop the login prompts" — it caps at 90 days, it already lost account and package management in July 2026, and it loses direct publish around January 2027 [source: references/authentication.md]
|
|
82
|
+
- Leaving an `NPM_TOKEN` secret in a repo whose workflow no longer uses it. It is a live credential with nothing watching it [source: references/migration.md]
|
|
83
|
+
- Assuming the npmjs.com 2FA-bypass banner is about your account. It is a site-wide campaign notice; the Access Tokens page is what answers whether it applies to you [source: references/authentication.md]
|
|
84
|
+
|
|
85
|
+
**Trusted publishing**
|
|
86
|
+
|
|
87
|
+
- Omitting `permissions: id-token: write`. Without it there is no OIDC token to mint, `npm publish` falls back to token auth, and the failure reads like a credentials problem rather than a missing permission [source: references/trusted-publishing.md]
|
|
88
|
+
- Typing the local folder name into the publisher's `Repository` field. The claim is matched against the repository's canonical name on GitHub, which is not always how the folder is spelled on disk [source: references/trusted-publishing.md]
|
|
89
|
+
- Registering the publisher before the workflow file exists on the default branch, or renaming the workflow afterwards. The registration names the file, so the rename breaks publishing until npmjs.com is updated to match [source: references/trusted-publishing.md]
|
|
90
|
+
- Setting `NODE_AUTH_TOKEN` alongside OIDC "just in case". It puts the publish back on token auth and silently gives up provenance [source: references/trusted-publishing.md]
|
|
91
|
+
- Running the publish job on a Node old enough that the npm shipped with it cannot do OIDC — it degrades to token auth instead of erroring clearly [source: references/trusted-publishing.md]
|
|
92
|
+
|
|
93
|
+
**Installing**
|
|
94
|
+
|
|
95
|
+
- Upgrading to npm v12 and reading the missing `postinstall` as a broken package. The scripts are off by default now; `npm approve-scripts` is what turns the intended ones back on [source: references/install-defaults.md]
|
|
96
|
+
- Approving every script the prompt lists to make CI green. The allowlist is a security decision that gets committed and inherited by the whole team [source: references/install-defaults.md]
|
|
97
|
+
- Assuming a clean local install means a clean CI install when `ignore-scripts=true` sits in a personal `~/.npmrc`. The machine has been running v12 behavior for years and CI has not [source: references/install-defaults.md]
|
|
98
|
+
|
|
99
|
+
**Verifying**
|
|
100
|
+
|
|
101
|
+
- Reporting `npm view <pkg> version` as the state of the registry. The CLI caches it, and it will happily report the previous version minutes after a successful publish [source: references/verification.md]
|
|
102
|
+
- Calling a release published because the tag pushed. The tag starts a workflow; the workflow is what publishes, and it can fail on the version guard, on tests, or on a publisher that was never registered [source: references/verification.md]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
# Publishes when a vX.Y.Z tag is pushed — which is the last thing a release does.
|
|
4
|
+
# Authentication is trusted publishing (OIDC): no NPM_TOKEN, no secrets, no 2FA prompt.
|
|
5
|
+
# The trusted publisher registered on npmjs.com must point at this exact filename,
|
|
6
|
+
# so renaming this file breaks publishing until the registration is updated.
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags:
|
|
11
|
+
- 'v*'
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
id-token: write # required for OIDC — without it npm publish falls back to token auth
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
publish:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
|
|
23
|
+
- uses: actions/setup-node@v7
|
|
24
|
+
with:
|
|
25
|
+
node-version: '24' # trusted publishing needs Node >= 22.14.0 and npm >= 11.5.1
|
|
26
|
+
registry-url: 'https://registry.npmjs.org'
|
|
27
|
+
# No NODE_AUTH_TOKEN anywhere: setting it puts the publish back on token
|
|
28
|
+
# auth and gives up provenance.
|
|
29
|
+
|
|
30
|
+
# A tag and a manifest can disagree, and npm would publish the manifest's
|
|
31
|
+
# version under a tag that says something else. This fails before npm ci, so
|
|
32
|
+
# a mismatched tag never reaches the registry. Add one line per version file
|
|
33
|
+
# the repo carries — drop the plugin.json block if there is no Claude Code
|
|
34
|
+
# plugin here.
|
|
35
|
+
- name: Verify the tag matches every version file
|
|
36
|
+
run: |
|
|
37
|
+
echo "node $(node --version) / npm $(npm --version)"
|
|
38
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
39
|
+
pkg=$(node -p "require('./package.json').version")
|
|
40
|
+
plugin=$(node -p "require('./.claude-plugin/plugin.json').version")
|
|
41
|
+
echo "tag=$tag package.json=$pkg plugin.json=$plugin"
|
|
42
|
+
[ "$tag" = "$pkg" ] || { echo "::error::tag $tag does not match package.json $pkg"; exit 1; }
|
|
43
|
+
[ "$tag" = "$plugin" ] || { echo "::error::tag $tag does not match plugin.json $plugin"; exit 1; }
|
|
44
|
+
|
|
45
|
+
- run: npm ci
|
|
46
|
+
|
|
47
|
+
- run: npm test
|
|
48
|
+
|
|
49
|
+
# npm attaches provenance automatically when the publish is authenticated by
|
|
50
|
+
# OIDC: the published version links back to this commit and this run.
|
|
51
|
+
- run: npm publish
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
What proves to npm that a publish is yours. Every long-lived option in this space was removed or put on a clock between November 2025 and January 2027, so the answer to "how do I stop typing my password" is different from what it was a year ago.
|
|
4
|
+
|
|
5
|
+
## The timeline
|
|
6
|
+
|
|
7
|
+
| When | What changed |
|
|
8
|
+
|---|---|
|
|
9
|
+
| November 2025 | Classic tokens removed from npm |
|
|
10
|
+
| December 2025 | Classic tokens revoked; `npm login` starts issuing a **two-hour session** instead of a token; CLI token management lands |
|
|
11
|
+
| 8 July 2026 | npm v12 goes GA with the install-time defaults, and the 2FA-bypass GAT deprecation is announced |
|
|
12
|
+
| 31 July 2026 | **Phase 1 applied**: 2FA-bypass tokens lose account, package and organization management |
|
|
13
|
+
| ~January 2027 | **Phase 2 expected**: 2FA-bypass tokens lose the ability to publish directly |
|
|
14
|
+
|
|
15
|
+
Sources: [npm classic tokens revoked, session-based auth and CLI token management](https://github.blog/changelog/2025-12-09-npm-classic-tokens-revoked-session-based-auth-and-cli-token-management-now-available/), [npm install-time security and GAT bypass2fa deprecation](https://github.blog/changelog/2026-07-08-npm-install-time-security-and-gat-bypass2fa-deprecation/), [Restricting npm bypass-2FA granular access tokens](https://github.blog/changelog/2026-07-31-restricting-npm-bypass-2fa-granular-access-tokens/).
|
|
16
|
+
|
|
17
|
+
## Why `npm login` keeps coming back
|
|
18
|
+
|
|
19
|
+
Since December 2025 `npm login` does not write a durable token. It opens a **session that lasts two hours**. Publish inside that window or authenticate again. This is not a token expiring early and there is no setting that extends it — it is the mechanism that replaced classic tokens.
|
|
20
|
+
|
|
21
|
+
The common symptom is a `401 Unauthorized` from `npm whoami` on a machine that "was logged in", because `~/.npmrc` still holds an `_authToken` line from the old regime that the registry no longer honors. `npm logout` clears it; the stale line is otherwise harmless and only produces confusing errors.
|
|
22
|
+
|
|
23
|
+
## The three token types, and which are alive
|
|
24
|
+
|
|
25
|
+
- **Classic tokens** — the long-lived, non-expiring strings people pasted into `.npmrc` and forgot. **Removed November 2025, revoked December 2025.** No flag, option or command brings them back.
|
|
26
|
+
- **Granular access tokens (GATs)** — the only token type still issued. Scoped to specific packages or organizations, with an expiry. **Tokens with write access cap at 90 days**, so a publish token has to be rotated four times a year.
|
|
27
|
+
- **GATs configured to bypass 2FA** — the subset built for unattended CI publishing. These are the ones being retired in two phases (below).
|
|
28
|
+
|
|
29
|
+
## The 2FA-bypass retirement, in two phases
|
|
30
|
+
|
|
31
|
+
**Phase 1 — already in effect since 31 July 2026.** A 2FA-bypass GAT can no longer perform sensitive account, package and organization management. Concretely: creating or deleting tokens; generating recovery codes; changing your password, email, profile or 2FA configuration; changing package access, maintainers, or **the trusted publishing configuration**; managing organization and team membership or their package grants. All of those now require an interactive 2FA challenge.
|
|
32
|
+
|
|
33
|
+
**Phase 2 — expected around January 2027.** The same tokens lose the ability to publish directly. Their publishing surface shrinks to reading private packages and *staging* a publish, where the package only becomes public after a human approves it with 2FA.
|
|
34
|
+
|
|
35
|
+
**What this does not touch:** GitHub personal access tokens, GitHub App tokens, and the `GITHUB_TOKEN` that Actions injects. The restriction is specific to npm granular access tokens. This clarification came with the 31 July post and was not in the 8 July announcement.
|
|
36
|
+
|
|
37
|
+
## The banner on npmjs.com
|
|
38
|
+
|
|
39
|
+
The 2FA-bypass notice shown when you log into npmjs.com is a **site-wide campaign banner**, not a diagnosis of the account looking at it. It appears whether or not you own a single bypass token.
|
|
40
|
+
|
|
41
|
+
To answer whether it applies: npmjs.com → avatar → **Access Tokens**, and read the **Bypass 2FA** column. An empty list, or a list where that column is blank on every row, means the deprecation does not touch the account at all. Expired tokens still show in that list and are worth deleting — a token that expired months ago is noise, and noise is what hides the one that matters.
|
|
42
|
+
|
|
43
|
+
## The three remaining paths
|
|
44
|
+
|
|
45
|
+
| Option | Friction per release | Lifetime |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| **A. Interactive** — `npm login`, then publish | one login (plus an OTP per publish) | indefinite |
|
|
48
|
+
| **B. GAT with 2FA bypass** in `~/.npmrc` | none | **90 days max**, and direct publish dies ~January 2027 |
|
|
49
|
+
| **C. Trusted publishing (OIDC)** from Actions | none | indefinite; the registry's own recommendation |
|
|
50
|
+
|
|
51
|
+
**B is the option the deprecation exists to discourage**: a long-lived secret on disk, renewable four times a year, with a known end date. Building it now means dismantling it within months.
|
|
52
|
+
|
|
53
|
+
**C is the only one that returns zero friction without an expiry.** See [trusted-publishing.md](trusted-publishing.md). A is a perfectly good fallback and stays available forever — if OIDC fails for any reason, `npm login` and a manual publish still work.
|
|
54
|
+
|
|
55
|
+
## Package-level hardening
|
|
56
|
+
|
|
57
|
+
npm can mark a package **"Require two-factor authentication and disallow tokens"**, which rejects any token regardless of its own configuration. On a package published by hand (option A) this is free protection. Its interaction with OIDC publishing is **not established here** — do not enable it on a package that publishes through trusted publishing without confirming the two coexist.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Install defaults in npm v12
|
|
2
|
+
|
|
3
|
+
npm v12 went generally available and became `latest` on 8 July 2026. It turns on the install-time security defaults announced in June: three things that used to happen automatically during `npm install` are now opt-in.
|
|
4
|
+
|
|
5
|
+
Source: [npm install-time security and GAT bypass2fa deprecation](https://github.blog/changelog/2026-07-08-npm-install-time-security-and-gat-bypass2fa-deprecation/).
|
|
6
|
+
|
|
7
|
+
## What changed
|
|
8
|
+
|
|
9
|
+
| Before | npm v12 |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Dependency lifecycle scripts (`preinstall`, `install`, `postinstall`) and implicit node-gyp builds ran automatically | **Do not run** unless explicitly allowed (`allowScripts` defaults to off) |
|
|
12
|
+
| Git dependencies (`github:user/repo`), direct or transitive, resolved | **Not resolved** unless allowed (`--allow-git` defaults to none) |
|
|
13
|
+
| Dependencies from remote URLs (an `https://` tarball) resolved | **Not resolved** unless allowed (`--allow-remote` defaults to none) |
|
|
14
|
+
|
|
15
|
+
The reason is the ecosystem's most exploited vector: a compromised package put its payload in a `postinstall` and ran it on your machine as a side effect of `npm install`. That code is now inert until someone approves it.
|
|
16
|
+
|
|
17
|
+
All three were available behind warnings from **npm 11.16.0**, so a project can be rehearsed against the new behavior before upgrading the major.
|
|
18
|
+
|
|
19
|
+
## The migration flow
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm approve-scripts --allow-scripts-pending
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It lists the packages asking to run install scripts. Approve the ones you trust, and **commit the resulting allowlist in `package.json`** so the team and CI inherit the same decision rather than each machine answering the prompt differently.
|
|
26
|
+
|
|
27
|
+
Two things about that allowlist: it is a security decision, not a build chore — approving everything the prompt offers to make CI green defeats the change entirely — and it is inherited, so whoever commits it is deciding for everyone.
|
|
28
|
+
|
|
29
|
+
## What actually breaks
|
|
30
|
+
|
|
31
|
+
Nothing in a dependency tree of pure JavaScript. The packages that notice are the ones that **download or compile a binary during install**: `sharp`, `puppeteer`, native CLI wrappers, anything using node-gyp. In a Laravel or Vite project the symptom shows up one step removed — assets that stop compiling after a clean `npm ci`.
|
|
32
|
+
|
|
33
|
+
The way to know without guessing is to run `npm approve-scripts --allow-scripts-pending` in each repo and read the list. A package whose `package.json` declares no `preinstall` / `install` / `postinstall` and pulls no node-gyp dependency has nothing to approve, and its users see no prompt at all.
|
|
34
|
+
|
|
35
|
+
## The trap: a personal `ignore-scripts`
|
|
36
|
+
|
|
37
|
+
A developer with `ignore-scripts=true` in `~/.npmrc` has been living with the main v12 behavior for years and will not notice the upgrade at all. Their CI, running with the default configuration, has not — so "it installs fine on my machine" is not evidence about the pipeline, in either direction.
|
|
38
|
+
|
|
39
|
+
Check both: the personal `~/.npmrc`, the project `.npmrc`, and what the CI job actually runs. Only the last one describes what users and the pipeline experience.
|
|
40
|
+
|
|
41
|
+
## As a package author
|
|
42
|
+
|
|
43
|
+
The question is what your *dependents* see. If none of your dependencies declares an install script, a user on npm v12 installs your package without a single approval prompt. This is worth measuring before publishing rather than after an issue arrives: the auditor in this skill reports it, and so does reading the `scripts` block of every installed `package.json`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Migrating a package to trusted publishing
|
|
2
|
+
|
|
3
|
+
Done once per package. The order matters, because two of the steps can only be done by the package owner with an interactive 2FA challenge, and one of them is easy to test cheaply before it can cost a bad publish.
|
|
4
|
+
|
|
5
|
+
## 1. Audit what is there
|
|
6
|
+
|
|
7
|
+
Before changing anything, establish the starting state — this is what `scripts/auditar_npm.py` reports, and each finding maps to a step below:
|
|
8
|
+
|
|
9
|
+
- **Tokens on disk**: `~/.npmrc` and any project `.npmrc`. An `_authToken` line from before December 2025 is dead weight producing confusing 401s [source: authentication.md].
|
|
10
|
+
- **Actions secrets**: anything named like an npm credential (`NPM_TOKEN` and variants). A secret no workflow references is orphaned — a live credential with nothing watching it.
|
|
11
|
+
- **Existing workflows**: which ones publish, and whether they authenticate with a token or with OIDC.
|
|
12
|
+
- **Version files**: `package.json`, and `.claude-plugin/plugin.json` if the repo also ships a Claude Code plugin. They feed the guard in step 2.
|
|
13
|
+
- **Install-time scripts** in the dependency tree, which is a different question but the same audit [source: install-defaults.md].
|
|
14
|
+
|
|
15
|
+
## 2. Add the workflow and push it
|
|
16
|
+
|
|
17
|
+
Copy `assets/publish.yml`, adjust the version files in the guard to match the repo, commit, push to the default branch. Details of each line are in [trusted-publishing.md](trusted-publishing.md).
|
|
18
|
+
|
|
19
|
+
The file must be on GitHub before the registration points at it, and before any tag is pushed.
|
|
20
|
+
|
|
21
|
+
## 3. Register the trusted publisher
|
|
22
|
+
|
|
23
|
+
npmjs.com → the package → **Settings → Trusted Publisher → GitHub Actions**, with the four fields from [trusted-publishing.md](trusted-publishing.md). Read the repository name from the GitHub API rather than from the local folder name — see [verification.md](verification.md) for the command.
|
|
24
|
+
|
|
25
|
+
**Only the package owner can do this**, interactively, with 2FA. It is one of the operations 2FA-bypass tokens lost in July 2026 [source: authentication.md]. Hand over the exact field values; do not attempt it on their behalf.
|
|
26
|
+
|
|
27
|
+
If the package is configured as *"Require two-factor authentication and disallow tokens"*, leave that setting alone. Whether it coexists with OIDC publishing is not established here.
|
|
28
|
+
|
|
29
|
+
## 4. Test the guard before trusting it
|
|
30
|
+
|
|
31
|
+
The version guard is the one part of the workflow that can be verified without publishing anything. Run its logic locally against a tag that should pass and one that should fail:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
tag="1.18.1"; node -p "require('./package.json').version" # expect a match
|
|
35
|
+
tag="9.9.9"; node -p "require('./package.json').version" # expect a mismatch → the job must exit 1
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A guard that passes everything is not a guard. Confirm both directions before relying on it — the same discipline as running a positive control on any measuring instrument.
|
|
39
|
+
|
|
40
|
+
## 5. First real release
|
|
41
|
+
|
|
42
|
+
Push a tag and watch the run rather than assuming it. The verification commands are in [verification.md](verification.md). Three things distinguish a real success from a plausible one: the workflow run is green, the registry (not the local CLI cache) reports the new version, and npmjs.com lists the publisher as **GitHub Actions** rather than a username.
|
|
43
|
+
|
|
44
|
+
If it fails, nothing is lost: `npm login` plus a manual publish is still the fallback, and it stays available permanently [source: authentication.md].
|
|
45
|
+
|
|
46
|
+
## 6. Clean up what the old flow left behind
|
|
47
|
+
|
|
48
|
+
- **Orphaned Actions secrets.** `gh secret delete NPM_TOKEN --repo <owner>/<repo>` once nothing references it. An unused publish credential is strictly a liability.
|
|
49
|
+
- **Dead `_authToken` in `~/.npmrc`.** `npm logout` removes it. Not dangerous, but it is what makes `npm whoami` answer 401 on a machine that looks logged in.
|
|
50
|
+
- **Empty workflow directories** left by a removed CI file, and any documentation that still tells the reader to run `npm publish` by hand or to set a token.
|
|
51
|
+
- **Expired tokens on npmjs.com.** They do nothing, and they hide the one entry that matters in the list.
|
|
52
|
+
|
|
53
|
+
Delete secrets only with the user's agreement, and never print a secret's value — listing names is enough to reason about orphans.
|
|
54
|
+
|
|
55
|
+
## What the release flow becomes
|
|
56
|
+
|
|
57
|
+
`/release` still does everything it did: semantic commits, version bump, CHANGELOG, tag, push. The publish is what moved — it now happens because the tag landed, not because someone typed `npm publish`. Nothing needs to run by hand, and the release is not finished until that workflow run is green.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Trusted publishing (OIDC)
|
|
2
|
+
|
|
3
|
+
The runner proves who it is instead of carrying a secret. GitHub Actions mints a short-lived OIDC credential describing the exact repository, workflow and ref that is running; npm checks that description against the trusted publisher registered for the package and, if it matches, accepts the publish. Nothing is stored anywhere: there is no token to rotate, no secret to leak, no session to keep alive.
|
|
4
|
+
|
|
5
|
+
Two consequences worth stating up front: **provenance is attached automatically** to a publish made this way — npm links the published version to the exact commit and workflow run — and the registry records the publisher as **GitHub Actions** rather than a person.
|
|
6
|
+
|
|
7
|
+
## The registration on npmjs.com
|
|
8
|
+
|
|
9
|
+
Per package, under **Settings → Trusted Publisher → GitHub Actions**:
|
|
10
|
+
|
|
11
|
+
| Field | Value |
|
|
12
|
+
|---|---|
|
|
13
|
+
| Organization or user | the GitHub owner, e.g. `macCesar` |
|
|
14
|
+
| Repository | the repository name **as GitHub spells it** |
|
|
15
|
+
| Workflow filename | the file's basename with extension, e.g. `publish.yml` |
|
|
16
|
+
| Environment | empty, unless the job declares `environment:` |
|
|
17
|
+
|
|
18
|
+
Three ways this goes wrong:
|
|
19
|
+
|
|
20
|
+
- **The `Repository` field is the canonical repository name, not the local folder name.** A project cloned into `~/Developer/TiTools` can live at `github.com/macCesar/titools`; the OIDC claim carries the canonical one, and a mismatch fails the publish with an authorization error that says nothing about capitalization. Read it from the API rather than from the filesystem — see [verification.md](verification.md).
|
|
21
|
+
- **The workflow filename must match exactly**, extension included. Renaming the file later breaks publishing until the registration on npmjs.com is updated, and the break surfaces only on the next release.
|
|
22
|
+
- **Registering it requires an interactive 2FA challenge.** Changing the trusted publishing configuration is one of the operations 2FA-bypass tokens lost on 31 July 2026 [source: authentication.md]. This is a step only the package owner can do; it cannot be automated or done on their behalf.
|
|
23
|
+
|
|
24
|
+
## Order of operations
|
|
25
|
+
|
|
26
|
+
1. Commit and **push the workflow to the default branch** first. The registration points at a file; the file should exist.
|
|
27
|
+
2. Register the publisher on npmjs.com.
|
|
28
|
+
3. Push a tag.
|
|
29
|
+
|
|
30
|
+
Doing 2 before 1 is not fatal, but it leaves a registration pointing at nothing and the first release fails for a reason that looks like the registration is wrong.
|
|
31
|
+
|
|
32
|
+
## The workflow
|
|
33
|
+
|
|
34
|
+
The template lives at `assets/publish.yml` in this skill. The parts that matter:
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
permissions:
|
|
38
|
+
contents: read
|
|
39
|
+
id-token: write # without this there is no OIDC token and npm falls back to token auth
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`id-token: write` is the whole switch. Omit it and the publish does not error with "you forgot a permission" — it degrades to looking for a token, finds none, and fails as a credentials problem.
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
- uses: actions/setup-node@v7
|
|
46
|
+
with:
|
|
47
|
+
node-version: '24'
|
|
48
|
+
registry-url: 'https://registry.npmjs.org'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Minimums: Node ≥ 22.14.0 and npm ≥ 11.5.1.** Older combinations do not know how to present the OIDC credential and quietly fall back to token auth. `registry-url` is what makes `setup-node` write the `.npmrc` pointing at the public registry.
|
|
52
|
+
|
|
53
|
+
**Do not set `NODE_AUTH_TOKEN`.** It is the environment variable `setup-node` wires up for token auth; present, it puts the publish back on a secret and gives up provenance. A trusted-publishing workflow has no `env:` block on the publish step and no `secrets.*` reference anywhere.
|
|
54
|
+
|
|
55
|
+
Pin the actions. `actions/checkout` and `actions/setup-node` are on v7 as of August 2026.
|
|
56
|
+
|
|
57
|
+
## The version guard
|
|
58
|
+
|
|
59
|
+
The template compares the pushed tag against every version file in the repo before publishing anything:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
63
|
+
pkg=$(node -p "require('./package.json').version")
|
|
64
|
+
[ "$tag" = "$pkg" ] || { echo "::error::tag $tag does not match package.json $pkg"; exit 1; }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
It exists because a tag and a manifest can disagree and npm will happily publish the manifest's version under a tag that says something else. In a repo that also ships a Claude Code plugin, `.claude-plugin/plugin.json` is a second version file and belongs in the same check — TiTools published 2.6.0 to npm while its `plugin.json` still read 3.0.0, and the marketplace announced a version that did not exist.
|
|
68
|
+
|
|
69
|
+
Costs one step, removes the whole class of mistake, and fails before `npm ci` so a bad tag never reaches the registry.
|
|
70
|
+
|
|
71
|
+
## What the release flow looks like afterwards
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
/release → creates the tag and pushes it → Actions publishes
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
No `npm login`, no two-hour session, no OTP. The human checkpoint does not disappear; it moves to the tag, which is still created deliberately after reviewing the diff.
|
|
78
|
+
|
|
79
|
+
If a publish fails for any reason, nothing is trapped: `npm login` and a manual `npm publish` remain available as they always were [source: authentication.md].
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Verification
|
|
2
|
+
|
|
3
|
+
Every claim in this area is checkable in one command, which is the reason none of them should be asserted from memory. This file is the list of those commands and, for each, what a wrong answer looks like.
|
|
4
|
+
|
|
5
|
+
## The repository's canonical name
|
|
6
|
+
|
|
7
|
+
The trusted publisher matches the repository as GitHub spells it, which is not necessarily how the folder is spelled on disk:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gh api repos/<owner>/<repo> --jq .full_name
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run it before handing the user any value to type into a form. A folder named `TiTools` whose repository is `macCesar/titools` produces an OIDC claim that never matches a registration typed from the filesystem, and the resulting failure says nothing about capitalization.
|
|
14
|
+
|
|
15
|
+
This is the general rule for anything the user has to copy by hand: verify the exact string first, then hand it over.
|
|
16
|
+
|
|
17
|
+
## Credentials, without reading them
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gh secret list --repo <owner>/<repo> # names and dates only, never values
|
|
21
|
+
grep -rl "secrets\." .github/workflows/ # which workflows reference any secret
|
|
22
|
+
npm whoami # 401 means the local session or token is dead
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A secret that appears in `gh secret list` and in no workflow is orphaned. Names and timestamps are enough to establish that; the value is never needed and must never be printed.
|
|
26
|
+
|
|
27
|
+
## Whether a workflow publishes, and how
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
grep -l "npm publish" .github/workflows/*.yml
|
|
31
|
+
grep -n "id-token\|NODE_AUTH_TOKEN\|NPM_TOKEN" .github/workflows/*.yml
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`id-token: write` present and no token reference means OIDC. A `NODE_AUTH_TOKEN` or `NPM_TOKEN` reference means token auth, regardless of what the file is named or what a comment claims.
|
|
35
|
+
|
|
36
|
+
## The registry, not the CLI cache
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm view <pkg> version # ← do not report this as the state of the registry
|
|
40
|
+
curl -s https://registry.npmjs.org/<pkg> | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['dist-tags'])"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**`npm view` is cached and it lies.** Measured on 2026-08-14: minutes after `@maccesar/aiskills` 1.18.1 published successfully, the local `npm view` still answered 1.18.0 while a direct request to the registry already returned 1.18.1. Reporting the cached answer as a failed publish sends someone to debug a release that worked.
|
|
44
|
+
|
|
45
|
+
For a scoped package the URL encodes the slash: `https://registry.npmjs.org/@maccesar%2Faiskills`.
|
|
46
|
+
|
|
47
|
+
## Provenance and the publisher
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm view <pkg> --json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('dist',{}).get('attestations'))"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The visual signal is stronger and takes one glance: on npmjs.com, the package's version list shows **`GitHub Actions`** in the publisher column where a manually published package shows a username. That is trusted publishing made visible — npm recorded that a workflow identified by OIDC uploaded the package — and it is what backs the provenance badge.
|
|
54
|
+
|
|
55
|
+
## The workflow run behind a tag
|
|
56
|
+
|
|
57
|
+
A pushed tag is not a publish. It starts a run, and the run can fail on the version guard, on the tests, or on a publisher that was never registered:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
gh run watch # follow the run started by the tag
|
|
61
|
+
gh run list --workflow=publish.yml --limit 5 # after the fact
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A release is finished when that run is green and the registry confirms the version — not when `git push origin v1.2.3` returns.
|
|
65
|
+
|
|
66
|
+
## Badges
|
|
67
|
+
|
|
68
|
+
A shields.io badge asks the registry for a package by name. If the name is wrong the badge renders "package not found" rather than erroring, so a broken badge survives for months:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
curl -s -o /dev/null -w "%{http_code}\n" https://registry.npmjs.org/<name-exactly-as-the-badge-spells-it>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The failure mode is specific to **scoped packages**: a README badge that says `npm/dm/aiskills` for a package published as `@maccesar/aiskills` looks plausible and reports nothing. It hid a real number here — 568 downloads per month — across three badges for months. An unscoped package cannot have this bug, since the badge name and the package name are the same string.
|
|
75
|
+
|
|
76
|
+
npmjs.com renders the README of the **published version**, so correcting a badge requires a release, not just a push to the default branch.
|
|
77
|
+
|
|
78
|
+
## Install-time scripts in the tree
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm approve-scripts --allow-scripts-pending # authoritative: what npm itself would ask about
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Reading each dependency's `scripts` block is the offline approximation and is what the auditor in this skill does; the command above is what npm actually evaluates [source: install-defaults.md].
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit how a repository publishes to npm and what it installs from it.
|
|
3
|
+
|
|
4
|
+
Reads the repo, the npm configuration and the GitHub Actions setup, and reports
|
|
5
|
+
the state of each: credentials on disk, orphaned Actions secrets, how the
|
|
6
|
+
publishing workflow authenticates, version files that disagree, README badges
|
|
7
|
+
pointing at a package that does not exist, dependencies that run scripts at
|
|
8
|
+
install time, and the local npm version against v12.
|
|
9
|
+
|
|
10
|
+
python3 auditar_npm.py # the repo in the current directory
|
|
11
|
+
python3 auditar_npm.py ~/code/foo
|
|
12
|
+
python3 auditar_npm.py --no-network # skip the registry and gh lookups
|
|
13
|
+
|
|
14
|
+
Writes nothing. Every finding is a proposal for the user to approve.
|
|
15
|
+
|
|
16
|
+
Standard library only: runs on any macOS or Linux with Python 3.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
OK, FALTA, REVISAR, INFO = "ok", "falta", "revisar", "info"
|
|
32
|
+
|
|
33
|
+
SIMBOLO = {OK: " ok ", FALTA: " MISS ", REVISAR: "CHECK ", INFO: " info "}
|
|
34
|
+
|
|
35
|
+
# Names that look like an npm publishing credential. Matched case-insensitively
|
|
36
|
+
# against secret names; the value of a secret is never read, here or anywhere.
|
|
37
|
+
PATRON_SECRETO_NPM = re.compile(r"NPM.*(TOKEN|AUTH|PUBLISH)|(TOKEN|AUTH).*NPM", re.I)
|
|
38
|
+
|
|
39
|
+
# The first npm version whose install-time defaults are the ones described in
|
|
40
|
+
# references/install-defaults.md.
|
|
41
|
+
NPM_V12 = 12
|
|
42
|
+
|
|
43
|
+
CONSULTAR_RED = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------- utilities
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def correr(comando: list[str], tiempo: int = 20) -> tuple[int, str]:
|
|
50
|
+
"""Run a command and return (exit code, stdout). 127 means it is not installed."""
|
|
51
|
+
try:
|
|
52
|
+
r = subprocess.run(
|
|
53
|
+
comando, capture_output=True, text=True, timeout=tiempo, check=False
|
|
54
|
+
)
|
|
55
|
+
return r.returncode, r.stdout.strip()
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
return 127, ""
|
|
58
|
+
except subprocess.TimeoutExpired:
|
|
59
|
+
return 124, ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def existe(programa: str) -> bool:
|
|
63
|
+
return correr([programa, "--version"], tiempo=10)[0] not in (127, 124)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def leer_json(ruta: Path) -> dict | None:
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(ruta.read_text(encoding="utf-8"))
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def leer_texto(ruta: Path) -> str:
|
|
74
|
+
try:
|
|
75
|
+
return ruta.read_text(encoding="utf-8", errors="replace")
|
|
76
|
+
except Exception:
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def pedir(url: str, tiempo: int = 10) -> int | None:
|
|
81
|
+
"""HTTP status of a URL, or None if it could not be reached."""
|
|
82
|
+
if not CONSULTAR_RED:
|
|
83
|
+
return None
|
|
84
|
+
peticion = urllib.request.Request(url, headers={"User-Agent": "auditar_npm"})
|
|
85
|
+
try:
|
|
86
|
+
with urllib.request.urlopen(peticion, timeout=tiempo) as r:
|
|
87
|
+
return r.status
|
|
88
|
+
except urllib.error.HTTPError as e:
|
|
89
|
+
return e.code
|
|
90
|
+
except Exception:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Reporte:
|
|
95
|
+
"""Collects findings by section and prints them once, so the output reads as
|
|
96
|
+
a report rather than as a log of the order the checks happened to run in."""
|
|
97
|
+
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
self.secciones: list[tuple[str, list[tuple[str, str, str]]]] = []
|
|
100
|
+
|
|
101
|
+
def abrir(self, titulo: str) -> None:
|
|
102
|
+
self.secciones.append((titulo, []))
|
|
103
|
+
|
|
104
|
+
def add(self, estado: str, etiqueta: str, detalle: str = "") -> None:
|
|
105
|
+
if not self.secciones:
|
|
106
|
+
self.abrir("General")
|
|
107
|
+
self.secciones[-1][1].append((estado, etiqueta, detalle))
|
|
108
|
+
|
|
109
|
+
def imprimir(self) -> int:
|
|
110
|
+
faltantes = 0
|
|
111
|
+
for titulo, filas in self.secciones:
|
|
112
|
+
if not filas:
|
|
113
|
+
continue
|
|
114
|
+
print(f"\n{titulo}")
|
|
115
|
+
print("-" * len(titulo))
|
|
116
|
+
for estado, etiqueta, detalle in filas:
|
|
117
|
+
faltantes += estado == FALTA
|
|
118
|
+
linea = f"[{SIMBOLO[estado]}] {etiqueta}"
|
|
119
|
+
if detalle:
|
|
120
|
+
linea += f" — {detalle}"
|
|
121
|
+
print(linea)
|
|
122
|
+
print()
|
|
123
|
+
return faltantes
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------- checks
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def revisar_npmrc(proyecto: Path, r: Reporte) -> None:
|
|
130
|
+
"""Credentials on disk, and whether the registry still accepts them.
|
|
131
|
+
|
|
132
|
+
A token line is reported by its presence only. Reading or printing the value
|
|
133
|
+
would put a live credential in a transcript, which is how a token becomes one
|
|
134
|
+
that has to be revoked.
|
|
135
|
+
"""
|
|
136
|
+
r.abrir("npm configuration")
|
|
137
|
+
|
|
138
|
+
for etiqueta, ruta in (
|
|
139
|
+
("~/.npmrc", Path.home() / ".npmrc"),
|
|
140
|
+
(".npmrc (project)", proyecto / ".npmrc"),
|
|
141
|
+
):
|
|
142
|
+
if not ruta.exists():
|
|
143
|
+
r.add(INFO, etiqueta, "not present")
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
texto = leer_texto(ruta)
|
|
147
|
+
if re.search(r"^\s*(//.*:)?_auth(Token)?\s*=", texto, re.M):
|
|
148
|
+
r.add(
|
|
149
|
+
REVISAR,
|
|
150
|
+
f"{etiqueta}: token line",
|
|
151
|
+
"an _authToken is present. Classic tokens were revoked in Dec 2025; "
|
|
152
|
+
"if this predates that, it is dead weight producing 401s (npm logout)",
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
r.add(OK, f"{etiqueta}: no token line", "")
|
|
156
|
+
|
|
157
|
+
if re.search(r"^\s*ignore-scripts\s*=\s*true", texto, re.M):
|
|
158
|
+
r.add(
|
|
159
|
+
INFO,
|
|
160
|
+
f"{etiqueta}: ignore-scripts=true",
|
|
161
|
+
"this machine already behaves like npm v12; CI probably does not",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if CONSULTAR_RED and existe("npm"):
|
|
165
|
+
codigo, salida = correr(["npm", "whoami"])
|
|
166
|
+
if codigo == 0 and salida:
|
|
167
|
+
r.add(OK, "npm session", f"authenticated as {salida}")
|
|
168
|
+
else:
|
|
169
|
+
r.add(
|
|
170
|
+
INFO,
|
|
171
|
+
"npm session",
|
|
172
|
+
"not authenticated (npm login opens a two-hour session; "
|
|
173
|
+
"irrelevant if publishing happens from Actions)",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def version_npm(r: Reporte) -> None:
|
|
178
|
+
r.abrir("npm version")
|
|
179
|
+
|
|
180
|
+
codigo, salida = correr(["npm", "--version"])
|
|
181
|
+
if codigo != 0 or not salida:
|
|
182
|
+
r.add(REVISAR, "npm", "not found on PATH")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
mayor = int(salida.split(".")[0])
|
|
187
|
+
except ValueError:
|
|
188
|
+
r.add(INFO, "npm", salida)
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
if mayor >= NPM_V12:
|
|
192
|
+
r.add(OK, "npm", f"{salida} — install-time defaults are on")
|
|
193
|
+
else:
|
|
194
|
+
r.add(
|
|
195
|
+
INFO,
|
|
196
|
+
"npm",
|
|
197
|
+
f"{salida} — v12 defaults not applied yet; 11.16.0+ warns about them first",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def repo_github(proyecto: Path) -> str | None:
|
|
202
|
+
"""owner/repo as GitHub spells it, read from the API rather than the folder name."""
|
|
203
|
+
codigo, salida = correr(
|
|
204
|
+
["git", "-C", str(proyecto), "remote", "get-url", "origin"]
|
|
205
|
+
)
|
|
206
|
+
if codigo != 0 or not salida:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
m = re.search(r"github\.com[:/]+([^/]+)/(.+?)(?:\.git)?$", salida)
|
|
210
|
+
if not m:
|
|
211
|
+
return None
|
|
212
|
+
local = f"{m.group(1)}/{m.group(2)}"
|
|
213
|
+
|
|
214
|
+
if CONSULTAR_RED and existe("gh"):
|
|
215
|
+
codigo, canonico = correr(["gh", "api", f"repos/{local}", "--jq", ".full_name"])
|
|
216
|
+
if codigo == 0 and canonico:
|
|
217
|
+
return canonico
|
|
218
|
+
return local
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def revisar_secretos(proyecto: Path, repo: str | None, r: Reporte) -> None:
|
|
222
|
+
"""Actions secrets that look like npm credentials, and whether anything uses them."""
|
|
223
|
+
r.abrir("GitHub Actions secrets")
|
|
224
|
+
|
|
225
|
+
if not repo:
|
|
226
|
+
r.add(INFO, "repository", "no GitHub remote — skipping")
|
|
227
|
+
return
|
|
228
|
+
if not CONSULTAR_RED:
|
|
229
|
+
r.add(INFO, "secrets", "skipped (--no-network)")
|
|
230
|
+
return
|
|
231
|
+
if not existe("gh"):
|
|
232
|
+
r.add(INFO, "secrets", "gh is not installed — cannot list them")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
codigo, salida = correr(["gh", "secret", "list", "--repo", repo])
|
|
236
|
+
if codigo != 0:
|
|
237
|
+
r.add(INFO, "secrets", f"could not read them for {repo}")
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
nombres = [l.split()[0] for l in salida.splitlines() if l.strip()]
|
|
241
|
+
sospechosos = [n for n in nombres if PATRON_SECRETO_NPM.search(n)]
|
|
242
|
+
|
|
243
|
+
if not sospechosos:
|
|
244
|
+
r.add(OK, "no npm credentials stored", f"{len(nombres)} secret(s) in {repo}")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
usados = leer_texto_workflows(proyecto)
|
|
248
|
+
for nombre in sospechosos:
|
|
249
|
+
if nombre in usados:
|
|
250
|
+
r.add(
|
|
251
|
+
REVISAR,
|
|
252
|
+
f"secret {nombre}",
|
|
253
|
+
"referenced by a workflow — token auth, replaceable by OIDC",
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
r.add(
|
|
257
|
+
FALTA,
|
|
258
|
+
f"secret {nombre} is orphaned",
|
|
259
|
+
f"no workflow references it. gh secret delete {nombre} --repo {repo}",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def leer_texto_workflows(proyecto: Path) -> str:
|
|
264
|
+
carpeta = proyecto / ".github" / "workflows"
|
|
265
|
+
if not carpeta.is_dir():
|
|
266
|
+
return ""
|
|
267
|
+
return "\n".join(sin_comentarios(leer_texto(f)) for f in sorted(carpeta.glob("*.y*ml")))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def sin_comentarios(texto: str) -> str:
|
|
271
|
+
"""YAML with its comments removed.
|
|
272
|
+
|
|
273
|
+
A well-commented trusted-publishing workflow explains that it carries no
|
|
274
|
+
NPM_TOKEN — and reading that sentence as a credential reference is exactly
|
|
275
|
+
how a correct file gets reported as broken.
|
|
276
|
+
"""
|
|
277
|
+
texto = re.sub(r"(?m)^\s*#.*$", "", texto)
|
|
278
|
+
return re.sub(r"(?m)\s#.*$", "", texto)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def revisar_workflows(proyecto: Path, r: Reporte) -> None:
|
|
282
|
+
"""Which workflow publishes, and how it proves who it is."""
|
|
283
|
+
r.abrir("Publishing workflow")
|
|
284
|
+
|
|
285
|
+
carpeta = proyecto / ".github" / "workflows"
|
|
286
|
+
archivos = sorted(carpeta.glob("*.y*ml")) if carpeta.is_dir() else []
|
|
287
|
+
|
|
288
|
+
# A package.json marked private is an application or a toolchain, not
|
|
289
|
+
# something that gets published — the absence of a publishing workflow is
|
|
290
|
+
# the correct state, not a finding.
|
|
291
|
+
pkg = leer_json(proyecto / "package.json") or {}
|
|
292
|
+
if pkg.get("private") is True:
|
|
293
|
+
r.add(INFO, "private package", "never published; nothing to automate")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
if not archivos:
|
|
297
|
+
r.add(
|
|
298
|
+
FALTA,
|
|
299
|
+
"no workflow publishes this package",
|
|
300
|
+
"every release needs an interactive login (two-hour session). "
|
|
301
|
+
"See references/trusted-publishing.md",
|
|
302
|
+
)
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
publicadores = [f for f in archivos if "npm publish" in sin_comentarios(leer_texto(f))]
|
|
306
|
+
if not publicadores:
|
|
307
|
+
r.add(
|
|
308
|
+
FALTA,
|
|
309
|
+
"no workflow runs npm publish",
|
|
310
|
+
f"{len(archivos)} workflow(s) present, none publishes",
|
|
311
|
+
)
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
for archivo in publicadores:
|
|
315
|
+
texto = sin_comentarios(leer_texto(archivo))
|
|
316
|
+
nombre = archivo.name
|
|
317
|
+
|
|
318
|
+
oidc = re.search(r"^\s*id-token:\s*write", texto, re.M)
|
|
319
|
+
token = re.search(r"NODE_AUTH_TOKEN|NPM_TOKEN|secrets\.\w*NPM", texto)
|
|
320
|
+
|
|
321
|
+
if oidc and not token:
|
|
322
|
+
r.add(OK, f"{nombre}: OIDC", "trusted publishing, no stored secret")
|
|
323
|
+
elif oidc and token:
|
|
324
|
+
r.add(
|
|
325
|
+
REVISAR,
|
|
326
|
+
f"{nombre}: OIDC and a token",
|
|
327
|
+
"a token reference puts the publish back on token auth and drops provenance",
|
|
328
|
+
)
|
|
329
|
+
elif token:
|
|
330
|
+
r.add(
|
|
331
|
+
FALTA,
|
|
332
|
+
f"{nombre}: token auth",
|
|
333
|
+
"long-lived credential; 2FA-bypass tokens lose direct publish ~Jan 2027",
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
r.add(REVISAR, f"{nombre}: no visible credential", "check how it authenticates")
|
|
337
|
+
|
|
338
|
+
if re.search(r"tags:\s*$|-\s*['\"]?v\*", texto, re.M):
|
|
339
|
+
r.add(OK, f"{nombre}: trigger", "runs on a pushed tag")
|
|
340
|
+
else:
|
|
341
|
+
r.add(REVISAR, f"{nombre}: trigger", "does not look tag-driven")
|
|
342
|
+
|
|
343
|
+
if not re.search(r"GITHUB_REF_NAME|github\.ref_name", texto):
|
|
344
|
+
r.add(
|
|
345
|
+
REVISAR,
|
|
346
|
+
f"{nombre}: no version guard",
|
|
347
|
+
"nothing compares the tag against the version files before publishing",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def revisar_paquete(proyecto: Path, r: Reporte) -> None:
|
|
352
|
+
"""The manifest itself: scope, and version files that must agree."""
|
|
353
|
+
r.abrir("Package manifest")
|
|
354
|
+
|
|
355
|
+
pkg = leer_json(proyecto / "package.json")
|
|
356
|
+
if not pkg:
|
|
357
|
+
r.add(INFO, "package.json", "not an npm project")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
nombre = pkg.get("name", "")
|
|
361
|
+
version = pkg.get("version", "")
|
|
362
|
+
r.add(INFO, "package", f"{nombre}@{version}")
|
|
363
|
+
|
|
364
|
+
if nombre.startswith("@"):
|
|
365
|
+
r.add(
|
|
366
|
+
INFO,
|
|
367
|
+
"scoped package",
|
|
368
|
+
"badges and registry URLs must carry the scope; the unscoped name is a different package",
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
plugin_path = proyecto / ".claude-plugin" / "plugin.json"
|
|
372
|
+
if plugin_path.exists():
|
|
373
|
+
plugin = leer_json(plugin_path) or {}
|
|
374
|
+
if plugin.get("version") == version:
|
|
375
|
+
r.add(OK, "plugin.json version", f"in sync at {version}")
|
|
376
|
+
else:
|
|
377
|
+
r.add(
|
|
378
|
+
FALTA,
|
|
379
|
+
"plugin.json is out of sync",
|
|
380
|
+
f"package.json {version} vs plugin.json {plugin.get('version')} — "
|
|
381
|
+
"marketplace users keep the cached old code",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def revisar_badges(proyecto: Path, r: Reporte) -> None:
|
|
386
|
+
"""shields.io badges asking the registry for a package name that does not exist.
|
|
387
|
+
|
|
388
|
+
The failure is silent: shields renders "package not found" instead of an error,
|
|
389
|
+
so a broken badge survives for months and hides whatever it was reporting.
|
|
390
|
+
"""
|
|
391
|
+
r.abrir("README badges")
|
|
392
|
+
|
|
393
|
+
readme = proyecto / "README.md"
|
|
394
|
+
if not readme.exists():
|
|
395
|
+
r.add(INFO, "README.md", "not present")
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
texto = leer_texto(readme)
|
|
399
|
+
nombres = set(re.findall(r"img\.shields\.io/npm/[a-z]+/([^)\s\]]+)", texto))
|
|
400
|
+
if not nombres:
|
|
401
|
+
r.add(INFO, "npm badges", "none")
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
pkg = leer_json(proyecto / "package.json") or {}
|
|
405
|
+
esperado = pkg.get("name", "")
|
|
406
|
+
|
|
407
|
+
for crudo in sorted(nombres):
|
|
408
|
+
nombre = crudo.replace("%2F", "/").rstrip("?").split("?")[0]
|
|
409
|
+
if esperado and nombre == esperado:
|
|
410
|
+
r.add(OK, f"badge {nombre}", "matches package.json")
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
estado = pedir(f"https://registry.npmjs.org/{nombre.replace('/', '%2F')}")
|
|
414
|
+
if estado == 200:
|
|
415
|
+
r.add(REVISAR, f"badge {nombre}", f"resolves, but package.json says {esperado}")
|
|
416
|
+
elif estado is None:
|
|
417
|
+
r.add(REVISAR, f"badge {nombre}", f"could not verify; package.json says {esperado}")
|
|
418
|
+
else:
|
|
419
|
+
r.add(
|
|
420
|
+
FALTA,
|
|
421
|
+
f"badge {nombre} points at nothing",
|
|
422
|
+
f"registry answers {estado}; this package is {esperado}",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def revisar_scripts_instalacion(proyecto: Path, r: Reporte) -> None:
|
|
427
|
+
"""What a user on npm v12 would be asked to approve when installing this tree."""
|
|
428
|
+
r.abrir("Install-time scripts (npm v12)")
|
|
429
|
+
|
|
430
|
+
modulos = proyecto / "node_modules"
|
|
431
|
+
if not modulos.is_dir():
|
|
432
|
+
r.add(INFO, "node_modules", "not installed — run npm install to measure this")
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
con_scripts: list[str] = []
|
|
436
|
+
node_gyp: list[str] = []
|
|
437
|
+
|
|
438
|
+
for manifiesto in modulos.glob("*/package.json"):
|
|
439
|
+
datos = leer_json(manifiesto)
|
|
440
|
+
if not datos:
|
|
441
|
+
continue
|
|
442
|
+
scripts = datos.get("scripts") or {}
|
|
443
|
+
if any(k in scripts for k in ("preinstall", "install", "postinstall")):
|
|
444
|
+
con_scripts.append(datos.get("name", manifiesto.parent.name))
|
|
445
|
+
if "node-gyp" in json.dumps(datos.get("dependencies") or {}):
|
|
446
|
+
node_gyp.append(datos.get("name", manifiesto.parent.name))
|
|
447
|
+
|
|
448
|
+
# Scoped packages live one level deeper.
|
|
449
|
+
for manifiesto in modulos.glob("@*/*/package.json"):
|
|
450
|
+
datos = leer_json(manifiesto)
|
|
451
|
+
if not datos:
|
|
452
|
+
continue
|
|
453
|
+
scripts = datos.get("scripts") or {}
|
|
454
|
+
if any(k in scripts for k in ("preinstall", "install", "postinstall")):
|
|
455
|
+
con_scripts.append(datos.get("name", manifiesto.parent.name))
|
|
456
|
+
|
|
457
|
+
if not con_scripts and not node_gyp:
|
|
458
|
+
r.add(OK, "no dependency runs install scripts", "npm v12 installs this cleanly")
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
for nombre in sorted(set(con_scripts)):
|
|
462
|
+
r.add(REVISAR, f"{nombre} declares an install script", "needs approval on npm v12")
|
|
463
|
+
for nombre in sorted(set(node_gyp) - set(con_scripts)):
|
|
464
|
+
r.add(REVISAR, f"{nombre} pulls node-gyp", "implicit build, off by default on npm v12")
|
|
465
|
+
|
|
466
|
+
r.add(
|
|
467
|
+
INFO,
|
|
468
|
+
"authoritative check",
|
|
469
|
+
"npm approve-scripts --allow-scripts-pending, then commit the allowlist",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def main() -> int:
|
|
474
|
+
global CONSULTAR_RED
|
|
475
|
+
|
|
476
|
+
p = argparse.ArgumentParser(
|
|
477
|
+
description="Audit how a repository publishes to npm and what it installs from it."
|
|
478
|
+
)
|
|
479
|
+
p.add_argument("ruta", nargs="?", default=".", help="project directory (default: .)")
|
|
480
|
+
p.add_argument(
|
|
481
|
+
"--no-network",
|
|
482
|
+
action="store_true",
|
|
483
|
+
help="skip the registry, gh and npm whoami lookups",
|
|
484
|
+
)
|
|
485
|
+
args = p.parse_args()
|
|
486
|
+
|
|
487
|
+
CONSULTAR_RED = not args.no_network
|
|
488
|
+
|
|
489
|
+
proyecto = Path(os.path.expanduser(args.ruta)).resolve()
|
|
490
|
+
if not proyecto.is_dir():
|
|
491
|
+
p.error(f"{args.ruta} is not a directory")
|
|
492
|
+
|
|
493
|
+
print(f"\nnpm supply-chain audit of {proyecto}")
|
|
494
|
+
|
|
495
|
+
r = Reporte()
|
|
496
|
+
version_npm(r)
|
|
497
|
+
revisar_npmrc(proyecto, r)
|
|
498
|
+
revisar_paquete(proyecto, r)
|
|
499
|
+
revisar_workflows(proyecto, r)
|
|
500
|
+
revisar_secretos(proyecto, repo_github(proyecto), r)
|
|
501
|
+
revisar_badges(proyecto, r)
|
|
502
|
+
revisar_scripts_instalacion(proyecto, r)
|
|
503
|
+
|
|
504
|
+
faltan = r.imprimir()
|
|
505
|
+
return 1 if faltan else 0
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
sys.exit(main())
|
|
@@ -25,10 +25,12 @@ The SKILL.md alone is an **index** of references. The detail you need to give ac
|
|
|
25
25
|
|
|
26
26
|
### Step 2 — Output contract
|
|
27
27
|
|
|
28
|
-
Every design recommendation, ratio, value, or rule you cite
|
|
28
|
+
Every design recommendation, ratio, value, or rule you cite carries its citation inline:
|
|
29
29
|
|
|
30
30
|
`[source: references/<file>.md]`
|
|
31
31
|
|
|
32
|
+
The citation is what separates a value you read from one that merely sounded right — written down, the two look identical, and the reader has no way to tell them apart. Cite while writing rather than collecting sources at the end, because by then you are reconstructing where something came from instead of recording it.
|
|
33
|
+
|
|
32
34
|
Example: *"Use weight and color, not just font size, to establish hierarchy [source: references/02-page-mechanics.md]"*
|
|
33
35
|
|
|
34
36
|
### Step 3 — If you must answer from memory
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: seo-launch
|
|
3
3
|
description: 'Audit and then install everything a site needs to be indexed and to render a proper card when its link is shared: head tags, Open Graph and Twitter card, the 1200x630 og:image, favicon and apple-touch-icon, robots.txt, sitemap.xml, an .htaccess with one canonical domain, JSON-LD, and the Search Console handover. Works on static sites and on Laravel or plain PHP projects. Use when the user says the link shows a grey box with no preview in WhatsApp, asks why Google cannot find the site, is putting a new domain live, or asks for an SEO review, meta tags, og:image, sitemap or robots.txt — even when they never say "SEO". Not for: keyword research, writing the content itself, backlinks, paid ads, or analytics dashboards.'
|
|
4
4
|
allowed-tools: Read, Grep, Glob, Bash, Edit, Write, AskUserQuestion
|
|
5
|
+
compatibility: Requires Python 3 (standard library only) and network access to audit a live site. Stage 2 uses ImageMagick to generate the images.
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
# SEO Launch
|
|
Binary file
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stitch-showcase
|
|
3
3
|
description: 'Turns Google Stitch design exports (zips holding `code.html` + `screen.png`) into a navigable showcase — gallery, viewer, component catalog — in about three seconds, and enriches it on demand. Use this for anything involving those exports: "organiza mis diseños de Stitch", "arma el muestrario", "organize my Stitch designs", "build the showcase", "tengo los zips de Stitch", "mis exports de Stitch", or a bare path to a folder of design zips. Also for maintaining one that already exists: "optimiza el showcase", "mejora las descripciones", "agrega estas pantallas nuevas", "el cliente pidió otra pantalla", "estandariza los navbars", "make all the footers the same". Not for: Figma or Sketch exports, loose screenshots, redesigning the screens themselves, or building the real app from them.'
|
|
4
|
+
compatibility: Requires Python 3 (standard library only) to extract the zips and build the showcase.
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# stitch-showcase
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: vscode-extension-dev
|
|
3
|
-
description: 'VS Code extension development grounded in the official Extension API docs. Use this whenever someone is creating, scaffolding, debugging, testing, bundling or publishing a VS Code extension — TreeView, QuickPick, Webview, StatusBar, SecretStorage, Language Server Protocol, Debug Adapter Protocol, notebooks — and also when they never say "extension" but the work clearly is one: editing `package.json` `contributes` / `activationEvents` / `keybindings`, an `activate(context)` function, importing from `vscode`, leaking disposables, `yo code`, `vsce`, `.vscodeignore`, bundling with esbuild, publishing to the Marketplace or Open VSX, Webview CSP/nonce/postMessage, or testing with @vscode/test-electron. Not for: configuring your own editor, Claude Code plugins or MCP servers, or general TypeScript/Node questions with no extension host involved.'
|
|
3
|
+
description: 'VS Code extension development grounded in the official Extension API docs. Use this whenever someone is creating, scaffolding, designing, debugging, testing, bundling or publishing a VS Code extension — TreeView, QuickPick, Webview, StatusBar, commands, configuration, SecretStorage, progress indicators, FileSystemWatcher, Diagnostics, Language Server Protocol, Debug Adapter Protocol, notebooks — and also when they never say "extension" but the work clearly is one: editing `package.json` `contributes` / `activationEvents` / `keybindings`, an `activate(context)` function, importing from `vscode`, leaking disposables, `yo code`, `vsce`, `.vscodeignore`, bundling with esbuild or webpack, publishing to the Marketplace or Open VSX, Webview CSP/nonce/postMessage, or testing with @vscode/test-electron. Not for: configuring your own editor (user settings, keybindings, installing extensions), Claude Code plugins, skills or MCP servers, or general TypeScript/Node questions with no extension host involved.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# VS Code Extension Development Skill
|
|
@@ -32,10 +32,12 @@ The SKILL.md alone is an **index** of references. The detail you need to give ac
|
|
|
32
32
|
|
|
33
33
|
### Step 2 — Output contract
|
|
34
34
|
|
|
35
|
-
Every API symbol, configuration key, command, or behavior you cite
|
|
35
|
+
Every API symbol, configuration key, command, or behavior you cite carries its citation inline:
|
|
36
36
|
|
|
37
37
|
`[source: references/<file>.md]`
|
|
38
38
|
|
|
39
|
+
The citation is what separates a value you read from one that merely sounded right — written down, the two look identical, and the reader has no way to tell them apart. Cite while writing rather than collecting sources at the end, because by then you are reconstructing where something came from instead of recording it.
|
|
40
|
+
|
|
39
41
|
Example: *"Push all subscriptions to `context.subscriptions` so they are disposed on deactivation [source: references/api-additional.md]"*
|
|
40
42
|
|
|
41
43
|
### Step 3 — If you must answer from memory
|