@marcomigozzi/trailhead 0.16.1 → 0.18.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.
Files changed (154) hide show
  1. package/README.it.md +82 -25
  2. package/README.md +82 -25
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/capture-action.test.js +167 -0
  6. package/bin/capture-docs.test.js +32 -0
  7. package/bin/engine-route-docs.test.js +155 -0
  8. package/bin/facet-stamp-docs.test.js +79 -0
  9. package/bin/forced-mutation-docs.test.js +38 -0
  10. package/bin/grill-model-docs.test.js +155 -0
  11. package/bin/grilling-holistic-review-docs.test.js +7 -3
  12. package/bin/guard-axis-docs.test.js +69 -0
  13. package/bin/inbox-needs-info-docs.test.js +68 -0
  14. package/bin/lib/codex-projection.js +28 -19
  15. package/bin/lib/codex-projection.test.js +77 -20
  16. package/bin/lib/host-descriptor.js +1 -1
  17. package/bin/lib/host-descriptor.test.js +6 -0
  18. package/bin/needs-you-docs.test.js +9 -2
  19. package/bin/options-in-chat-docs.test.js +65 -0
  20. package/bin/pick-stress-test-docs.test.js +1 -1
  21. package/bin/pipeline-review-docs.test.js +118 -0
  22. package/bin/plan-review-budget-docs.test.js +63 -0
  23. package/bin/repo-target-docs.test.js +41 -0
  24. package/bin/report-status-docs.test.js +10 -2
  25. package/bin/seam-coverage-docs.test.js +46 -0
  26. package/bin/session-trust-docs.test.js +37 -0
  27. package/bin/standards-brief-docs.test.js +112 -0
  28. package/bin/surfaced-scope-docs.test.js +57 -0
  29. package/bin/test-once-docs.test.js +101 -0
  30. package/bin/test-run-docs.test.js +47 -0
  31. package/bin/trailhead.js +15 -8
  32. package/bin/trailhead.test.js +71 -17
  33. package/bin/unblock-docs.test.js +56 -0
  34. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  35. package/package.json +1 -1
  36. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  37. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  38. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  39. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  40. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  41. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  42. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  43. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  44. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  45. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  46. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  47. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  48. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  49. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  50. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  51. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  52. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  53. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  54. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  55. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  56. package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
  57. package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
  58. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
  59. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  60. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  61. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  62. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  63. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  64. package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
  65. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  66. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  67. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  68. package/plugins/trailhead/hooks/mod/README.md +42 -17
  69. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  70. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  71. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  72. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  73. package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
  74. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  75. package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
  76. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  77. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  79. package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
  80. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  81. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  83. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  84. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  85. package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
  86. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  87. package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
  88. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  89. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  90. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  91. package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
  92. package/plugins/trailhead/skills/_shared/substrate.md +4 -3
  93. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  94. package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
  95. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  96. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  103. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  104. package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
  105. package/plugins/trailhead/skills/_shared/techniques.md +8 -6
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +11 -6
  111. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
  112. package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
  113. package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
  114. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  115. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
  116. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  117. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  118. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  119. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  120. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  121. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  122. package/plugins/trailhead/templates/trailhead-capture.js +419 -0
  123. package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
  125. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  126. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  127. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  128. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  135. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  136. package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
  137. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  138. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  139. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  140. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  141. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  142. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  143. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  144. package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
  145. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  146. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  147. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  148. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  149. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  150. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  151. package/plugins/trailhead/tests/unscrub.ts +8 -0
  152. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  153. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
  154. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-checkout-find.js. Run: node checkout-find.test.js
