@marcomigozzi/trailhead 0.10.0 → 0.11.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.
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead commit-msg hook sync (self-contained, ships in templates/ so it
4
+ // reaches every install channel: bin/trailhead.js copies templates/ wholesale
5
+ // for Claude/npm and Codex alike). Installs the sibling template
6
+ // trailhead-commit-msg into the repo's git hooks dir, upgrades it in place
7
+ // when an OLDER trailhead hook is already there (recognised by its own
8
+ // header line, never by trusting the filename), and never touches a foreign
9
+ // (non-trailhead) hook. #185.
10
+ //
11
+ // CLI: node trailhead-commit-msg-sync.js [--repo <dir>] (default cwd)
12
+ // Prints exactly one JSON line: {"status","hook","message"}.
13
+ // status: installed | upgraded | current | foreign | skipped
14
+ // hook: absolute path to the hook file, or null
15
+ // Exit 0 on every normal outcome (including "not a repo" and any unexpected
16
+ // error: this must never block the caller); exit 2 only on a usage error
17
+ // (unknown flag, or --repo with no value).
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const crypto = require('crypto');
22
+ const { execFileSync } = require('child_process');
23
+
24
+ // La prima riga riconosciuta di ogni versione dell'hook trailhead: basta che
25
+ // una delle prime 5 righe (trim) inizi con questa stringa.
26
+ const TRAILHEAD_MARK = '// trailhead commit-msg hook (git).';
27
+
28
+ function isTrailheadHook(text) {
29
+ const lines = String(text == null ? '' : text).split('\n').slice(0, 5);
30
+ return lines.some((line) => line.trim().startsWith(TRAILHEAD_MARK));
31
+ }
32
+
33
+ // Uniforma i fine riga per il confronto di contenuto: CRLF -> LF, non tocca
34
+ // il file su disco (usato solo per decidere current vs upgraded).
35
+ function normalize(text) {
36
+ return String(text == null ? '' : text).replace(/\r\n/g, '\n');
37
+ }
38
+
39
+ // stdio 'pipe' su tutti e tre i canali: un git fallito (es. "not a repo") non
40
+ // deve stampare rumore sul terminale dell'invocante, solo far fallire questa
41
+ // chiamata (il chiamante decide come reagire).
42
+ function defaultRunGit(args, cwd) {
43
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
44
+ }
45
+
46
+ // Risolve la hooksDir effettiva del repo (rispetta core.hooksPath e i
47
+ // worktree collegati: `git rev-parse --git-path hooks` la restituisce già
48
+ // corretta, eventualmente relativa a cwd). { skip: 'no-repo' } se repoDir non
49
+ // è un repo git.
50
+ function resolveHooksTarget({ repoDir, runGit = defaultRunGit }) {
51
+ try {
52
+ runGit(['rev-parse', '--git-dir'], repoDir);
53
+ } catch {
54
+ return { skip: 'no-repo' };
55
+ }
56
+ const hooksPathOut = runGit(['rev-parse', '--git-path', 'hooks'], repoDir);
57
+ const hooksDir = path.resolve(repoDir, hooksPathOut);
58
+ let hooksPathSet = false;
59
+ try {
60
+ const value = runGit(['config', '--get', 'core.hooksPath'], repoDir);
61
+ hooksPathSet = Boolean(value && value.trim());
62
+ } catch {
63
+ hooksPathSet = false;
64
+ }
65
+ return { hooksDir, hooksPathSet };
66
+ }
67
+
68
+ // Applica la sincronizzazione vera e propria dentro hooksDir. fs è iniettato
69
+ // per testabilità (default: il modulo fs reale).
70
+ function syncCommitMsgHook({ hooksDir, templateText, hooksPathSet, fs: fsMod = fs }) {
71
+ const hookPath = path.join(hooksDir, 'commit-msg');
72
+
73
+ if (!fsMod.existsSync(hookPath)) {
74
+ if (hooksPathSet) {
75
+ return {
76
+ status: 'skipped',
77
+ hook: null,
78
+ message: 'core.hooksPath is set and has no commit-msg; trailhead does not add files there, copy the template yourself.',
79
+ };
80
+ }
81
+ fsMod.mkdirSync(hooksDir, { recursive: true });
82
+ fsMod.writeFileSync(hookPath, templateText);
83
+ fsMod.chmodSync(hookPath, 0o755);
84
+ return { status: 'installed', hook: hookPath, message: 'installed the trailhead commit-msg hook.' };
85
+ }
86
+
87
+ const stat = fsMod.lstatSync(hookPath);
88
+ if (stat.isSymbolicLink()) {
89
+ return { status: 'foreign', hook: hookPath, message: 'a foreign commit-msg hook (symlink) is present; left untouched.' };
90
+ }
91
+
92
+ const existingText = fsMod.readFileSync(hookPath, 'utf8');
93
+ if (!isTrailheadHook(existingText)) {
94
+ return { status: 'foreign', hook: hookPath, message: 'a foreign commit-msg hook is present; left untouched.' };
95
+ }
96
+
97
+ if (normalize(existingText) === normalize(templateText)) {
98
+ if ((stat.mode & 0o777) !== 0o755) fsMod.chmodSync(hookPath, 0o755);
99
+ return { status: 'current', hook: hookPath, message: 'the trailhead commit-msg hook is already current.' };
100
+ }
101
+
102
+ // Upgrade atomico: scrive su un temp file unico nella stessa dir, poi rename
103
+ // sopra l'hook esistente (mai un truncate in place).
104
+ const tmpPath = path.join(hooksDir, `.commit-msg.trailhead-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
105
+ try {
106
+ fsMod.writeFileSync(tmpPath, templateText);
107
+ fsMod.chmodSync(tmpPath, 0o755);
108
+ fsMod.renameSync(tmpPath, hookPath);
109
+ } catch (err) {
110
+ try { fsMod.unlinkSync(tmpPath); } catch { /* best effort cleanup */ }
111
+ throw err;
112
+ }
113
+ return { status: 'upgraded', hook: hookPath, message: 'upgraded the trailhead commit-msg hook to the current template.' };
114
+ }
115
+
116
+ function parseArgs(argv) {
117
+ let repo = null;
118
+ for (let i = 0; i < argv.length; i++) {
119
+ const arg = argv[i];
120
+ if (arg === '--repo') {
121
+ i++;
122
+ if (i >= argv.length) return { error: 'usage: --repo requires a value' };
123
+ repo = argv[i];
124
+ } else {
125
+ return { error: `usage: unknown argument: ${arg}` };
126
+ }
127
+ }
128
+ return { repo };
129
+ }
130
+
131
+ function writeResult(stdout, result) {
132
+ stdout.write(JSON.stringify(result) + '\n');
133
+ }
134
+
135
+ function main(argv, { cwd, stdout } = {}) {
136
+ const cwd_ = cwd || process.cwd();
137
+ const out = stdout || process.stdout;
138
+
139
+ const parsed = parseArgs(argv || []);
140
+ if (parsed.error) {
141
+ process.stderr.write(parsed.error + '\n');
142
+ return 2;
143
+ }
144
+
145
+ const repoDir = parsed.repo ? path.resolve(cwd_, parsed.repo) : cwd_;
146
+ let hooksDirForError = null;
147
+ try {
148
+ const target = resolveHooksTarget({ repoDir });
149
+ if (target.skip) {
150
+ writeResult(out, { status: 'skipped', hook: null, message: 'not a git repository; nothing to sync.' });
151
+ return 0;
152
+ }
153
+ hooksDirForError = target.hooksDir;
154
+ const templatePath = path.join(__dirname, 'trailhead-commit-msg');
155
+ const templateText = fs.readFileSync(templatePath, 'utf8');
156
+ const result = syncCommitMsgHook({ hooksDir: target.hooksDir, templateText, hooksPathSet: target.hooksPathSet, fs });
157
+ writeResult(out, result);
158
+ return 0;
159
+ } catch (err) {
160
+ const hookGuess = hooksDirForError ? path.join(hooksDirForError, 'commit-msg') : null;
161
+ writeResult(out, { status: 'skipped', hook: hookGuess, message: 'error: ' + (err && err.message ? err.message : String(err)) });
162
+ return 0;
163
+ }
164
+ }
165
+
166
+ module.exports = { resolveHooksTarget, isTrailheadHook, normalize, syncCommitMsgHook, parseArgs, main };
167
+
168
+ if (require.main === module) {
169
+ const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout });
170
+ process.exit(code);
171
+ }