@marcomigozzi/trailhead 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.it.md +6 -3
  2. package/README.md +6 -3
  3. package/bin/lib/codex-projection.js +2 -1
  4. package/bin/lib/codex-projection.test.js +4 -1
  5. package/bin/trailhead.js +23 -13
  6. package/bin/trailhead.test.js +328 -0
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/commands/adopt.md +5 -1
  10. package/plugins/trailhead/commands/audit.md +10 -0
  11. package/plugins/trailhead/commands/auto.md +5 -1
  12. package/plugins/trailhead/commands/bug.md +5 -1
  13. package/plugins/trailhead/commands/config.md +5 -1
  14. package/plugins/trailhead/commands/dashboard.md +5 -1
  15. package/plugins/trailhead/commands/grill.md +5 -1
  16. package/plugins/trailhead/commands/idea.md +5 -1
  17. package/plugins/trailhead/commands/inbox.md +5 -1
  18. package/plugins/trailhead/commands/map.md +5 -1
  19. package/plugins/trailhead/commands/new.md +5 -1
  20. package/plugins/trailhead/commands/note.md +5 -1
  21. package/plugins/trailhead/commands/pause.md +5 -1
  22. package/plugins/trailhead/commands/prune.md +5 -1
  23. package/plugins/trailhead/commands/quick.md +6 -2
  24. package/plugins/trailhead/commands/resume.md +5 -1
  25. package/plugins/trailhead/commands/seed.md +5 -1
  26. package/plugins/trailhead/commands/split.md +5 -1
  27. package/plugins/trailhead/commands/ticket.md +5 -1
  28. package/plugins/trailhead/commands/todo.md +5 -1
  29. package/plugins/trailhead/commands/update.md +5 -1
  30. package/plugins/trailhead/commands/whiteboard.md +5 -1
  31. package/plugins/trailhead/commands/work.md +5 -1
  32. package/plugins/trailhead/hooks/hooks.json +9 -0
  33. package/plugins/trailhead/hooks/lib/audit-walk.test.js +802 -0
  34. package/plugins/trailhead/hooks/lib/fixtures/label-guard-0.12.0.yml +55 -0
  35. package/plugins/trailhead/hooks/lib/label-guard-sync.test.js +156 -0
  36. package/plugins/trailhead/hooks/lib/mockup-link.js +50 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +56 -4
  38. package/plugins/trailhead/hooks/lib/model-defaults.json +5 -1
  39. package/plugins/trailhead/hooks/trailhead-check-update.js +25 -1
  40. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +95 -0
  41. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +145 -0
  42. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.js +38 -0
  43. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +64 -0
  44. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +151 -6
  45. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +56 -0
  46. package/plugins/trailhead/skills/_shared/choices.md +2 -0
  47. package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
  48. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  49. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  50. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  51. package/plugins/trailhead/skills/_shared/substrate-commands.md +9 -2
  52. package/plugins/trailhead/skills/_shared/teamwork.md +3 -1
  53. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  54. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  55. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  56. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
  57. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +132 -0
  58. package/plugins/trailhead/skills/trailhead-manage/references/updating.md +4 -0
  59. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  60. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -1
  61. package/plugins/trailhead/templates/trailhead-audit-walk.js +685 -0
  62. package/plugins/trailhead/templates/trailhead-label-guard-sync.js +212 -0
  63. package/plugins/trailhead/templates/trailhead-label-guard.yml +5 -0
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead label guard sync (self-contained, ships in templates/ so it
4
+ // reaches every install channel). Classifies the repo's
5
+ // .github/workflows/trailhead-label-guard.yml against the sibling template
6
+ // and, with --apply, installs it when absent or upgrades an OUTDATED trailhead
7
+ // guard (one without the job-level `if:` filter, so every `issues: labeled`
8
+ // event started a billed runner). Never touches a foreign or customised guard.
9
+ //
10
+ // CLI: node trailhead-label-guard-sync.js [--repo <dir>] [--apply | --notice | --ack]
11
+ // Default and --apply print exactly one JSON line: {"status","file","message"}.
12
+ // --notice prints the session-start upgrade offer (plain text) when the guard
13
+ // is outdated and the user has not declined it for this exact file, else
14
+ // nothing. --ack records that the user answered the offer: it stores the
15
+ // guard's hash in the gitignored .trailhead/label-guard-ack, so the offer
16
+ // comes back only if the file changes.
17
+ // status (check): absent | current | outdated | custom | foreign | skipped
18
+ // status (--apply): installed | upgraded, or the check status when nothing
19
+ // was written
20
+ // Writing only changes the working tree: committing (and pushing, which needs
21
+ // the `workflow` token scope) stays with the caller.
22
+ // Exit 0 on every normal outcome (including unexpected errors: this must never
23
+ // block the caller); exit 2 only on a usage error.
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const crypto = require('crypto');
28
+
29
+ const GUARD_REL = path.join('.github', 'workflows', 'trailhead-label-guard.yml');
30
+ // Prima riga di ogni versione del guard trailhead (tra le prime 5, trim).
31
+ const TRAILHEAD_MARK = '# trailhead label guard:';
32
+ const ACK_REL = path.join('.trailhead', 'label-guard-ack');
33
+ // Il filtro che deve stare nell'`if:` a livello di job.
34
+ const JOB_FILTER = "startsWith(github.event.label.name, 'trailhead:')";
35
+
36
+ function normalize(text) {
37
+ return String(text == null ? '' : text).replace(/\r\n/g, '\n');
38
+ }
39
+
40
+ function isTrailheadGuard(text) {
41
+ return normalize(text).split('\n').slice(0, 5).some((line) => line.trim().startsWith(TRAILHEAD_MARK));
42
+ }
43
+
44
+ // Estrae il testo dell'`if:` diretto di jobs.guard (indentazione 4, prima del
45
+ // successivo `steps:`/chiave), incluse le righe di continuazione di `>-`.
46
+ // null se jobs.guard non ha un `if:` a livello di job.
47
+ function jobGuardIf(text) {
48
+ const lines = normalize(text).split('\n');
49
+ const start = lines.findIndex((l) => /^ {2}guard:\s*$/.test(l));
50
+ if (start < 0) return null;
51
+ for (let i = start + 1; i < lines.length; i++) {
52
+ const line = lines[i];
53
+ if (/^\S/.test(line) || /^ {2}\S/.test(line)) return null; // fine del job
54
+ const m = /^ {4}if:\s*(.*)$/.exec(line);
55
+ if (!m) continue;
56
+ const parts = [m[1]];
57
+ for (let j = i + 1; j < lines.length && /^ {6,}\S/.test(lines[j]); j++) parts.push(lines[j].trim());
58
+ return parts.join(' ');
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function hasJobLevelFilter(text) {
64
+ const cond = jobGuardIf(text);
65
+ return cond !== null && cond.includes(JOB_FILTER);
66
+ }
67
+
68
+ function classify({ existingText, templateText }) {
69
+ if (existingText == null) return 'absent';
70
+ if (!isTrailheadGuard(existingText)) return 'foreign';
71
+ if (normalize(existingText) === normalize(templateText)) return 'current';
72
+ return hasJobLevelFilter(existingText) ? 'custom' : 'outdated';
73
+ }
74
+
75
+ const MESSAGES = {
76
+ absent: 'no trailhead label guard in this repo.',
77
+ current: 'the trailhead label guard is already current.',
78
+ outdated: 'the trailhead label guard is outdated: no job-level filter, so every label event starts a billed runner.',
79
+ custom: 'the trailhead label guard has the job-level filter but differs from the template; left untouched.',
80
+ foreign: 'a non-trailhead workflow sits at the label guard path; left untouched.',
81
+ installed: 'installed the trailhead label guard (commit it).',
82
+ upgraded: 'upgraded the trailhead label guard to the current template (commit it).',
83
+ };
84
+
85
+ function writeAtomic(filePath, text, fsMod) {
86
+ const dir = path.dirname(filePath);
87
+ fsMod.mkdirSync(dir, { recursive: true });
88
+ const tmpPath = path.join(dir, `.trailhead-label-guard-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
89
+ try {
90
+ fsMod.writeFileSync(tmpPath, text);
91
+ fsMod.renameSync(tmpPath, filePath);
92
+ } catch (err) {
93
+ try { fsMod.unlinkSync(tmpPath); } catch { /* best effort cleanup */ }
94
+ throw err;
95
+ }
96
+ }
97
+
98
+ function syncLabelGuard({ repoDir, templateText, apply = false, fs: fsMod = fs }) {
99
+ const file = path.join(repoDir, GUARD_REL);
100
+ const existingText = fsMod.existsSync(file) ? fsMod.readFileSync(file, 'utf8') : null;
101
+ const status = classify({ existingText, templateText });
102
+ if (apply && (status === 'absent' || status === 'outdated')) {
103
+ writeAtomic(file, templateText, fsMod);
104
+ const done = status === 'absent' ? 'installed' : 'upgraded';
105
+ return { status: done, file, message: MESSAGES[done] };
106
+ }
107
+ return { status, file, message: MESSAGES[status] };
108
+ }
109
+
110
+ function sha256(text) {
111
+ return crypto.createHash('sha256').update(normalize(text)).digest('hex');
112
+ }
113
+
114
+ function readGuard(repoDir, fsMod) {
115
+ const file = path.join(repoDir, GUARD_REL);
116
+ return fsMod.existsSync(file) ? fsMod.readFileSync(file, 'utf8') : null;
117
+ }
118
+
119
+ // Registra la risposta all'offerta: hash del guard attuale, cosi' l'offerta
120
+ // torna solo se il file cambia. false se non c'e' un guard da ackare.
121
+ function ackGuard({ repoDir, fs: fsMod = fs }) {
122
+ const existingText = readGuard(repoDir, fsMod);
123
+ if (existingText == null) return false;
124
+ const file = path.join(repoDir, ACK_REL);
125
+ fsMod.mkdirSync(path.dirname(file), { recursive: true });
126
+ fsMod.writeFileSync(file, sha256(existingText) + '\n');
127
+ return true;
128
+ }
129
+
130
+ function isAcked(repoDir, existingText, fsMod) {
131
+ try {
132
+ return fsMod.readFileSync(path.join(repoDir, ACK_REL), 'utf8').trim() === sha256(existingText);
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ // Offerta di sessione (SessionStart o load-first inline): solo per un guard
139
+ // trailhead `outdated` non ancora ackato. Sola lettura, mai eccezioni.
140
+ function sessionNotice({ repoDir, templateText, scriptPath, fs: fsMod = fs }) {
141
+ try {
142
+ const existingText = readGuard(repoDir, fsMod);
143
+ if (classify({ existingText, templateText }) !== 'outdated') return null;
144
+ if (isAcked(repoDir, existingText, fsMod)) return null;
145
+ const cmd = `node "${scriptPath}" --repo "${repoDir}"`;
146
+ return [
147
+ `trailhead's label guard in this repo (${GUARD_REL}) is outdated: it has no job-level \`if:\`, so every \`issues: labeled\` event starts a GitHub Actions runner billed at least one minute, even for labels it ignores.`,
148
+ 'Offer the user, once, without blocking their current request, two choices, and put the reason in the question itself, in plain words, not just the choices. Say that the workflow currently starts a GitHub Actions run every time any label is added to an issue, that GitHub bills each run at least one minute even when the workflow then does nothing, that trailhead adds labels all the time so this can reach thousands of billed runs a month and use up the Actions minutes, and that the updated version skips irrelevant label events before a runner starts (skipped runs are free) while keeping the same protection.',
149
+ `1) upgrade it: run \`${cmd} --apply\`, then commit the file (\`fix: skip trailhead label guard job for irrelevant label events\`) and push (a workflow file needs the \`workflow\` token scope: run \`gh auth refresh -s workflow\` if the push is rejected);`,
150
+ '2) keep it as it is.',
151
+ `This offer reappears every session until answered: once the user has picked either choice, run: ${cmd} --ack`,
152
+ ].join('\n');
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ function parseArgs(argv) {
159
+ let repo = null;
160
+ let apply = false;
161
+ let mode = 'sync';
162
+ for (let i = 0; i < argv.length; i++) {
163
+ const arg = argv[i];
164
+ if (arg === '--repo') {
165
+ i++;
166
+ if (i >= argv.length) return { error: 'usage: --repo requires a value' };
167
+ repo = argv[i];
168
+ } else if (arg === '--apply') {
169
+ apply = true;
170
+ } else if (arg === '--notice' || arg === '--ack') {
171
+ if (mode !== 'sync') return { error: 'usage: --notice and --ack are exclusive' };
172
+ mode = arg.slice(2);
173
+ } else {
174
+ return { error: `usage: unknown argument: ${arg}` };
175
+ }
176
+ }
177
+ if (apply && mode !== 'sync') return { error: `usage: --apply cannot be combined with --${mode}` };
178
+ return { repo, apply, mode };
179
+ }
180
+
181
+ function main(argv, { cwd, stdout } = {}) {
182
+ const out = stdout || process.stdout;
183
+ const parsed = parseArgs(argv || []);
184
+ if (parsed.error) {
185
+ process.stderr.write(parsed.error + '\n');
186
+ return 2;
187
+ }
188
+ const repoDir = path.resolve(cwd || process.cwd(), parsed.repo || '.');
189
+ try {
190
+ const templateText = fs.readFileSync(path.join(__dirname, 'trailhead-label-guard.yml'), 'utf8');
191
+ if (parsed.mode === 'notice') {
192
+ const notice = sessionNotice({ repoDir, templateText, scriptPath: __filename });
193
+ if (notice) out.write(notice + '\n');
194
+ return 0;
195
+ }
196
+ if (parsed.mode === 'ack') {
197
+ ackGuard({ repoDir });
198
+ return 0;
199
+ }
200
+ out.write(JSON.stringify(syncLabelGuard({ repoDir, templateText, apply: parsed.apply })) + '\n');
201
+ } catch (err) {
202
+ if (parsed.mode !== 'sync') return 0; // notice/ack: mai rumore
203
+ out.write(JSON.stringify({ status: 'skipped', file: path.join(repoDir, GUARD_REL), message: 'error: ' + (err && err.message ? err.message : String(err)) }) + '\n');
204
+ }
205
+ return 0;
206
+ }
207
+
208
+ module.exports = { normalize, isTrailheadGuard, jobGuardIf, hasJobLevelFilter, classify, syncLabelGuard, sessionNotice, ackGuard, parseArgs, main, GUARD_REL, ACK_REL, JOB_FILTER };
209
+
210
+ if (require.main === module) {
211
+ process.exit(main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout }));
212
+ }
@@ -18,6 +18,11 @@ permissions:
18
18
 
19
19
  jobs:
20
20
  guard:
21
+ # Filtro a livello di job: un job saltato non consuma minuti Actions.
22
+ if: >-
23
+ startsWith(github.event.label.name, 'trailhead:') &&
24
+ github.event.sender.login != github.repository_owner &&
25
+ !contains(format(',{0},', vars.TRAILHEAD_LABEL_ALLOWLIST), format(',{0},', github.event.sender.login))
21
26
  runs-on: ubuntu-latest
22
27
  steps:
23
28
  - uses: actions/github-script@v7