3
+ // No framework: plain asserts, same style as checkout-in.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. parseRemote / matches: pure URL handling.
7
+ // 2. findCheckouts over temp roots: matching, walk rules, truncation, tiers.
8
+ // 3. The CLI: statuses, default tiers under a temp HOME, usage errors.
9
+ const assert = require('assert');
10
+ const { spawnSync } = require('child_process');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+
15
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-checkout-find.js');
16
+
17
+ // require() must print nothing and must not exit.
18
+ const probe = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(scriptPath)}); process.stdout.write('alive')`], { encoding: 'utf8' });
19
+ assert.strictEqual(probe.stdout, 'alive', 'require prints nothing and does not exit');
20
+ const { parseRemote, parseRepoShape, matches, findCheckouts } = require(scriptPath);
21
+
22
+ let passed = 1;
23
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
24
+ const eq = (name, a, b) => { assert.deepStrictEqual(a, b, name); passed++; };
25
+
26
+ // --- 1. pure helpers ---------------------------------------------------------------
27
+ const GH = { host: 'github.com', path: 'o/r' };
28
+ for (const u of [
29
+ 'https://github.com/o/r.git', 'https://github.com/o/r', 'https://github.com/o/r/', 'ssh://git@github.com/o/r.git',
30
+ 'git@github.com:o/r.git', 'git@github.com:o/r', 'github.com:o/r', 'https://user@GitHub.com/o/r.git', 'git://github.com/o/r.git',
31
+ ' git@github.com:o/r.git\n',
32
+ ]) eq(`parse ${u.trim()}`, parseRemote(u), GH);
33
+ eq('port kept in the host', parseRemote('ssh://git@ghe.example.com:2222/o/r.git'), { host: 'ghe.example.com:2222', path: 'o/r' });
34
+ eq('nested group path', parseRemote('git@gitlab.com:g/sub/r.git'), { host: 'gitlab.com', path: 'g/sub/r' });
35
+ eq('local path is not a remote', parseRemote('/srv/git/r.git'), null);
36
+ eq('relative path is not a remote', parseRemote('../r'), null);
37
+ eq('empty is not a remote', parseRemote(''), null);
38
+
39
+ ok('github path is case-insensitive', matches('git@github.com:Foo/Bar.git', 'github.com/foo/bar'));
40
+ ok('host is case-insensitive', matches('https://GITHUB.com/o/r', 'github.com/o/r'));
41
+ ok('bare owner/name matches any host', matches('git@gitlab.com:o/r.git', 'o/r'));
42
+ ok('host mismatch rejects', !matches('git@gitlab.com:o/r.git', 'github.com/o/r'));
43
+ ok('path case is exact off github', !matches('https://gitlab.com/O/r', 'gitlab.com/o/r') && matches('https://gitlab.com/o/r', 'gitlab.com/o/r'));
44
+ ok('different repo rejects', !matches('https://github.com/o/other', 'github.com/o/r'));
45
+ ok('wanted port must equal the remote port', matches('ssh://git@h.io:2222/o/r', 'h.io:2222/o/r') && !matches('ssh://git@h.io:2223/o/r', 'h.io:2222/o/r'));
46
+ ok('port-less wanted host ignores the remote port', matches('ssh://git@h.io:2222/o/r', 'h.io/o/r'));
47
+ eq('repo shape: a dotless host is a host', parseRepoShape('gitea/owner/name'), { host: 'gitea', path: 'owner/name' });
48
+ eq('repo shape: two segments is no shape', parseRepoShape('owner/name'), null);
49
+ ok('a parsed dotless host matches a scp remote', matches('git@gitea:owner/name.git', { host: 'gitea', path: 'owner/name' }));
50
+ ok('a parsed dotless host rejects another host', !matches('git@forge:owner/name.git', { host: 'gitea', path: 'owner/name' }));
51
+ ok('nested group matches', matches('git@gitlab.com:g/sub/r.git', 'gitlab.com/g/sub/r'));
52
+
53
+ // --- 2. findCheckouts --------------------------------------------------------------
54
+ const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'checkout-find-')));
55
+ const git = (cwd, ...args) => {
56
+ const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8' });
57
+ assert.strictEqual(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
58
+ };
59
+ function makeRepo(dir, origin) {
60
+ fs.mkdirSync(dir, { recursive: true });
61
+ git(dir, 'init', '-q', '-b', 'main');
62
+ git(dir, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'init');
63
+ if (origin) git(dir, 'remote', 'add', 'origin', origin);
64
+ return fs.realpathSync(dir);
65
+ }
66
+ const fresh = (name) => { const d = path.join(tmp, name); fs.mkdirSync(d, { recursive: true }); return fs.realpathSync(d); };
67
+ const WANT = 'github.com/o/r';
68
+
69
+ {
70
+ const root = fresh('basic');
71
+ const a = makeRepo(path.join(root, 'a'), 'git@github.com:O/R.git');
72
+ makeRepo(path.join(root, 'b'), 'https://github.com/o/other');
73
+ makeRepo(path.join(root, 'c'), null);
74
+ eq('lone match is found (case, .git, scp)', findCheckouts(WANT, { roots: [root] }), { status: 'found', path: a });
75
+ const https = makeRepo(path.join(root, 'deep', 'd'), 'https://github.com/o/r');
76
+ const r = findCheckouts(WANT, { roots: [root] });
77
+ eq('ssh and https both match: ambiguous, sorted', r, { status: 'ambiguous', candidates: [{ path: a, kind: 'main' }, { path: https, kind: 'main' }] });
78
+ eq('no match is none with the searched roots', findCheckouts('github.com/x/y', { roots: [root] }), { status: 'none', searched: [root] });
79
+ }
80
+ {
81
+ const root = fresh('nested');
82
+ const outer = makeRepo(path.join(root, 'outer'), 'https://github.com/o/other');
83
+ makeRepo(path.join(outer, 'inner'), 'https://github.com/o/r');
84
+ eq('a nested repo under a checkout is not descended', findCheckouts(WANT, { roots: [root] }).status, 'none');
85
+ }
86
+ {
87
+ const root = fresh('worktrees');
88
+ const main = makeRepo(path.join(root, 'main'), 'https://github.com/o/r');
89
+ git(main, 'worktree', 'add', '-q', '-b', 'wt', path.join(root, 'wt'));
90
+ const wt = fs.realpathSync(path.join(root, 'wt'));
91
+ eq('main + worktree is ambiguous with kinds', findCheckouts(WANT, { roots: [root] }),
92
+ { status: 'ambiguous', candidates: [{ path: main, kind: 'main' }, { path: wt, kind: 'linked' }].sort((x, y) => (x.path < y.path ? -1 : 1)) });
93
+ const lone = fresh('lone-wt');
94
+ const base = makeRepo(path.join(tmp, 'elsewhere-main'), 'https://github.com/o/r');
95
+ git(base, 'worktree', 'add', '-q', '-b', 'wt2', path.join(lone, 'wt'));
96
+ eq('a lone worktree is found', findCheckouts(WANT, { roots: [lone] }), { status: 'found', path: fs.realpathSync(path.join(lone, 'wt')) });
97
+ }
98
+ {
99
+ const root = fresh('overlap');
100
+ const a = makeRepo(path.join(root, 'sub', 'a'), 'https://github.com/o/r');
101
+ eq('overlapping roots never double-count', findCheckouts(WANT, { roots: [root, path.join(root, 'sub'), root] }), { status: 'found', path: a });
102
+ fs.symlinkSync(root, path.join(root, 'sub', 'loop'));
103
+ fs.symlinkSync(path.join(root, 'sub', 'a'), path.join(root, 'alias'));
104
+ eq('a symlink cycle and an alias never double-count', findCheckouts(WANT, { roots: [root] }), { status: 'found', path: a });
105
+ }
106
+ {
107
+ const root = fresh('skips');
108
+ makeRepo(path.join(root, 'node_modules', 'pkg'), 'https://github.com/o/r');
109
+ makeRepo(path.join(root, '.hidden', 'x'), 'https://github.com/o/r');
110
+ eq('node_modules and dot-dirs are skipped', findCheckouts(WANT, { roots: [root] }).status, 'none');
111
+ }
112
+ {
113
+ const root = fresh('depth');
114
+ const deep = makeRepo(path.join(root, 'l1', 'l2', 'l3', 'r'), 'https://github.com/o/r');
115
+ eq('depth 3 reaches the 3rd level below the root', findCheckouts(WANT, { roots: [root], depth: 3 }).status, 'none');
116
+ eq('depth 4 reaches the 4th level', findCheckouts(WANT, { roots: [root], depth: 4 }), { status: 'found', path: deep });
117
+ const near = makeRepo(path.join(root, 'n1', 'n2', 'n3'), 'https://github.com/o/r');
118
+ eq('default depth 3 reaches level 3', findCheckouts(WANT, { roots: [root] }), { status: 'found', path: near });
119
+ }
120
+ {
121
+ const root = fresh('nested-group');
122
+ const g = makeRepo(path.join(root, 'g'), 'git@gitlab.com:grp/sub/repo.git');
123
+ eq('a nested group/sub/repo remote is found', findCheckouts('gitlab.com/grp/sub/repo', { roots: [root] }), { status: 'found', path: g });
124
+ const p = makeRepo(path.join(root, 'p'), 'ssh://git@ghe.io:2222/o/r.git');
125
+ eq('port kept: wanted with the port matches', findCheckouts('ghe.io:2222/o/r', { roots: [root] }), { status: 'found', path: p });
126
+ eq('port kept: another port does not', findCheckouts('ghe.io:2223/o/r', { roots: [root] }).status, 'none');
127
+ eq('path case exact off github', findCheckouts('gitlab.com/GRP/sub/repo', { roots: [root] }).status, 'none');
128
+ }
129
+ {
130
+ const root = fresh('broken');
131
+ const a = makeRepo(path.join(root, 'ok'), 'https://github.com/o/r');
132
+ const bad = makeRepo(path.join(root, 'bad'), 'https://github.com/o/r');
133
+ fs.writeFileSync(path.join(bad, '.git', 'config'), '[remote "origin"\n\turl = ???\n');
134
+ eq('a broken config is skipped', findCheckouts(WANT, { roots: [root] }), { status: 'found', path: a });
135
+ eq('a checkout with no origin is skipped', findCheckouts(WANT, { roots: [fresh('noorigin')] }).status, 'none');
136
+ }
137
+ {
138
+ // Cap: injected origin reader keeps it deterministic.
139
+ const root = fresh('cap');
140
+ for (let i = 0; i < 6; i++) fs.mkdirSync(path.join(root, `d${i}`));
141
+ const hit = makeRepo(path.join(root, 'a-hit'), 'https://github.com/o/r');
142
+ const originOf = (dir) => (dir === hit ? 'https://github.com/o/r' : null);
143
+ eq('no cap hit: found', findCheckouts(WANT, { roots: [root], originOf }), { status: 'found', path: hit });
144
+ eq('cap hit with a lone match is ambiguous and truncated', findCheckouts(WANT, { roots: [root], originOf, cap: 3 }),
145
+ { status: 'ambiguous', candidates: [{ path: hit, kind: 'main' }], truncated: true });
146
+ eq('cap hit with no match is none and truncated', findCheckouts('github.com/x/y', { roots: [root], originOf, cap: 2 }),
147
+ { status: 'none', searched: [root], truncated: true });
148
+ }
149
+ {
150
+ const focused = fresh('tier-focused');
151
+ const home = fresh('tier-home');
152
+ const f = makeRepo(path.join(focused, 'f'), 'https://github.com/o/r');
153
+ makeRepo(path.join(home, 'h'), 'https://github.com/o/r');
154
+ eq('a focused-tier match wins over a home-tier one', findCheckouts(WANT, { tiers: [[focused], [home]] }), { status: 'found', path: f });
155
+ const empty = fresh('tier-empty');
156
+ const h2 = makeRepo(path.join(home, 'only-home'), 'https://github.com/o/solo');
157
+ eq('home tier is searched when the focused one is empty', findCheckouts('github.com/o/solo', { tiers: [[empty], [home]] }), { status: 'found', path: h2 });
158
+ eq('a cap on the focused tier does not starve the home tier', findCheckouts('github.com/o/solo', { tiers: [[focused], [home]], cap: 3 }).status, 'found');
159
+ }
160
+
161
+ // --- 3. CLI ------------------------------------------------------------------------
162
+ const cli = (args, env = {}) => {
163
+ const r = spawnSync(process.execPath, [scriptPath, ...args], { encoding: 'utf8', env: { ...process.env, ...env } });
164
+ return { code: r.status, out: r.stdout.trim() ? JSON.parse(r.stdout.trim()) : null, err: r.stderr };
165
+ };
166
+ {
167
+ const root = fresh('cli');
168
+ const a = makeRepo(path.join(root, 'a'), 'https://github.com/o/r');
169
+ eq('cli found', cli(['--repo', WANT, '--roots', root]).out, { status: 'found', path: a });
170
+ const b = makeRepo(path.join(root, 'b'), 'git@github.com:o/r.git');
171
+ const amb = cli(['--repo', 'o/r', '--roots', root]);
172
+ eq('cli ambiguous (bare owner/name)', [amb.code, amb.out.status, amb.out.candidates.map((c) => c.path)], [0, 'ambiguous', [a, b]]);
173
+ eq('cli none', cli(['--repo', 'github.com/x/y', '--roots', root]).out, { status: 'none', searched: [root] });
174
+ eq('cli depth 0 sees only the roots', cli(['--repo', WANT, '--roots', root, '--depth', '0']).out.status, 'none');
175
+
176
+ // Default tiers under a temp HOME: a code folder wins, then $HOME itself.
177
+ const home = fresh('cli-home');
178
+ const inGit = makeRepo(path.join(home, 'Git', 'proj'), 'https://github.com/o/r');
179
+ makeRepo(path.join(home, 'stray', 'proj'), 'https://github.com/o/r');
180
+ const cwd = path.join(fresh('cli-cwd-parent'), 'cwd');
181
+ fs.mkdirSync(cwd);
182
+ eq('default tiers: the focused code folder wins', cli(['--repo', WANT, '--cwd', cwd], { HOME: home }).out, { status: 'found', path: inGit });
183
+ const home2 = fresh('cli-home2');
184
+ const stray = makeRepo(path.join(home2, 'stray', 'proj'), 'https://github.com/o/r');
185
+ eq('default tiers: falls back to $HOME', cli(['--repo', WANT, '--cwd', cwd], { HOME: home2 }).out, { status: 'found', path: stray });
186
+ }
187
+ {
188
+ const r = spawnSync(process.execPath, [scriptPath], { encoding: 'utf8' });
189
+ eq('missing --repo is a usage error', r.status, 2);
190
+ eq('a repo with no owner is a usage error', spawnSync(process.execPath, [scriptPath, '--repo', 'solo'], { encoding: 'utf8' }).status, 2);
191
+ eq('a bad --depth is a usage error', spawnSync(process.execPath, [scriptPath, '--repo', WANT, '--depth', 'x'], { encoding: 'utf8' }).status, 2);
192
+ }
193
+
194
+ fs.rmSync(tmp, { recursive: true, force: true });
195
+ console.log(`checkout-find: ${passed} assertions passed`);
@@ -5,7 +5,8 @@
5
5
  // Layers covered:
6
6
  // 1. The pure token helpers: encode / decode round-trip.
7
7
  // 2. The CLI against real temp git repos: invalid, here, move, the `cd` field.
8
- // 3. The protocol contract: the work skill moves before it loads or preflights.
8
+ // 3. The remote fallback (#329): a missing path plus a repo in the token.
9
+ // 4. The protocol contract: the work skill moves before it loads or preflights.
9
10
  const assert = require('assert');
10
11
  const { spawnSync } = require('child_process');
11
12
  const path = require('path');
@@ -26,17 +27,20 @@ const sh = (cwd, ...args) => {
26
27
  assert.strictEqual(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
27
28
  return r.stdout;
28
29
  };
29
- function makeRepo(dir) {
30
+ function makeRepo(dir, origin) {
30
31
  fs.mkdirSync(dir, { recursive: true });
31
32
  sh(dir, 'init', '-q', '-b', 'main');
32
33
  sh(dir, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'init');
34
+ if (origin) sh(dir, 'remote', 'add', 'origin', origin);
33
35
  return fs.realpathSync(dir);
34
36
  }
35
- const run = (inArg, cwd) => {
36
- const r = spawnSync(process.execPath, [scriptPath, '--in', inArg, '--cwd', cwd], { encoding: 'utf8' });
37
+ const run = (inArg, cwd, roots) => {
38
+ const extra = roots ? ['--roots', roots.join(':')] : [];
39
+ const r = spawnSync(process.execPath, [scriptPath, '--in', inArg, '--cwd', cwd, ...extra], { encoding: 'utf8' });
37
40
  return { code: r.status, out: r.stdout.trim() ? JSON.parse(r.stdout.trim()) : null, err: r.stderr };
38
41
  };
39
- const tokenOf = (p) => encodeIn(p).slice('--in='.length);
42
+ const fresh = (name) => { const d = path.join(tmp, name); fs.mkdirSync(d, { recursive: true }); return fs.realpathSync(d); };
43
+ const tokenOf = (p, repo) => encodeIn(p, repo).slice('--in='.length);
40
44
 
41
45
  // --- 1. pure helpers ---------------------------------------------------------------
42
46
  const odd = [
@@ -51,11 +55,30 @@ const odd = [
51
55
  for (const p of odd) {
52
56
  const enc = encodeIn(p);
53
57
  ok(`token shape ${p}`, /^--in=[A-Za-z0-9_-]+$/.test(enc));
54
- eq(`round-trip ${p}`, decodeIn(enc.slice(5)), p);
58
+ eq(`round-trip ${p}`, decodeIn(enc.slice(5)), { path: p, repo: null });
55
59
  }
56
60
  eq('decode of garbage', decodeIn('!!!'), null);
57
61
  eq('decode of empty', decodeIn(''), null);
58
62
 
63
+ // Two-part token: path + host/owner/name.
64
+ {
65
+ const enc = encodeIn('/w/with space/r', 'github.com/o/r');
66
+ ok('two-part shape', /^--in=[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(enc));
67
+ eq('two-part round-trip', decodeIn(enc.slice(5)), { path: '/w/with space/r', repo: 'github.com/o/r' });
68
+ eq('encodeIn without a repo stays one part', encodeIn('/r'), `--in=${Buffer.from('/r').toString('base64url')}`);
69
+ // An old helper (#312) only accepts [A-Za-z0-9_-]+: a two-part token is invalid there, so the rollout fails closed.
70
+ ok('an old helper shape check rejects a two-part token', !/^[A-Za-z0-9_-]+$/.test(enc.slice(5)));
71
+ const b = (s) => Buffer.from(s).toString('base64url');
72
+ eq('extra separator is malformed', decodeIn(`${b('/r')}.${b('github.com/o/r')}.${b('x')}`), null);
73
+ eq('invalid base64url in the path part', decodeIn(`!!.${b('github.com/o/r')}`), null);
74
+ eq('invalid base64url in the repo part', decodeIn(`${b('/r')}.!!`), null);
75
+ eq('empty repo part', decodeIn(`${b('/r')}.`), null);
76
+ eq('empty path part', decodeIn(`.${b('github.com/o/r')}`), null);
77
+ eq('a dotless host is a valid repo part', decodeIn(`${b('/r')}.${b('gitea/owner/name')}`), { path: '/r', repo: 'gitea/owner/name' });
78
+ eq('repo part without a host is malformed', decodeIn(`${b('/r')}.${b('owner/name')}`), null);
79
+ eq('repo part that is not host/path is malformed', decodeIn(`${b('/r')}.${b('justone')}`), null);
80
+ }
81
+
59
82
  // --- 2. CLI ------------------------------------------------------------------------
60
83
  const main = makeRepo(path.join(tmp, 'main repo'));
61
84
  const other = makeRepo(path.join(tmp, 'other'));
@@ -145,7 +168,54 @@ fs.mkdirSync(plain);
145
168
  }
146
169
  }
147
170
 
148
- // --- 3. protocol contract: the move comes before anything else -----------------------
171
+ // --- 3. remote fallback (#329): roots are always explicit, the real home is never scanned ---
172
+ {
173
+ const REPO = 'github.com/o/r';
174
+ const gone = path.join(tmp, 'moved-away', 'r');
175
+ const roots = fresh('relocate-roots');
176
+ const found = makeRepo(path.join(roots, 'r'), 'git@github.com:o/r.git');
177
+ makeRepo(path.join(roots, 'unrelated'), 'https://github.com/o/other');
178
+
179
+ const mv = run(tokenOf(gone, REPO), other, [roots]);
180
+ eq('missing path + repo moves to the matching repo', [mv.code, mv.out.status, mv.out.path, mv.out.relocatedFrom], [0, 'move', found, gone]);
181
+ ok('the relocated move carries a cd', mv.out.cd === `cd -- '${found}'`);
182
+ const here = run(tokenOf(gone, REPO), found, [roots]);
183
+ eq('here when cwd is the found repo', [here.out.status, here.out.path, here.out.relocatedFrom], ['here', found, gone]);
184
+
185
+ const two = fresh('relocate-two');
186
+ const t1 = makeRepo(path.join(two, 'a'), 'https://github.com/o/r');
187
+ const t2 = makeRepo(path.join(two, 'b'), 'https://github.com/o/r');
188
+ const ch = run(tokenOf(gone, REPO), other, [two]);
189
+ eq('two matches -> choose', [ch.out.status, ch.out.path], ['choose', gone]);
190
+ eq('choose lists both candidates', ch.out.candidates, [{ path: t1, kind: 'main' }, { path: t2, kind: 'main' }]);
191
+ ok('choose has a reason and no cd', typeof ch.out.reason === 'string' && ch.out.reason.length > 0 && !('cd' in ch.out));
192
+
193
+ const none = run(tokenOf(gone, 'github.com/x/y'), other, [roots]);
194
+ eq('no match -> invalid', none.out.status, 'invalid');
195
+ ok('invalid names the repo', none.out.reason.includes('github.com/x/y'));
196
+ eq('one-part token with a missing path stays invalid', run(tokenOf(gone), other, [roots]).out.status, 'invalid');
197
+ fs.mkdirSync(path.join(found, 'sub'));
198
+ eq('an existing non-root path with a repo is invalid, no relocation', run(tokenOf(path.join(found, 'sub'), REPO), other, [roots]).out.status, 'invalid');
199
+ eq('a relative path with a repo is invalid without a search', run(tokenOf('rel/r', REPO), other, [roots]).out.status, 'invalid');
200
+ eq('an existing path ignores the repo', run(tokenOf(main, REPO), other, [roots]).out.path, main);
201
+ eq('no relocatedFrom on a plain move', 'relocatedFrom' in run(tokenOf(main, REPO), other, [roots]).out, false);
202
+ eq('a malformed two-part token is invalid', run(`${tokenOf(gone)}.!!`, other, [roots]).out.status, 'invalid');
203
+ }
204
+
205
+ // Dotless hosts (gitea, localhost): the repo part is read by shape, never by a dot heuristic.
206
+ {
207
+ const exists = run(tokenOf(main, 'gitea/owner/name'), other, [fresh('dotless-unused')]);
208
+ eq('dotless-host token with an existing path moves', [exists.out.status, exists.out.path], ['move', main]);
209
+ eq('dotless-host token with an existing path is here in it', run(tokenOf(main, 'localhost/o/n'), main, [fresh('dotless-unused2')]).out.status, 'here');
210
+ const roots = fresh('dotless-roots');
211
+ const g = makeRepo(path.join(roots, 'n'), 'git@gitea:owner/name.git');
212
+ const gone = path.join(tmp, 'dotless-gone', 'n');
213
+ const r = run(tokenOf(gone, 'gitea/owner/name'), other, [roots]);
214
+ eq('dotless-host relocation moves with relocatedFrom', [r.out.status, r.out.path, r.out.relocatedFrom], ['move', g, gone]);
215
+ eq('another dotless host does not match', run(tokenOf(gone, 'forge/owner/name'), other, [roots]).out.status, 'invalid');
216
+ }
217
+
218
+ // --- 4. protocol contract: the move comes before anything else -----------------------
149
219
  {
150
220
  const skill = fs.readFileSync(skillPath, 'utf8');
151
221
  const move = skill.indexOf('## Move into the handed checkout');
@@ -166,6 +236,12 @@ fs.mkdirSync(plain);
166
236
  ok('stops when the post-check is not here', /could not move/.test(section));
167
237
  ok('stops when the helper is missing', /missing[^.]*stop|stop[^.]*missing/i.test(section));
168
238
  ok('never moves by hand', /by hand/.test(section));
239
+ ok('has a choose branch that asks', /`choose`/.test(section) && /AskUserQuestion/.test(section));
240
+ ok('choose never picks by hand', /never pick one by hand/.test(section));
241
+ ok('choose re-checks for here', /original `--in` token/.test(section) && /could not move/.test(section));
242
+ ok('relocatedFrom gets a result-oriented line', /`relocatedFrom`[^.]*moved/.test(section));
243
+ const subs = fs.readFileSync(path.resolve(__dirname, '..', '..', 'skills', '_shared', 'substrate-commands.md'), 'utf8');
244
+ ok('substrate documents the finder', /## Find a checkout by its remote/.test(subs) && subs.includes('trailhead-checkout-find.js'));
169
245
  ok('Routing points at the move section', /Routing[\s\S]*Move into the handed checkout/.test(skill.slice(skill.indexOf('## Routing'))));
170
246
  }
171
247
 
@@ -407,6 +407,25 @@ function runMain(argv, t, extra = {}) {
407
407
  ok('create: a secret in the generated body aborts the create', guardHit.code === 1 && ghost.created.length === 0);
408
408
  }
409
409
 
410
+ // --- --update-only: never opens a dashboard of its own (#330) -----------------
411
+ {
412
+ const t = makeTracker({ dash: null, pinned: [] });
413
+ const r = runMain(['--repo', 'o/r', '--update-only'], t);
414
+ ok('update-only: no dashboard -> skip line, exit 0', r.code === 0 && r.out === 'skipped: no dashboard issue (update-only)\n');
415
+ ok('update-only: no dashboard -> no create, pin, label or write call', t.created.length === 0 && t.pins.length === 0 && t.labels.length === 0 && t.writes.length === 0 && !t.calls.some((c) => /^label create|^issue create|^issue edit|graphql/.test(c)));
416
+ }
417
+ {
418
+ const t = makeTracker();
419
+ const r = runMain(['--repo', 'o/r', '--update-only'], t);
420
+ ok('update-only: a dashboard present is rewritten as usual', r.code === 0 && t.writes.length === 1 && /^dashboard #33: 3 maps/.test(r.out) && !/skipped/.test(r.out));
421
+ ok('update-only: a dashboard present keeps the label ensure and creates nothing', t.labels.includes('trailhead:dashboard') && t.created.length === 0);
422
+ }
423
+ {
424
+ const t = makeTracker({ dups: { 'trailhead:dashboard': [{ number: 90, title: 'dup' }] } });
425
+ const r = runMain(['--repo', 'o/r', '--update-only'], t);
426
+ ok('update-only: duplicate dashboards still abort, never skipped', r.code === 1 && /ambiguous singleton/.test(r.out) && t.writes.length === 0);
427
+ }
428
+
410
429
  // --- I2: a pin-check failure after the write is not an abort ------------------
411
430
  {
412
431
  const t = makeTracker({ failOn: /pinnedIssues/ });
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ // Preflight dell'auth di gh (SessionStart): segnala una volta, all'avvio, un
3
+ // GH_TOKEN/GITHUB_TOKEN non valido nell'ambiente che oscura un login gh
4
+ // funzionante. Pura: il runner è iniettabile (spawnSync shape), i test non
5
+ // toccano mai la rete. Mai lancia: ogni errore o caso dubbio -> null.
6
+ const { spawnSync } = require('child_process');
7
+
8
+ // gh dà la precedenza a GH_TOKEN, poi GITHUB_TOKEN, poi al login salvato.
9
+ const TOKEN_VARS = ['GH_TOKEN', 'GITHUB_TOKEN'];
10
+ // SessionStart scatta di nuovo su resume e compact: li saltiamo (una volta sola).
11
+ const SKIP_SOURCES = new Set(['resume', 'compact']);
12
+
13
+ // Budget totale condiviso da tutti i probe: SessionStart non resta bloccato oltre.
14
+ const BUDGET_MS = 5000;
15
+
16
+ function defaultRun(env, timeoutMs = BUDGET_MS) {
17
+ try {
18
+ return spawnSync('gh', ['api', 'user', '-q', '.login'], {
19
+ env, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
20
+ });
21
+ } catch (error) {
22
+ return { status: null, stdout: '', stderr: '', error };
23
+ }
24
+ }
25
+
26
+ function activeVar(env) {
27
+ return TOKEN_VARS.find((v) => typeof env[v] === 'string' && env[v] !== '') || null;
28
+ }
29
+
30
+ function isSuccess(r) {
31
+ return Boolean(r) && !r.error && r.status === 0;
32
+ }
33
+ // Solo un 401 esplicito conta come rifiuto: tutto il resto (rete, timeout,
34
+ // gh assente) degrada in silenzio.
35
+ function isAuthFailure(r) {
36
+ if (!r || r.error || r.status === 0 || r.status === null || r.status === undefined) return false;
37
+ return /HTTP 401|Bad credentials/.test(String(r.stderr || '')) || /"status":\s*"401"/.test(String(r.stdout || ''));
38
+ }
39
+
40
+ // gh non loggato: exit 4 o invito a "gh auth login" (non un errore di rete).
41
+ function isNotLoggedIn(r) {
42
+ if (!r || r.error) return false;
43
+ return r.status === 4 || /gh auth login/.test(String(r.stderr || ''));
44
+ }
45
+
46
+ function notice(rejected, working) {
47
+ const vars = rejected.join(' and ');
48
+ const unset = rejected.map((v) => `${v}=`).join(' ');
49
+ if (working === null) {
50
+ return `GitHub rejects the ${vars} set in this environment (HTTP 401) and there is no working \`gh\` login. `
51
+ + `Tell the user to remove ${rejected.length > 1 ? 'them' : 'it'} from their shell profile and run \`gh auth login\`.`;
52
+ }
53
+ const what = working === 'stored' ? 'the working `gh` login' : `the valid ${working}`;
54
+ return `GitHub rejects the ${vars} set in this environment (HTTP 401); it overrides ${what}, so \`gh\` calls fail. `
55
+ + `Tell the user to remove ${rejected.length > 1 ? 'them' : rejected[0]} from their shell profile and restart; `
56
+ + `until then run \`gh\` as \`${unset} gh ...\`.`;
57
+ }
58
+
59
+ function check({ env, run, source, now } = {}) {
60
+ try {
61
+ if (SKIP_SOURCES.has(source)) return null;
62
+ const base = env || process.env;
63
+ if (!activeVar(base)) return null;
64
+ const runner = run || defaultRun;
65
+ let cur = { ...base };
66
+ const rejected = [];
67
+ const clock = now || Date.now;
68
+ const start = clock();
69
+ for (;;) {
70
+ const remaining = BUDGET_MS - (clock() - start);
71
+ if (remaining <= 0) return null;
72
+ const r = runner(cur, remaining);
73
+ if (isSuccess(r)) {
74
+ return rejected.length ? notice(rejected, activeVar(cur) || 'stored') : null;
75
+ }
76
+ const active = activeVar(cur);
77
+ // dopo un 401 confermato, senza altre variabili da togliere, anche "non
78
+ // loggato" vale "nessun login funzionante"; rete/timeout restano silenziosi
79
+ if (!active && rejected.length && (isAuthFailure(r) || isNotLoggedIn(r))) return notice(rejected, null);
80
+ if (!isAuthFailure(r) || !active) return null;
81
+ rejected.push(active);
82
+ cur = { ...cur, [active]: '' };
83
+ }
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ module.exports = { check, defaultRun };
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ // Test per lib/gh-auth-preflight.js. Run: node gh-auth-preflight.test.js
3
+ // Nessun framework: assert semplici, runner iniettato (mai la rete).
4
+ const assert = require('assert');
5
+ const path = require('path');
6
+ const { check } = require(path.join(__dirname, 'gh-auth-preflight.js'));
7
+
8
+ let passed = 0;
9
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
10
+
11
+ const AUTH_ERR = { status: 1, stdout: '{"message":"Bad credentials","status":"401"}', stderr: 'gh: Bad credentials (HTTP 401)' };
12
+ const GOOD = { status: 0, stdout: 'octo\n', stderr: '' };
13
+
14
+ // Runner che modella la precedenza di gh: GH_TOKEN, poi GITHUB_TOKEN, poi il login salvato.
15
+ // valid: { GH_TOKEN, GITHUB_TOKEN, stored } = true/false.
16
+ function model(valid, failure) {
17
+ const calls = [];
18
+ const run = (env) => {
19
+ calls.push(env);
20
+ const v = env.GH_TOKEN ? valid.GH_TOKEN : env.GITHUB_TOKEN ? valid.GITHUB_TOKEN : valid.stored;
21
+ if (v === 'other') return failure;
22
+ return v ? GOOD : AUTH_ERR;
23
+ };
24
+ run.calls = calls;
25
+ return run;
26
+ }
27
+
28
+ // nessuna variabile token -> null, runner mai chiamato
29
+ {
30
+ const run = model({ stored: false });
31
+ ok('no token var: null', check({ env: { PATH: 'x' }, run }) === null);
32
+ ok('no token var: runner not called', run.calls.length === 0);
33
+ }
34
+ // variabile vuota = non impostata
35
+ {
36
+ const run = model({ stored: false });
37
+ ok('empty var counts as unset', check({ env: { GH_TOKEN: '', GITHUB_TOKEN: '' }, run }) === null && run.calls.length === 0);
38
+ }
39
+ // token d'ambiente valido -> null
40
+ ok('env token works: null', check({ env: { GITHUB_TOKEN: 'good' }, run: model({ GITHUB_TOKEN: true }) }) === null);
41
+
42
+ // token rifiutato, login salvato valido -> notice (variante ii)
43
+ {
44
+ const run = model({ GITHUB_TOKEN: false, stored: true });
45
+ const n = check({ env: { GITHUB_TOKEN: 'bad' }, run });
46
+ ok('rejected + stored ok: notice', typeof n === 'string' && n.includes('GITHUB_TOKEN') && n.includes('overrides the working `gh` login'));
47
+ ok('rejected + stored ok: workaround', n.includes('GITHUB_TOKEN= gh'));
48
+ ok('notice never has the token value', !n.includes('bad'));
49
+ ok('stripped call used', run.calls.length === 2 && run.calls[1].GITHUB_TOKEN === '');
50
+ }
51
+ // rifiuto riconosciuto anche solo da stdout 401 o solo da stderr
52
+ for (const [label, res] of [
53
+ ['stdout only', { status: 1, stdout: '{"status": "401"}', stderr: '' }],
54
+ ['stderr only', { status: 1, stdout: '', stderr: 'gh: Bad credentials (HTTP 401)' }],
55
+ ]) {
56
+ const calls = [];
57
+ const run = (env) => { calls.push(env); return env.GITHUB_TOKEN ? res : GOOD; };
58
+ ok(`auth failure detected: ${label}`, check({ env: { GITHUB_TOKEN: 'bad' }, run }) !== null);
59
+ }
60
+ // token rifiutato e nessun login funzionante -> variante iii
61
+ {
62
+ const n = check({ env: { GITHUB_TOKEN: 'bad' }, run: model({ GITHUB_TOKEN: false, stored: false }) });
63
+ ok('nothing works: notice', n.includes('no working `gh` login') && n.includes('gh auth login'));
64
+ ok('nothing works: no workaround', !n.includes('GITHUB_TOKEN= gh') && !n.includes('overrides'));
65
+ }
66
+ // errori non di auth -> null
67
+ for (const [label, res] of [
68
+ ['network', { status: 1, stdout: '', stderr: 'error connecting to api.github.com' }],
69
+ ['timeout', { status: null, stdout: '', stderr: '', error: Object.assign(new Error('t'), { code: 'ETIMEDOUT' }) }],
70
+ ['ENOENT', { status: null, stdout: '', stderr: '', error: Object.assign(new Error('x'), { code: 'ENOENT' }) }],
71
+ ['throws', null],
72
+ ]) {
73
+ const run = () => { if (res === null) throw new Error('boom'); return res; };
74
+ ok(`non-auth failure: ${label} -> null`, check({ env: { GITHUB_TOKEN: 'bad' }, run }) === null);
75
+ }
76
+ // entrambe le variabili: GH_TOKEN rifiutato, GITHUB_TOKEN valido
77
+ {
78
+ const n = check({ env: { GH_TOKEN: 'bad', GITHUB_TOKEN: 'good' }, run: model({ GH_TOKEN: false, GITHUB_TOKEN: true, stored: false }) });
79
+ ok('both set: names only GH_TOKEN', n.includes('GH_TOKEN') && !n.includes('GH_TOKEN, GITHUB_TOKEN'));
80
+ ok('both set: says GITHUB_TOKEN works', n.includes('valid GITHUB_TOKEN') && n.includes('GH_TOKEN= gh'));
81
+ }
82
+ // entrambe rifiutate, login salvato valido
83
+ {
84
+ const n = check({ env: { GH_TOKEN: 'bad', GITHUB_TOKEN: 'bad2' }, run: model({ GH_TOKEN: false, GITHUB_TOKEN: false, stored: true }) });
85
+ ok('both rejected: names both', n.includes('GH_TOKEN') && n.includes('GITHUB_TOKEN') && n.includes('GH_TOKEN= GITHUB_TOKEN= gh'));
86
+ ok('both rejected: no token values', !n.includes('bad'));
87
+ }
88
+ // W1: token rifiutato (401 confermato), login salvato assente (exit 4) -> variante iii
89
+ {
90
+ const NOLOGIN = { status: 4, stdout: '', stderr: 'To get started with GitHub CLI, please run: gh auth login' };
91
+ const run = (env) => (env.GITHUB_TOKEN ? AUTH_ERR : NOLOGIN);
92
+ const n = check({ env: { GITHUB_TOKEN: 'bad' }, run });
93
+ ok('bad token + no stored login (exit 4): nothing works', typeof n === 'string' && n.includes('no working `gh` login'));
94
+ const run2 = (env) => (env.GITHUB_TOKEN ? AUTH_ERR : { status: 1, stdout: '', stderr: 'please run: gh auth login' });
95
+ ok('bad token + "gh auth login" in stderr: nothing works', /no working/.test(check({ env: { GITHUB_TOKEN: 'bad' }, run: run2 })));
96
+ // il primo probe (prima di un 401) non usa questa regola: exit 4 senza rifiuto -> null
97
+ ok('first probe exit 4: null', check({ env: { GITHUB_TOKEN: 'x' }, run: () => NOLOGIN }) === null);
98
+ }
99
+ // W1: token rifiutato, probe del login salvato in errore di rete -> null
100
+ {
101
+ const run = (env) => (env.GITHUB_TOKEN ? AUTH_ERR : { status: 1, stdout: '', stderr: 'error connecting to api.github.com' });
102
+ ok('bad token + stored network error: null', check({ env: { GITHUB_TOKEN: 'bad' }, run }) === null);
103
+ const run2 = (env) => (env.GITHUB_TOKEN ? AUTH_ERR : { status: null, stdout: '', stderr: '', error: new Error('t') });
104
+ ok('bad token + stored timeout: null', check({ env: { GITHUB_TOKEN: 'bad' }, run: run2 }) === null);
105
+ }
106
+ // I1: budget totale condiviso fra i probe
107
+ {
108
+ const mk = (step) => {
109
+ let t = 1000;
110
+ const timeouts = [];
111
+ const run = (env, timeoutMs) => { timeouts.push(timeoutMs); t += step; return env.GH_TOKEN || env.GITHUB_TOKEN ? AUTH_ERR : GOOD; };
112
+ return { run, now: () => t, timeouts };
113
+ };
114
+ const a = mk(2000);
115
+ const n = check({ env: { GH_TOKEN: 'b1', GITHUB_TOKEN: 'b2' }, run: a.run, now: a.now });
116
+ ok('budget: timeouts decrease and stay bounded', a.timeouts.length === 3 && a.timeouts[0] <= 5000 && a.timeouts[1] < a.timeouts[0] && a.timeouts[2] < a.timeouts[1] && a.timeouts[2] > 0);
117
+ ok('budget: notice still produced within budget', typeof n === 'string');
118
+ const b = mk(3000);
119
+ ok('budget exhausted: null', check({ env: { GH_TOKEN: 'b1', GITHUB_TOKEN: 'b2' }, run: b.run, now: b.now }) === null && b.timeouts.length === 2);
120
+ }
121
+ // source
122
+ for (const source of ['resume', 'compact']) {
123
+ const run = model({ GITHUB_TOKEN: false, stored: true });
124
+ ok(`source ${source}: skipped`, check({ env: { GITHUB_TOKEN: 'bad' }, run, source }) === null && run.calls.length === 0);
125
+ }
126
+ for (const source of ['startup', 'clear', undefined, 'weird']) {
127
+ const run = model({ GITHUB_TOKEN: false, stored: true });
128
+ ok(`source ${source}: checked`, check({ env: { GITHUB_TOKEN: 'bad' }, run, source }) !== null);
129
+ }
130
+ ok('no em-dash in notices', !/—/.test(check({ env: { GITHUB_TOKEN: 'bad' }, run: model({ GITHUB_TOKEN: false, stored: true }) })));
131
+
132
+ console.log(`gh-auth-preflight: ${passed} passed`);
@@ -0,0 +1,55 @@
1
+ // Mod side of #349: git spawns go through `env -u <repo-location vars> git ...`
2
+ // (the mod's process.run cannot delete env), and the name list matches the template's.
3
+ // The mod file is TypeScript: loaded through Node's type stripping (Node >= 22.18);
4
+ // skipped on an older Node, where the source checks below still run.
5
+ const assert = require('assert');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { GIT_LOCATION_VARS } = require('../../templates/trailhead-git-env.js');
9
+
10
+ const modDir = path.join(__dirname, '..', 'mod');
11
+ let passed = 0;
12
+ const t = (name, fn) => { fn(); passed++; console.log('ok -', name); };
13
+
14
+ let mod = null;
15
+ try { mod = require(path.join(modDir, 'git-env.ts')); } catch { /* no type stripping */ }
16
+
17
+ if (mod) {
18
+ t('the mod name list equals the template list', () => {
19
+ assert.deepStrictEqual([...mod.GIT_LOCATION_VARS], [...GIT_LOCATION_VARS]);
20
+ });
21
+ t('gitArgv starts with the env -u wrapper and ends with git args', () => {
22
+ const argv = mod.gitArgv('-C', '/r', 'status');
23
+ const gitAt = argv.indexOf('git');
24
+ assert.strictEqual(argv[0], 'env');
25
+ assert.deepStrictEqual(argv.slice(gitAt), ['git', '-C', '/r', 'status']);
26
+ const pairs = argv.slice(1, gitAt);
27
+ assert.deepStrictEqual(pairs.filter((_, i) => i % 2 === 0), GIT_LOCATION_VARS.map(() => '-u'));
28
+ assert.deepStrictEqual(pairs.filter((_, i) => i % 2 === 1), [...GIT_LOCATION_VARS]);
29
+ });
30
+ t('scrubGitEnv wraps a gh argv the same way', () => {
31
+ const argv = mod.scrubGitEnv(['gh', 'api', 'graphql']);
32
+ assert.strictEqual(argv[0], 'env');
33
+ assert.deepStrictEqual(argv.slice(-3), ['gh', 'api', 'graphql']);
34
+ });
35
+ t('the wrapper really drops GIT_DIR (env -u, run for real)', () => {
36
+ const { spawnSync } = require('child_process');
37
+ const argv = mod.scrubGitEnv([process.execPath, '-p', "'GIT_DIR' in process.env"]);
38
+ const r = spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', env: { ...process.env, GIT_DIR: '/x' } });
39
+ assert.strictEqual(r.stdout.trim(), 'false');
40
+ });
41
+ }
42
+
43
+ t('no mod file spawns git without the wrapper', () => {
44
+ for (const f of fs.readdirSync(modDir).filter((n) => /\.tsx?$/.test(n) && n !== 'git-env.ts')) {
45
+ const src = fs.readFileSync(path.join(modDir, f), 'utf8');
46
+ assert.ok(!/\[\s*'git'/.test(src), `${f} builds a raw git argv; use gitArgv()`);
47
+ }
48
+ });
49
+
50
+ t('map-pane gh calls are wrapped', () => {
51
+ const src = fs.readFileSync(path.join(modDir, 'map-pane.tsx'), 'utf8');
52
+ assert.ok(src.includes("scrubGitEnv(['gh', ...args])"));
53
+ });
54
+
55
+ console.log(`${passed} passed${mod ? '' : ' (type stripping unavailable: wrapper checks skipped)'}`);