@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.
- package/README.it.md +82 -25
- package/README.md +82 -25
- package/bin/base-preflight-docs.test.js +4 -1
- package/bin/bulk-cross-reference-docs.test.js +36 -0
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/engine-route-docs.test.js +155 -0
- package/bin/facet-stamp-docs.test.js +79 -0
- package/bin/forced-mutation-docs.test.js +38 -0
- package/bin/grill-model-docs.test.js +155 -0
- package/bin/grilling-holistic-review-docs.test.js +7 -3
- package/bin/guard-axis-docs.test.js +69 -0
- package/bin/inbox-needs-info-docs.test.js +68 -0
- package/bin/lib/codex-projection.js +28 -19
- package/bin/lib/codex-projection.test.js +77 -20
- package/bin/lib/host-descriptor.js +1 -1
- package/bin/lib/host-descriptor.test.js +6 -0
- package/bin/needs-you-docs.test.js +9 -2
- package/bin/options-in-chat-docs.test.js +65 -0
- package/bin/pick-stress-test-docs.test.js +1 -1
- package/bin/pipeline-review-docs.test.js +118 -0
- package/bin/plan-review-budget-docs.test.js +63 -0
- package/bin/repo-target-docs.test.js +41 -0
- package/bin/report-status-docs.test.js +10 -2
- package/bin/seam-coverage-docs.test.js +46 -0
- package/bin/session-trust-docs.test.js +37 -0
- package/bin/standards-brief-docs.test.js +112 -0
- package/bin/surfaced-scope-docs.test.js +57 -0
- package/bin/test-once-docs.test.js +101 -0
- package/bin/test-run-docs.test.js +47 -0
- package/bin/trailhead.js +15 -8
- package/bin/trailhead.test.js +71 -17
- package/bin/unblock-docs.test.js +56 -0
- package/bin/yes-accepts-recommended-docs.test.js +54 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
- package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- package/plugins/trailhead/agents/trailhead-plan.md +1 -1
- package/plugins/trailhead/agents/trailhead-verify.md +1 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
- package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
- package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
- package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
- package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
- package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
- package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
- package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
- package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
- package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
- package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
- package/plugins/trailhead/hooks/lib/model-route.js +207 -0
- package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
- package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
- package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
- package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
- package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
- package/plugins/trailhead/hooks/mod/README.md +42 -17
- package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
- package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
- package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
- package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
- package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
- package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
- package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
- package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
- package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
- package/plugins/trailhead/skills/_shared/substrate.md +4 -3
- package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
- package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
- package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
- package/plugins/trailhead/skills/_shared/techniques.md +8 -6
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +11 -6
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
- package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
- package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
- package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
- package/plugins/trailhead/templates/trailhead-capture.js +419 -0
- package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
- package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
- package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
- package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
- package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
- package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
- package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
- package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
- package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
- package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
- package/plugins/trailhead/tests/git-env.test.ts +20 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
- package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
- package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
- package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
- package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
- package/plugins/trailhead/tests/unscrub.ts +8 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
- package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
- package/plugins/trailhead/types/index.d.ts +7 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead capture helper (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel). Scriptable capture for automations (CI, cron, bots): opens a bug,
|
|
5
|
+
// todo, seed or idea with the full label wiring, without a chat session. #332.
|
|
6
|
+
//
|
|
7
|
+
// CLI:
|
|
8
|
+
// node trailhead-capture.js --type bug|todo|idea|seed --title <t> \
|
|
9
|
+
// (--body <b> | --body-file <path|->) --repo <owner/name> \
|
|
10
|
+
// [--map <n> | --of <n>] [--trigger <text>] [--unverified] \
|
|
11
|
+
// [--dedupe-key <key>] [--no-dashboard]
|
|
12
|
+
// --trigger is required for seed and rejected otherwise. --unverified is rejected for idea.
|
|
13
|
+
// --of is only for bug and is exclusive with --map (the ticket inherits the origin's map or the whiteboard).
|
|
14
|
+
// --dedupe-key matches ^[A-Za-z0-9._:-]{1,100}$ and makes the run idempotent.
|
|
15
|
+
//
|
|
16
|
+
// Flow (nothing is created on any pre-check failure): trust check, destination, dedupe,
|
|
17
|
+
// secret-scan of the exact payload, create (labels ride in the one create call), label
|
|
18
|
+
// read-back, post-create trust verification when the identity was unreadable, native
|
|
19
|
+
// sub-issue edge (map tickets), dashboard refresh (whiteboard tickets).
|
|
20
|
+
//
|
|
21
|
+
// Prints exactly one JSON line on stdout:
|
|
22
|
+
// created: {"status":"created","issue","url","type","destination","labels","trust","edge"?,"dashboard","warnings"} exit 0
|
|
23
|
+
// existing: {"status":"existing","issue","url","dedupeKey"} exit 0
|
|
24
|
+
// partial: {"status":"partial","issue","url",...,"edge":"failed","warnings"} exit 3
|
|
25
|
+
// error: {"status":"error","reason",...} ("trust":"insufficient" on a trust failure) exit 1
|
|
26
|
+
// usage: {"status":"usage","reason"} (also on stderr) exit 2
|
|
27
|
+
// A caller checking only the exit status therefore never mistakes a partial for success.
|
|
28
|
+
|
|
29
|
+
const { spawnSync } = require('child_process');
|
|
30
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
|
|
34
|
+
const TYPES = ['bug', 'todo', 'idea', 'seed'];
|
|
35
|
+
const TYPE_LABEL = { bug: 'trailhead:bug', todo: 'trailhead:build', seed: 'trailhead:build' };
|
|
36
|
+
const ALLOWLIST_VAR = 'TRAILHEAD_LABEL_ALLOWLIST';
|
|
37
|
+
const ACTIONS_BOT = 'github-actions[bot]';
|
|
38
|
+
const SEARCH_CAP = 1000;
|
|
39
|
+
const KEY_RE = /^[A-Za-z0-9._:-]{1,100}$/;
|
|
40
|
+
|
|
41
|
+
function defaultGh(args, input) {
|
|
42
|
+
const r = spawnSync('gh', args, { input, encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
43
|
+
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function defaultSleep(ms) {
|
|
47
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
|
|
51
|
+
const failText = (r) => firstLine(r.stderr) || 'exit ' + r.status;
|
|
52
|
+
const markerFor = (key) => `<!-- trailhead-dedupe: ${key} -->`;
|
|
53
|
+
|
|
54
|
+
// --- secret guard (same approach as trailhead-needs-you.js) -------------------
|
|
55
|
+
|
|
56
|
+
function guardCandidates() {
|
|
57
|
+
return [
|
|
58
|
+
path.join(__dirname, '..', 'hooks', 'trailhead-secret-guard.js'),
|
|
59
|
+
path.join(__dirname, '..', '..', 'hooks', 'trailhead-secret-guard.js'),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The shipped secret-guard module ({ scan }), or null when none is found.
|
|
64
|
+
function loadGuard(candidates = guardCandidates()) {
|
|
65
|
+
for (const file of candidates) {
|
|
66
|
+
if (!fs.existsSync(file)) continue;
|
|
67
|
+
try {
|
|
68
|
+
const m = require(file);
|
|
69
|
+
if (m && typeof m.scan === 'function') return m;
|
|
70
|
+
} catch { /* try the next layout */ }
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// null when the text is clean, else the abort reason (fail closed).
|
|
76
|
+
function secretProblem(text, guard) {
|
|
77
|
+
if (!guard || typeof guard.scan !== 'function') return 'secret-scan unavailable, nothing created';
|
|
78
|
+
try {
|
|
79
|
+
const hit = guard.scan(text, {});
|
|
80
|
+
return hit ? `secret detected (${hit}) in the payload, nothing created` : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return 'secret-scan unavailable, nothing created';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- args ---------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const VALUE_FLAGS = ['--type', '--title', '--body', '--body-file', '--repo', '--map', '--of', '--trigger', '--dedupe-key'];
|
|
89
|
+
const BOOL_FLAGS = ['--unverified', '--no-dashboard'];
|
|
90
|
+
|
|
91
|
+
function parseArgs(argv) {
|
|
92
|
+
const o = { type: null, title: null, body: null, bodyFile: null, repo: null, map: null, of: null, trigger: null, unverified: false, dedupeKey: null, noDashboard: false };
|
|
93
|
+
for (let i = 0; i < argv.length; i++) {
|
|
94
|
+
const flag = argv[i];
|
|
95
|
+
if (BOOL_FLAGS.includes(flag)) {
|
|
96
|
+
if (flag === '--unverified') o.unverified = true; else o.noDashboard = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!VALUE_FLAGS.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
|
|
100
|
+
i++;
|
|
101
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
102
|
+
const v = argv[i];
|
|
103
|
+
if (flag === '--type') {
|
|
104
|
+
if (!TYPES.includes(v)) return { error: `usage: --type must be one of ${TYPES.join('|')}, got: ${v}` };
|
|
105
|
+
o.type = v;
|
|
106
|
+
} else if (flag === '--repo') {
|
|
107
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
|
|
108
|
+
o.repo = v;
|
|
109
|
+
} else if (flag === '--map' || flag === '--of') {
|
|
110
|
+
if (!/^[1-9]\d*$/.test(v)) return { error: `usage: ${flag} must be a positive integer, got: ${v}` };
|
|
111
|
+
o[flag === '--map' ? 'map' : 'of'] = Number(v);
|
|
112
|
+
} else if (flag === '--dedupe-key') {
|
|
113
|
+
if (!KEY_RE.test(v)) return { error: `usage: --dedupe-key must match ${KEY_RE}, got: ${v}` };
|
|
114
|
+
o.dedupeKey = v;
|
|
115
|
+
} else if (flag === '--title') o.title = v;
|
|
116
|
+
else if (flag === '--body') o.body = v;
|
|
117
|
+
else if (flag === '--body-file') o.bodyFile = v;
|
|
118
|
+
else o.trigger = v;
|
|
119
|
+
}
|
|
120
|
+
if (!o.type) return { error: 'usage: --type is required' };
|
|
121
|
+
if (o.title === null || !o.title.trim()) return { error: 'usage: a non-empty --title is required' };
|
|
122
|
+
if (!o.repo) return { error: 'usage: --repo is required' };
|
|
123
|
+
if (o.body !== null && o.bodyFile !== null) return { error: 'usage: --body and --body-file are mutually exclusive' };
|
|
124
|
+
if (o.body === null && o.bodyFile === null) return { error: 'usage: one of --body or --body-file is required' };
|
|
125
|
+
if (o.type === 'seed' && (o.trigger === null || !o.trigger.trim())) return { error: 'usage: seed requires a non-empty --trigger' };
|
|
126
|
+
if (o.type !== 'seed' && o.trigger !== null) return { error: 'usage: --trigger is only for --type seed' };
|
|
127
|
+
if (o.of !== null && o.type !== 'bug') return { error: 'usage: --of is only for --type bug' };
|
|
128
|
+
if (o.unverified && o.type === 'idea') return { error: 'usage: --unverified is only for ticket types (fog is already off the frontier)' };
|
|
129
|
+
if (o.of !== null && o.map !== null) return { error: 'usage: --of and --map are mutually exclusive' };
|
|
130
|
+
return o;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- pure payload ---------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
// { title, body, labels, destination } from the resolved inputs. map: { number, title, url } | null,
|
|
136
|
+
// of: { title, url } | null. Pure.
|
|
137
|
+
function buildIssue({ type, title, body, map, of, trigger, unverified, dedupeKey }) {
|
|
138
|
+
const isIdea = type === 'idea';
|
|
139
|
+
const labels = isIdea ? ['trailhead:fog'] : ['trailhead:ticket', TYPE_LABEL[type]];
|
|
140
|
+
if (type === 'seed') labels.push('trailhead:seed', 'trailhead:blocked');
|
|
141
|
+
if (!isIdea) labels.push(map ? `trailhead:map-${map.number}` : 'trailhead:whiteboard');
|
|
142
|
+
if (!isIdea && unverified) labels.push('trailhead:unverified');
|
|
143
|
+
|
|
144
|
+
const parts = [];
|
|
145
|
+
if (map) parts.push(`Parent: [${map.title}](${map.url})`);
|
|
146
|
+
if (of) parts.push(`Regression of: [${of.title}](${of.url})`);
|
|
147
|
+
if (type === 'seed') parts.push(`Trigger: ${trigger.trim()}`);
|
|
148
|
+
parts.push(String(body).replace(/\s+$/, ''));
|
|
149
|
+
if (type === 'seed') parts.push(`## Blocked by\n\n- Trigger: ${trigger.trim()}`);
|
|
150
|
+
if (dedupeKey) parts.push(markerFor(dedupeKey));
|
|
151
|
+
const destination = map ? `map-${map.number}` : isIdea ? 'none' : 'whiteboard';
|
|
152
|
+
return { title: title.trim(), body: parts.join('\n\n') + '\n', labels, destination };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The same parsing expression as templates/trailhead-label-guard.yml (parity is tested).
|
|
156
|
+
function parseAllowlist(s) {
|
|
157
|
+
return (s || '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- gh steps ---------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
function parseJson(r) {
|
|
163
|
+
if (r.status !== 0) return null;
|
|
164
|
+
try { return JSON.parse(r.stdout); } catch { return null; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// True when the login has write, maintain or admin on the repo. A failed read counts as none.
|
|
168
|
+
function hasWrite(run, repo, login) {
|
|
169
|
+
const j = parseJson(run(['api', `repos/${repo}/collaborators/${login}/permission`]));
|
|
170
|
+
if (!j) return false;
|
|
171
|
+
return ['admin', 'write'].includes(j.permission) || j.role_name === 'maintain';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The repository's own allowlist (the source the guard Action reads), never the local env.
|
|
175
|
+
function readAllowlist(run, repo) {
|
|
176
|
+
const r = run(['api', `repos/${repo}/actions/variables/${ALLOWLIST_VAR}`, '--jq', '.value']);
|
|
177
|
+
return r.status === 0 ? parseAllowlist(r.stdout.trim()) : [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// { trust: 'ok'|'allowlisted'|'pending'|'insufficient', login? }. The env argument is accepted
|
|
181
|
+
// and deliberately ignored: a caller-controlled allowlist would defeat the check.
|
|
182
|
+
function checkTrust(run, repo) {
|
|
183
|
+
const who = run(['api', 'user', '--jq', '.login']);
|
|
184
|
+
const login = who.status === 0 ? who.stdout.trim() : '';
|
|
185
|
+
if (!login) return { trust: 'pending' };
|
|
186
|
+
if (hasWrite(run, repo, login)) return { trust: 'ok', login };
|
|
187
|
+
if (readAllowlist(run, repo).includes(login.toLowerCase())) return { trust: 'allowlisted', login };
|
|
188
|
+
return { trust: 'insufficient', login };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// An open `trailhead:map` -> { number, title, url }, else { error }.
|
|
192
|
+
function readMap(run, repo, n) {
|
|
193
|
+
const j = parseJson(run(['issue', 'view', String(n), '--json', 'state,title,url,labels', '-R', repo]));
|
|
194
|
+
if (!j) return { error: `cannot read map #${n}` };
|
|
195
|
+
const names = (j.labels || []).map((l) => (typeof l === 'string' ? l : l && l.name));
|
|
196
|
+
if (String(j.state).toUpperCase() !== 'OPEN') return { error: `map #${n} is not open` };
|
|
197
|
+
if (!names.includes('trailhead:map')) return { error: `#${n} is not a trailhead:map` };
|
|
198
|
+
return { number: n, title: String(j.title), url: String(j.url) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// { map, of? } with map null for the whiteboard, or { error }.
|
|
202
|
+
function resolveDestination(run, repo, { map, of }) {
|
|
203
|
+
if (map) {
|
|
204
|
+
const m = readMap(run, repo, map);
|
|
205
|
+
return m.error ? { error: m.error } : { map: m };
|
|
206
|
+
}
|
|
207
|
+
if (of) {
|
|
208
|
+
const unresolved = { error: 'origin destination unresolved' };
|
|
209
|
+
const j = parseJson(run(['issue', 'view', String(of), '--json', 'title,url,labels', '-R', repo]));
|
|
210
|
+
if (!j) return unresolved;
|
|
211
|
+
const names = (j.labels || []).map((l) => (typeof l === 'string' ? l : l && l.name));
|
|
212
|
+
const maps = names.filter((n) => /^trailhead:map-[1-9]\d*$/.test(n));
|
|
213
|
+
const origin = { title: String(j.title), url: String(j.url) };
|
|
214
|
+
if (maps.length === 1 && !names.includes('trailhead:whiteboard')) {
|
|
215
|
+
const m = readMap(run, repo, Number(maps[0].slice('trailhead:map-'.length)));
|
|
216
|
+
return m.error ? unresolved : { map: m, of: origin };
|
|
217
|
+
}
|
|
218
|
+
if (maps.length === 0 && names.includes('trailhead:whiteboard')) return { map: null, of: origin };
|
|
219
|
+
return unresolved;
|
|
220
|
+
}
|
|
221
|
+
return { map: null };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Lines of `gh api --paginate ... --jq '... | tojson'`: one JSON document per line.
|
|
225
|
+
function jsonLines(r) {
|
|
226
|
+
if (r.status !== 0) return null;
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const line of r.stdout.split('\n')) {
|
|
229
|
+
if (!line.trim()) continue;
|
|
230
|
+
try { out.push(JSON.parse(line)); } catch { return null; }
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// { issue, url } for an open issue carrying the exact marker, null for none, { error } on failure.
|
|
236
|
+
function findDuplicate(run, repo, key) {
|
|
237
|
+
const marker = markerFor(key);
|
|
238
|
+
const s = run(['issue', 'list', '-R', repo, '--state', 'open', '--search', `"trailhead-dedupe: ${key}" in:body`, '--json', 'number,url,body', '--limit', String(SEARCH_CAP)]);
|
|
239
|
+
const list = parseJson(s);
|
|
240
|
+
if (!Array.isArray(list)) return { error: `dedupe search failed: ${failText(s)}` };
|
|
241
|
+
const hit = list.find((i) => String(i.body || '').includes(marker));
|
|
242
|
+
if (hit) return { issue: hit.number, url: hit.url };
|
|
243
|
+
if (list.length < SEARCH_CAP) return null;
|
|
244
|
+
// A search at its cap cannot be trusted to be complete: scan every open issue.
|
|
245
|
+
const scan = run(['api', '--paginate', `repos/${repo}/issues?state=open&per_page=100`, '--jq', '.[] | {number, html_url, body, pr: (.pull_request != null)} | tojson']);
|
|
246
|
+
const rows = jsonLines(scan);
|
|
247
|
+
if (!rows) return { error: `dedupe scan failed: ${failText(scan)}` };
|
|
248
|
+
const found = rows.find((i) => !i.pr && String(i.body || '').includes(marker));
|
|
249
|
+
return found ? { issue: found.number, url: found.html_url } : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Waits between reads of the issue events right after create: the endpoint can briefly return a
|
|
253
|
+
// successful but empty (or partial) list before the labeled events are visible.
|
|
254
|
+
const EVENTS_BACKOFF_MS = [1000, 2000, 4000];
|
|
255
|
+
|
|
256
|
+
// { label, actor } of each trailhead:* labeled event on the issue, or null when unreadable.
|
|
257
|
+
function labelEvents(run, repo, n) {
|
|
258
|
+
const r = run(['api', '--paginate', `repos/${repo}/issues/${n}/events`, '--jq', '.[] | select(.event == "labeled") | {label: .label.name, actor: .actor.login} | tojson']);
|
|
259
|
+
const rows = jsonLines(r);
|
|
260
|
+
if (!rows) return null;
|
|
261
|
+
return rows.filter((e) => String(e.label).startsWith('trailhead:')).map((e) => ({ label: String(e.label), actor: String(e.actor) }));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Decides one actor's trust: 'actions-token' | 'allowlisted' | 'ok' | null.
|
|
265
|
+
function actorTrust(run, repo, actor, allow) {
|
|
266
|
+
if (actor === ACTIONS_BOT) return 'actions-token';
|
|
267
|
+
if (allow.includes(actor.toLowerCase())) return 'allowlisted';
|
|
268
|
+
return hasWrite(run, repo, actor) ? 'ok' : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Post-create trust for an unreadable identity: re-reads the events (bounded backoff) until every
|
|
272
|
+
// expected trailhead:* label shows a labeled event, then every actor who applied one must be one the
|
|
273
|
+
// guard Action never strips. 'actions-token' | 'allowlisted' | 'ok', { bad: actor }, or { error }.
|
|
274
|
+
function verifyActors(run, repo, n, expected, nap) {
|
|
275
|
+
// Nothing to wait for would pass with no actor checked: fail closed.
|
|
276
|
+
if (!expected.length) return { error: 'no trailhead:* label to verify, so who applied the labels is unverified' };
|
|
277
|
+
let allow = null;
|
|
278
|
+
const rank = { 'actions-token': 0, allowlisted: 1, ok: 2 };
|
|
279
|
+
const trusts = new Map();
|
|
280
|
+
let events = null;
|
|
281
|
+
for (let attempt = 0; attempt <= EVENTS_BACKOFF_MS.length; attempt++) {
|
|
282
|
+
if (attempt) nap(EVENTS_BACKOFF_MS[attempt - 1]);
|
|
283
|
+
events = labelEvents(run, repo, n);
|
|
284
|
+
if (!events) continue;
|
|
285
|
+
// A bad actor seen on any read fails at once.
|
|
286
|
+
for (const actor of new Set(events.map((e) => e.actor))) {
|
|
287
|
+
if (trusts.has(actor)) continue;
|
|
288
|
+
if (!allow) allow = readAllowlist(run, repo);
|
|
289
|
+
const t = actorTrust(run, repo, actor, allow);
|
|
290
|
+
if (!t) return { bad: actor };
|
|
291
|
+
trusts.set(actor, t);
|
|
292
|
+
}
|
|
293
|
+
if (expected.every((l) => events.some((e) => e.label === l))) {
|
|
294
|
+
let result = 'actions-token';
|
|
295
|
+
for (const t of trusts.values()) if (rank[t] > rank[result]) result = t;
|
|
296
|
+
return { trust: result };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!events) return { error: 'the issue events could not be read, so who applied the labels is unverified' };
|
|
300
|
+
const unseen = expected.filter((l) => !events.some((e) => e.label === l));
|
|
301
|
+
return { error: `no visible labeled event for ${unseen.join(', ')}, so who applied the labels is unverified` };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function readBody(o, stdin) {
|
|
305
|
+
if (o.body !== null) return o.body;
|
|
306
|
+
try {
|
|
307
|
+
if (o.bodyFile === '-') return stdin !== undefined ? stdin : fs.readFileSync(0, 'utf8');
|
|
308
|
+
return fs.readFileSync(o.bodyFile, 'utf8');
|
|
309
|
+
} catch (e) {
|
|
310
|
+
return { error: `cannot read the body: ${e.message}` };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function main(argv, { gh, guard, stdout, stderr, sleep, dashboard, stdin } = {}) {
|
|
315
|
+
const out = stdout || process.stdout;
|
|
316
|
+
const err = stderr || process.stderr;
|
|
317
|
+
const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
|
|
318
|
+
const o = parseArgs(argv || []);
|
|
319
|
+
if (o.error) { emit({ status: 'usage', reason: o.error }); err.write(o.error + '\n'); return 2; }
|
|
320
|
+
const run = gh || defaultGh;
|
|
321
|
+
const nap = sleep || defaultSleep;
|
|
322
|
+
const fail = (reason, extra = {}) => { emit({ status: 'error', reason, ...extra }); return 1; };
|
|
323
|
+
const repo = o.repo;
|
|
324
|
+
|
|
325
|
+
const body = readBody(o, stdin);
|
|
326
|
+
if (body && typeof body === 'object') return fail(body.error);
|
|
327
|
+
|
|
328
|
+
// 2. trust (pre-create)
|
|
329
|
+
const tr = checkTrust(run, repo);
|
|
330
|
+
if (tr.trust === 'insufficient') {
|
|
331
|
+
return fail(`${tr.login} lacks write access and is not in the repository allowlist (${ALLOWLIST_VAR}): the label guard would strip the labels`, { trust: 'insufficient' });
|
|
332
|
+
}
|
|
333
|
+
let trust = tr.trust;
|
|
334
|
+
|
|
335
|
+
// 3. destination
|
|
336
|
+
const dest = resolveDestination(run, repo, o);
|
|
337
|
+
if (dest.error) return fail(dest.error);
|
|
338
|
+
|
|
339
|
+
// 4. dedupe
|
|
340
|
+
if (o.dedupeKey) {
|
|
341
|
+
const dup = findDuplicate(run, repo, o.dedupeKey);
|
|
342
|
+
if (dup && dup.error) return fail(dup.error);
|
|
343
|
+
if (dup) { emit({ status: 'existing', issue: dup.issue, url: dup.url, dedupeKey: o.dedupeKey }); return 0; }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 5. build and secret-scan exactly the payload that will be sent
|
|
347
|
+
const built = buildIssue({ type: o.type, title: o.title, body, map: dest.map, of: dest.of, trigger: o.trigger, unverified: o.unverified, dedupeKey: o.dedupeKey });
|
|
348
|
+
const problem = secretProblem(`${built.title}\n\n${built.body}`, guard === undefined ? loadGuard() : guard);
|
|
349
|
+
if (problem) return fail(problem);
|
|
350
|
+
|
|
351
|
+
// 6. create
|
|
352
|
+
const isTicket = o.type !== 'idea';
|
|
353
|
+
if (isTicket && !dest.map) run(['label', 'create', 'trailhead:whiteboard', '--color', 'C5DEF5', '--description', 'Loose ticket, no map', '-R', repo]);
|
|
354
|
+
const c = run(['issue', 'create', '-R', repo, '--title', built.title, '--label', built.labels.join(','), '--body-file', '-'], built.body);
|
|
355
|
+
if (c.status !== 0) return fail(`gh issue create failed: ${failText(c)}`);
|
|
356
|
+
const m = /\/issues\/(\d+)\s*$/.exec(c.stdout.trim());
|
|
357
|
+
if (!m) return fail('gh issue create printed no issue URL');
|
|
358
|
+
const issue = Number(m[1]);
|
|
359
|
+
const url = c.stdout.trim().split('\n').pop().trim();
|
|
360
|
+
const made = { issue, url };
|
|
361
|
+
|
|
362
|
+
// 7. read-back
|
|
363
|
+
const rb = parseJson(run(['issue', 'view', String(issue), '--json', 'labels', '-R', repo]));
|
|
364
|
+
if (!rb) return fail('cannot read back the created issue labels', made);
|
|
365
|
+
const have = (rb.labels || []).map((l) => (typeof l === 'string' ? l : l && l.name));
|
|
366
|
+
const missing = built.labels.filter((l) => !have.includes(l));
|
|
367
|
+
if (missing.length) return fail(`labels missing: ${missing.join(', ')}`, made);
|
|
368
|
+
|
|
369
|
+
// 7b. unreadable identity: verify who applied the labels
|
|
370
|
+
if (trust === 'pending') {
|
|
371
|
+
const expected = built.labels.filter((l) => l.startsWith('trailhead:'));
|
|
372
|
+
const v = verifyActors(run, repo, issue, expected, nap);
|
|
373
|
+
if (v.error) return fail(v.error, { trust: 'insufficient', ...made });
|
|
374
|
+
if (!v.trust) return fail(`labels applied by ${v.bad} will be stripped by the label guard`, { trust: 'insufficient', ...made });
|
|
375
|
+
trust = v.trust;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 8. native edge for a map ticket
|
|
379
|
+
const warnings = [];
|
|
380
|
+
const result = { status: 'created', ...made, type: o.type, destination: built.destination, labels: built.labels, trust };
|
|
381
|
+
if (isTicket && dest.map) {
|
|
382
|
+
let edge = false;
|
|
383
|
+
for (let attempt = 0; attempt < 2 && !edge; attempt++) {
|
|
384
|
+
if (attempt) nap(1000);
|
|
385
|
+
const id = run(['api', `repos/${repo}/issues/${issue}`, '--jq', '.id']);
|
|
386
|
+
if (id.status !== 0 || !id.stdout.trim()) continue;
|
|
387
|
+
const e = run(['api', '--method', 'POST', `repos/${repo}/issues/${dest.map.number}/sub_issues`, '-F', `sub_issue_id=${id.stdout.trim()}`]);
|
|
388
|
+
edge = e.status === 0;
|
|
389
|
+
}
|
|
390
|
+
if (!edge) {
|
|
391
|
+
warnings.push(`native sub-issue edge to map #${dest.map.number} failed (e.g. the 100 sub-issue cap); the ticket keeps its trailhead:map-${dest.map.number} label so it stays on the frontier; repair it with the trailhead prune command`);
|
|
392
|
+
result.status = 'partial';
|
|
393
|
+
result.edge = 'failed';
|
|
394
|
+
} else {
|
|
395
|
+
result.edge = 'ok';
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 10. dashboard: only for a whiteboard ticket
|
|
400
|
+
if (!isTicket || dest.map) result.dashboard = 'not-applicable';
|
|
401
|
+
else if (o.noDashboard) result.dashboard = 'skipped';
|
|
402
|
+
else {
|
|
403
|
+
const sink = { write: () => true };
|
|
404
|
+
const dash = dashboard || require('./trailhead-dashboard.js');
|
|
405
|
+
let code = 1;
|
|
406
|
+
try { code = dash.main(['--repo', repo, '--update-only'], { gh: run, stdout: sink, stderr: sink }); } catch { code = 1; }
|
|
407
|
+
if (code === 0) result.dashboard = 'refreshed';
|
|
408
|
+
else { result.dashboard = 'failed'; warnings.push('the dashboard refresh failed; refresh it with the trailhead dashboard command'); }
|
|
409
|
+
}
|
|
410
|
+
result.warnings = warnings;
|
|
411
|
+
emit(result);
|
|
412
|
+
return result.status === 'partial' ? 3 : 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
module.exports = { main, parseArgs, buildIssue, parseAllowlist, checkTrust, verifyActors, resolveDestination, findDuplicate, loadGuard, secretProblem };
|
|
416
|
+
|
|
417
|
+
if (require.main === module) {
|
|
418
|
+
process.exit(main(process.argv.slice(2), {}));
|
|
419
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead checkout-find (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel). When a checkout path a session was handed no longer exists (the
|
|
5
|
+
// repo moved), this finds the repo's checkout by its `origin` remote instead of a
|
|
6
|
+
// hand-run search of the disk. #329.
|
|
7
|
+
//
|
|
8
|
+
// Search order is an explicit precedence, not a uniqueness claim: the focused roots
|
|
9
|
+
// first (the parent of --cwd and of its git toplevel, and the usual code folders under
|
|
10
|
+
// $HOME), then $HOME itself only if they found nothing. `found` is the single match of
|
|
11
|
+
// the first tier that has any; a later tier is never weighed against it.
|
|
12
|
+
//
|
|
13
|
+
// Walk: breadth-first, depth-limited, one realpath-visited set (overlapping roots and
|
|
14
|
+
// symlink cycles never count a checkout twice), dot-dirs and node_modules skipped, a
|
|
15
|
+
// found checkout never descended into. A directory holding `.git` (directory or file)
|
|
16
|
+
// is a checkout; its origin is read with `git remote get-url origin`. A visited-dir
|
|
17
|
+
// cap per tier bounds the walk; hitting it sets `truncated`, and a lone match under
|
|
18
|
+
// truncation is reported `ambiguous`, never `found`, so the caller asks.
|
|
19
|
+
//
|
|
20
|
+
// Main checkouts (`.git` dir) and linked worktrees/submodules (`.git` file) are equal
|
|
21
|
+
// candidates: the remote cannot tell a moved worktree from its main checkout.
|
|
22
|
+
//
|
|
23
|
+
// CLI:
|
|
24
|
+
// node trailhead-checkout-find.js --repo <[host/]owner/name> [--cwd <dir>]
|
|
25
|
+
// [--roots <dir>:<dir>...] [--depth <n>]
|
|
26
|
+
// `--roots` replaces the default tiers with one explicit tier (tests, odd layouts).
|
|
27
|
+
//
|
|
28
|
+
// Prints exactly one JSON line on stdout (plus "truncated":true when the cap hit):
|
|
29
|
+
// {"status":"found","path":"..."}
|
|
30
|
+
// {"status":"ambiguous","candidates":[{"path":"...","kind":"main"|"linked"}]}
|
|
31
|
+
// {"status":"none","searched":["..."]}
|
|
32
|
+
// Exit 0 for the three statuses, 2 on a usage error (stderr).
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const os = require('os');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
const { spawnSync } = require('child_process');
|
|
38
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
39
|
+
|
|
40
|
+
const DEFAULT_DEPTH = 3;
|
|
41
|
+
const DEFAULT_CAP = 20000;
|
|
42
|
+
const CODE_DIRS = ['Git', 'git', 'src', 'code', 'Code', 'projects', 'Projects', 'dev', 'repos', 'work', 'workspace'];
|
|
43
|
+
|
|
44
|
+
// A remote URL as {host, path} (host lowercased, port kept), or null when it is not a
|
|
45
|
+
// network remote (a local path, empty).
|
|
46
|
+
function parseRemote(url) {
|
|
47
|
+
if (typeof url !== 'string') return null;
|
|
48
|
+
const s = url.trim();
|
|
49
|
+
if (s === '' || s.startsWith('/') || s.startsWith('.') || s.startsWith('-')) return null;
|
|
50
|
+
let host;
|
|
51
|
+
let rest;
|
|
52
|
+
const m = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(?:[^@/]*@)?([^/]+)\/(.*)$/.exec(s);
|
|
53
|
+
if (m) {
|
|
54
|
+
host = m[1];
|
|
55
|
+
rest = m[2];
|
|
56
|
+
} else {
|
|
57
|
+
// scp-like: [user@]host:path
|
|
58
|
+
const n = /^(?:[^@/:]+@)?([^/:]+):(.*)$/.exec(s);
|
|
59
|
+
if (!n) return null;
|
|
60
|
+
host = n[1];
|
|
61
|
+
rest = n[2];
|
|
62
|
+
}
|
|
63
|
+
rest = rest.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/, '').replace(/\/+$/, '');
|
|
64
|
+
if (host === '' || rest === '') return null;
|
|
65
|
+
return { host: host.toLowerCase(), path: rest };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A --repo argument: `owner/name` (any host) or `host/owner/name` (the first segment
|
|
69
|
+
// is a host when there are 3+ segments and it holds a dot or a colon).
|
|
70
|
+
function parseRepoArg(arg) {
|
|
71
|
+
if (typeof arg !== 'string') return null;
|
|
72
|
+
const parts = arg.trim().replace(/\/+$/, '').replace(/\.git$/, '').split('/');
|
|
73
|
+
if (parts.length < 2 || parts.some((p) => p === '')) return null;
|
|
74
|
+
if (parts.length >= 3 && /[.:]/.test(parts[0])) return { host: parts[0].toLowerCase(), path: parts.slice(1).join('/') };
|
|
75
|
+
return { host: null, path: parts.join('/') };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The repo identity the panel token carries: `host/owner/name` by shape (3+ non-empty
|
|
79
|
+
// segments, the first is the host, whatever it looks like: `gitea`, `localhost`).
|
|
80
|
+
function parseRepoShape(text) {
|
|
81
|
+
if (typeof text !== 'string') return null;
|
|
82
|
+
const parts = text.split('/');
|
|
83
|
+
if (parts.length < 3 || parts.some((p) => p === '')) return null;
|
|
84
|
+
return { host: parts[0].toLowerCase(), path: parts.slice(1).join('/') };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const stripPort = (h) => h.replace(/:\d+$/, '');
|
|
88
|
+
|
|
89
|
+
// Whether a remote URL is the wanted repo. Host: case-insensitive; a wanted host with
|
|
90
|
+
// no port ignores the remote's port. Path: case-insensitive on github.com only.
|
|
91
|
+
function matches(remoteUrl, repoArg) {
|
|
92
|
+
const remote = parseRemote(remoteUrl);
|
|
93
|
+
const want = typeof repoArg === 'string' ? parseRepoArg(repoArg) : repoArg;
|
|
94
|
+
if (remote === null || want === null) return false;
|
|
95
|
+
if (want.host !== null) {
|
|
96
|
+
const same = /:\d+$/.test(want.host) ? remote.host === want.host : stripPort(remote.host) === want.host;
|
|
97
|
+
if (!same) return false;
|
|
98
|
+
}
|
|
99
|
+
if (stripPort(remote.host) === 'github.com') return remote.path.toLowerCase() === want.path.toLowerCase();
|
|
100
|
+
return remote.path === want.path;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function real(p) {
|
|
104
|
+
try {
|
|
105
|
+
return fs.realpathSync(p);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function defaultOrigin(dir) {
|
|
112
|
+
const r = spawnSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], { encoding: 'utf8', env: gitEnv(), timeout: 5000 });
|
|
113
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toplevelOf(dir) {
|
|
117
|
+
const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', env: gitEnv(), timeout: 5000 });
|
|
118
|
+
return r.status === 0 && r.stdout.trim() !== '' ? real(r.stdout.replace(/\n$/, '')) : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function uniqueExisting(dirs) {
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const d of dirs) {
|
|
125
|
+
const r = d ? real(d) : null;
|
|
126
|
+
if (r !== null && !seen.has(r)) {
|
|
127
|
+
seen.add(r);
|
|
128
|
+
out.push(r);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The default tiers: focused roots, then $HOME.
|
|
135
|
+
function defaultTiers(cwd, home) {
|
|
136
|
+
const focused = [];
|
|
137
|
+
if (cwd) {
|
|
138
|
+
focused.push(path.dirname(path.resolve(cwd)));
|
|
139
|
+
const top = real(cwd) !== null ? toplevelOf(cwd) : null;
|
|
140
|
+
if (top !== null) focused.push(path.dirname(top));
|
|
141
|
+
}
|
|
142
|
+
for (const n of CODE_DIRS) focused.push(path.join(home, n));
|
|
143
|
+
const homeReal = real(home);
|
|
144
|
+
const first = uniqueExisting(focused).filter((d) => d !== '/' && d !== homeReal);
|
|
145
|
+
return [first, uniqueExisting([home])];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function kindOf(dir) {
|
|
149
|
+
try {
|
|
150
|
+
return fs.statSync(path.join(dir, '.git')).isDirectory() ? 'main' : 'linked';
|
|
151
|
+
} catch {
|
|
152
|
+
return null; // no .git
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// One tier: breadth-first from its roots. Returns {matches, truncated}.
|
|
157
|
+
function walkTier(roots, repo, o, visited) {
|
|
158
|
+
const matchesFound = [];
|
|
159
|
+
let visitedCount = 0;
|
|
160
|
+
let truncated = false;
|
|
161
|
+
const queue = roots.map((r) => [r, 0]);
|
|
162
|
+
for (let i = 0; i < queue.length; i++) {
|
|
163
|
+
const [dir, depth] = queue[i];
|
|
164
|
+
const rp = real(dir);
|
|
165
|
+
if (rp === null || visited.has(rp)) continue;
|
|
166
|
+
if (visitedCount >= o.cap) {
|
|
167
|
+
truncated = true;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
visited.add(rp);
|
|
171
|
+
visitedCount++;
|
|
172
|
+
const kind = kindOf(rp);
|
|
173
|
+
if (kind !== null) {
|
|
174
|
+
const origin = o.originOf(rp);
|
|
175
|
+
if (origin && matches(origin, repo)) matchesFound.push({ path: rp, kind });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (depth >= o.depth) continue;
|
|
179
|
+
let entries;
|
|
180
|
+
try {
|
|
181
|
+
entries = fs.readdirSync(rp, { withFileTypes: true });
|
|
182
|
+
} catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
186
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
187
|
+
const child = path.join(rp, e.name);
|
|
188
|
+
let isDir = e.isDirectory();
|
|
189
|
+
if (!isDir && e.isSymbolicLink()) {
|
|
190
|
+
try {
|
|
191
|
+
isDir = fs.statSync(child).isDirectory();
|
|
192
|
+
} catch {
|
|
193
|
+
isDir = false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (isDir) queue.push([child, depth + 1]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { matches: matchesFound, truncated };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// findCheckouts(repo, {roots, tiers, cwd, home, depth, cap, originOf}) -> result object.
|
|
203
|
+
// `roots` is one explicit tier; `tiers` several; neither means the defaults from cwd/home.
|
|
204
|
+
function findCheckouts(repo, opts = {}) {
|
|
205
|
+
const o = {
|
|
206
|
+
depth: Number.isInteger(opts.depth) && opts.depth >= 0 ? opts.depth : DEFAULT_DEPTH,
|
|
207
|
+
cap: Number.isInteger(opts.cap) && opts.cap > 0 ? opts.cap : DEFAULT_CAP,
|
|
208
|
+
originOf: opts.originOf || defaultOrigin,
|
|
209
|
+
};
|
|
210
|
+
let tiers;
|
|
211
|
+
if (Array.isArray(opts.tiers)) tiers = opts.tiers.map(uniqueExisting);
|
|
212
|
+
else if (Array.isArray(opts.roots)) tiers = [uniqueExisting(opts.roots)];
|
|
213
|
+
else tiers = defaultTiers(opts.cwd || null, opts.home || process.env.HOME || os.homedir());
|
|
214
|
+
const visited = new Set();
|
|
215
|
+
const searched = [];
|
|
216
|
+
let truncated = false;
|
|
217
|
+
for (const tier of tiers) {
|
|
218
|
+
if (tier.length === 0) continue;
|
|
219
|
+
searched.push(...tier);
|
|
220
|
+
const r = walkTier(tier, repo, o, visited);
|
|
221
|
+
truncated = truncated || r.truncated;
|
|
222
|
+
if (r.matches.length === 0) continue;
|
|
223
|
+
const candidates = r.matches.sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
224
|
+
const res = candidates.length === 1 && !r.truncated
|
|
225
|
+
? { status: 'found', path: candidates[0].path }
|
|
226
|
+
: { status: 'ambiguous', candidates };
|
|
227
|
+
return r.truncated ? { ...res, truncated: true } : res;
|
|
228
|
+
}
|
|
229
|
+
return truncated ? { status: 'none', searched, truncated: true } : { status: 'none', searched };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function main(argv) {
|
|
233
|
+
const get = (flag) => {
|
|
234
|
+
const i = argv.indexOf(flag);
|
|
235
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
236
|
+
};
|
|
237
|
+
const usage = (msg) => {
|
|
238
|
+
process.stderr.write(`${msg}\nusage: trailhead-checkout-find.js --repo <[host/]owner/name> [--cwd <dir>] [--roots <a:b>] [--depth <n>]\n`);
|
|
239
|
+
return 2;
|
|
240
|
+
};
|
|
241
|
+
const repo = get('--repo');
|
|
242
|
+
if (repo === null || parseRepoArg(repo) === null) return usage('--repo <[host/]owner/name> is required');
|
|
243
|
+
const opts = { cwd: get('--cwd') };
|
|
244
|
+
const roots = get('--roots');
|
|
245
|
+
if (roots !== null) opts.roots = roots.split(':').filter((r) => r !== '');
|
|
246
|
+
const depth = get('--depth');
|
|
247
|
+
if (depth !== null) {
|
|
248
|
+
if (!/^\d+$/.test(depth)) return usage('--depth must be a non-negative integer');
|
|
249
|
+
opts.depth = Number(depth);
|
|
250
|
+
}
|
|
251
|
+
process.stdout.write(JSON.stringify(findCheckouts(repo, opts)) + '\n');
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { parseRemote, parseRepoArg, parseRepoShape, matches, findCheckouts };
|
|
256
|
+
|
|
257
|
+
if (require.main === module) process.exit(main(process.argv.slice(2)));
|