@maccesar/aiskills 1.18.1 → 1.20.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 +55 -9
- package/bin/aiskills.js +1 -0
- package/commands/release.md +25 -5
- package/lib/commands/list.js +45 -22
- 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
|
@@ -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
|