gh-glance 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +97 -13
  3. package/index.mjs +544 -100
  4. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,108 @@ All notable changes to this project are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.1] - 2026-08-04
9
+
10
+ Two fixes to the interactive surface: a selection marker that outlived its
11
+ usefulness, and a key hint that named the wrong keys.
12
+
13
+ ### Fixed
14
+
15
+ - **The row cursor clears itself after 60s without movement.** Once you pressed
16
+ a movement key the selection marker stayed on screen until you quit, so a pane
17
+ left in the corner of a screen sat there marked at something you had stopped
18
+ caring about an hour ago. Any movement key rearms the timer; expiry clears the
19
+ cursor on every tab, not just the visible one.
20
+ - **The `Move` hint in the status bar reads `↑↓` rather than `jk`.** The hints
21
+ are otherwise strictly width-1 ASCII, because East-Asian-Ambiguous glyphs
22
+ measure as two columns in ink's width model and overflowed an 80-column
23
+ terminal once before. The arrow pair is a deliberate exception: the pty suite
24
+ asserts the rendered frame stays inside the terminal width, so a font that
25
+ renders them double-width fails there loudly instead of shifting columns
26
+ silently.
27
+
28
+ ## [0.4.0] - 2026-08-04
29
+
30
+ Correctness on GitHub Enterprise and Enterprise Managed Users tenants, and a
31
+ diagnostic command built to settle what a bug report cannot describe. The two
32
+ enterprise entries below are the same failure: the dashboard stating something
33
+ confident and wrong, which is precisely what its error classifier exists to
34
+ prevent. The rest of the release is a run of layout and interaction fixes.
35
+
36
+ ### Fixed
37
+
38
+ - **A lapsed enterprise SAML session was reported as "not enabled for this
39
+ repository", and latched for up to an hour.** EMU tenants expire the SAML
40
+ session periodically; while lapsed the API answers 403, which the classifier
41
+ could not tell apart from "this feature is switched off". So the Security tab
42
+ went blank under a false explanation, and the escalating backoff
43
+ (60s -> 300s -> 1800s -> 3600s) kept it that way long after the user had
44
+ re-authorized in the browser. Auth failures -- SAML, SSO, a credential not
45
+ authorized for the org, a missing token scope -- now surface `gh`'s real
46
+ message and take a short fixed 30s retry instead of the escalating ladder, so
47
+ recovery is bounded at about half a minute. A 403 with no auth markers still
48
+ produces the existing note and ladder.
49
+ - **On a non-default host the three security-alert endpoints were queried
50
+ against `github.com`.** `gh api` has no `--repo`, so a host-qualified target
51
+ routed the four list-driven tabs to the enterprise host while the alert calls
52
+ went to `github.com`, 404ed, and -- via the defect above -- rendered as "not
53
+ enabled". The Security tab could therefore report that a feature was off for a
54
+ repository whose alerts it had never asked for. The host now travels as
55
+ `--hostname`, and both halves of the dashboard talk to the same server. The
56
+ fixture-log assertions in `test/pty/routing.test.mjs` are the guard against it
57
+ recurring.
58
+ - **`Enter` on an Actions row opened the wrong run, or none at all.** Every
59
+ openable row passed its display `number` to `gh <kind> view`, which is right
60
+ for an issue or a pull request and wrong for a workflow run: runs are
61
+ addressed by `databaseId`, a different and global ID space, so a run's number
62
+ is almost never a valid one and the browser got a 404. Runs now open by
63
+ `databaseId`; issues and pull requests are unchanged.
64
+ - **The panel's right border could be clipped at the terminal edge.** One
65
+ trailing column is now reserved for it, so the frame closes on terminals that
66
+ treat the final cell as the wrap boundary.
67
+ - **The stale indicator reserved its column even when there was nothing to
68
+ say.** An idle dashboard now gives that width back to the table instead of
69
+ holding it empty against a label that only appears after 30 seconds without a
70
+ successful refresh.
71
+ - **The spinner jittered against the status icons beside it.** The classic
72
+ "dots" braille set lights only one or two of a cell's eight dot positions per
73
+ frame, and in different corners each frame, so next to the solid circle glyphs
74
+ used for completed runs it visibly wandered rather than holding a centre. The
75
+ replacement lights six or seven dots per frame, reading as a filled blob of
76
+ the same visual weight, and stays width-1.
77
+
78
+ ### Changed
79
+
80
+ - **The tab bar moved to the top of the pane**, above the table rather than
81
+ below it, with a divider separating the two. Below the table it was being
82
+ missed entirely; the status and key hints stay at the bottom.
83
+ - **The rate-limit figures in the README were wrong and are now stated per
84
+ tab.** The documented "roughly 500 requests an hour, about 10% of the limit"
85
+ did not follow from the constants it described. A visible Actions, Issues or
86
+ Pull Requests tab costs about 1,000 an hour (20% of the 5,000/hour
87
+ authenticated limit); the Security tab, being three endpoints rather than one,
88
+ costs about 2,300 (47%). Both figures fall when an endpoint backs off because
89
+ the feature is not enabled, and `--refresh` scales the whole number.
90
+
91
+ ### Added
92
+
93
+ - **`--repo` accepts `[host/]owner/name`**, the same form `gh --repo` itself
94
+ accepts, so a GitHub Enterprise or EMU data-residency tenant can be watched
95
+ from outside a clone: `gh-glance --repo tenant.ghe.com/acme/widget`. A host
96
+ must contain a dot, which is what keeps `owner/name/extra` a rejected typo
97
+ rather than a silent request to a host named `owner`. The host is validated
98
+ separately from the `owner/name` slug and is never interpolated into a request
99
+ path.
100
+ - **`--doctor`**, a reporting command that prints versions, authenticated hosts,
101
+ how the repository target resolved, the relevant environment, and one probe
102
+ per endpoint -- with the exact argv sent and how any error was classified.
103
+ It exits 0 and reports rather than failing when `gh` is missing or the working
104
+ directory is not a repository, and it works through a pipe. Tokens are never
105
+ printed: token-valued variables are reported as present or absent, anything
106
+ token-shaped in captured text is replaced, URL credentials are stripped, and
107
+ no response bodies are included -- so the report is safe to attach to a bug
108
+ report.
109
+
8
110
  ## [0.3.1] - 2026-08-04
9
111
 
10
112
  First release published by CI rather than from a laptop, and the first carrying
@@ -227,6 +329,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
227
329
  - The `main` field from `package.json`. It advertised the file as importable,
228
330
  but importing it took over the terminal or exited the host process.
229
331
 
332
+ [0.4.1]: https://github.com/juan294/gh-glance/releases/tag/v0.4.1
333
+ [0.4.0]: https://github.com/juan294/gh-glance/releases/tag/v0.4.0
230
334
  [0.3.1]: https://github.com/juan294/gh-glance/releases/tag/v0.3.1
231
335
  [0.3.0]: https://github.com/juan294/gh-glance/releases/tag/v0.3.0
232
336
  [0.2.0]: https://github.com/juan294/gh-glance/releases/tag/v0.2.0
package/README.md CHANGED
@@ -18,6 +18,8 @@ pane next to your editor) where you want an always-on glance at repo activity
18
18
  without switching to the browser.
19
19
 
20
20
  ```
21
+ [1:Actions (4)] 2:Issues (3) 3:PRs (2) 4:Security (0)
22
+ ────────────────────────────────────────────────────────────────────────────
21
23
  ╭─ Actions ────────────────────────────────────────────────────────────────╮
22
24
  │ TITLE WORKFLOW BRANCH TIME UPDATED │
23
25
  │ ──────────────────────────────────────────────────────────────────────── │
@@ -28,8 +30,7 @@ without switching to the browser.
28
30
  │ │
29
31
  │ │
30
32
  ╰───────────────────────────────────────────────────────────────── 4 of 4 ─╯
31
- [1:Actions (4)] 2:Issues (3) 3:PRs (2) 4:Security (0)
32
- ⠋ Fetching Move: jk | Open: Ent | Refresh: r | Quit: q
33
+ ⣾ Fetching Move: ↑↓ | Open: Ent | Refresh: r | Quit: q
33
34
  ```
34
35
 
35
36
  > Captured from a real run at 76 columns with `GH_GLANCE_ICONS=unicode`, so the
@@ -66,8 +67,8 @@ current.
66
67
  by colour alone.
67
68
  Code scanning and secret scanning alerts are included too, on repos/plans
68
69
  that have GitHub Advanced Security enabled -- see [Limitations](#limitations).
69
- - Tab bar with live counts, pinned to the bottom of the pane alongside the
70
- status line, switchable via `1`-`4`, arrow keys, or `Tab`/`Shift+Tab`
70
+ - Tab bar with live counts, pinned to the top of the pane above a divider,
71
+ switchable via `1`-`4`, arrow keys, or `Tab`/`Shift+Tab`
71
72
  - `lazygit`-style panel frame: the tab name sits in the top border, the
72
73
  visible-of-total row count in the bottom
73
74
  - A spinning `Fetching` indicator while a refresh is in flight, so the pane
@@ -87,7 +88,10 @@ current.
87
88
  ## Prerequisites
88
89
 
89
90
  - [Node.js](https://nodejs.org/) `>=22` (Ink requires it; Node 20 is end-of-life)
90
- - The [`gh` CLI](https://cli.github.com/) `>=2.20`, authenticated (`gh auth login`)
91
+ - The [`gh` CLI](https://cli.github.com/) `>=2.20`, authenticated for the host
92
+ you intend to watch (`gh auth login`, or `gh auth login --hostname <host>` on
93
+ a GitHub Enterprise or EMU tenant -- see [GitHub Enterprise and
94
+ EMU](#github-enterprise-and-emu))
91
95
  - A terminal font with [Nerd Font](https://www.nerdfonts.com/) glyphs (for the
92
96
  status icons to render correctly -- without one, they'll show as blank
93
97
  boxes; see [Icons without a Nerd Font](#icons-without-a-nerd-font))
@@ -187,7 +191,9 @@ stdout isn't a terminal rather than streaming redraw frames into a pipe.
187
191
 
188
192
  The cursor tracks the *item*, not the row position, so it stays on what you
189
193
  selected as new rows arrive above it. `Enter` works on Actions, Issues and Pull
190
- Requests; the Security tab has no per-alert `gh` command to open.
194
+ Requests; the Security tab has no per-alert `gh` command to open. It clears
195
+ itself after 60s with no cursor movement, so a pane left idle in the corner of
196
+ a screen doesn't sit there marked forever.
191
197
 
192
198
  If `gh-glance` is started without an interactive stdin (for example with stdin
193
199
  redirected), the key handlers cannot run and the status bar shows only `Quit: ^C`
@@ -200,10 +206,11 @@ pane definition. Flags are there when you want them:
200
206
 
201
207
  | Flag | Effect |
202
208
  |---|---|
203
- | `-R`, `--repo owner/name` | Watch a specific repository instead of the current directory's. Works from anywhere -- you do not need a local clone. |
209
+ | `-R`, `--repo [host/]owner/name` | Watch a specific repository instead of the current directory's. Works from anywhere -- you do not need a local clone. The optional host targets a GitHub Enterprise or EMU data-residency tenant, e.g. `tenant.ghe.com/acme/widget`. |
204
210
  | `--refresh <seconds>` | Active-tab poll interval, 2-3600, default 5. Background tabs stay at 12x this. |
205
211
  | `--tab <name>` | Start on `actions`, `issues`, `prs` or `security`. |
206
212
  | `--verbose` | Log one line per `gh` call to stderr, with timing and outcome. stderr must be redirected: `gh-glance --verbose 2>gh-glance.log`. |
213
+ | `--doctor` | Print a diagnostic report and exit. See [Diagnostics](#diagnostics). |
207
214
 
208
215
  An unrecognised flag exits 2 rather than being ignored, so a typo fails loudly.
209
216
 
@@ -211,19 +218,89 @@ Environment variables work too, and the flags take precedence:
211
218
 
212
219
  | Variable | Effect |
213
220
  |---|---|
214
- | `GH_REPO=owner/name` | Watch a specific repository instead of the current directory's |
221
+ | `GH_REPO=owner/name` | Watch a specific repository instead of the current directory's. A host-qualified `GH_REPO` does **not** route the security-alert endpoints -- use `GH_HOST` or `--repo host/owner/name` for that |
222
+ | `GH_HOST=<host>` | Send every call to a GitHub Enterprise or EMU host instead of `github.com`. Routes both the list commands and the alert endpoints |
223
+ | `GH_TOKEN`, `GH_ENTERPRISE_TOKEN`, `GH_CONFIG_DIR` | Not read by `gh-glance` -- passed through to `gh` untouched, along with the proxy variables. `gh-glance` handles no credentials of its own |
215
224
  | `GH_GLANCE_ICONS=unicode` | Plain ASCII status icons, for terminals without a Nerd Font |
216
225
  | `GH_GLANCE_NO_ANIMATION=1` | Freeze the spinner — no motion at all |
217
226
  | `NO_COLOR=1` | Disable colour. Status stays readable: severity has its own column, run states have distinct glyphs, and the active tab is bracketed |
218
227
  | `INK_SCREEN_READER=true` | Switch the renderer to a linear, unthrottled mode. The status icons carry text labels for it, but this path has not been tested against a real screen reader — treat it as unverified rather than supported. |
219
228
 
229
+ ## GitHub Enterprise and EMU
230
+
231
+ **The common case needs no configuration.** Authenticate once --
232
+ `gh auth login --hostname <your-host>` -- then run `gh-glance` inside a clone of
233
+ a work repository. The repository and its host are both resolved from the git
234
+ remote, the same way `gh` does it.
235
+
236
+ **There are two EMU forms.** Standard Enterprise Managed Users lives on
237
+ `github.com`; EMU with data residency lives on `<slug>.ghe.com`. `gh auth
238
+ status` tells you which you have, and only the second needs a host mentioned
239
+ anywhere.
240
+
241
+ **To watch a repository you have not cloned**, on a non-default host:
242
+
243
+ ```bash
244
+ gh-glance --repo tenant.ghe.com/acme/widget
245
+ ```
246
+
247
+ The `[host/]owner/name` form is the same one `gh --repo` accepts. The host must
248
+ contain a dot, so `owner/name/extra` is still rejected as the typo it is rather
249
+ than read as a request to a host named `owner`.
250
+
251
+ **`GH_HOST` works too**, and is the better lever when every repository you watch
252
+ lives on one enterprise host -- it routes the list commands and the alert
253
+ endpoints alike. A host-qualified **`GH_REPO`**, by contrast, does *not* route
254
+ `gh api`: it supplies the owner and repository and ignores the host, which is
255
+ why `--repo` is the right tool for a one-off cross-host target.
256
+
257
+ **SAML sessions expire.** When an enterprise session lapses, the endpoints
258
+ answer 403 and the tab shows the real `gh` message. Re-authorize in the browser
259
+ (or run `gh auth refresh`) and the dashboard recovers within about 30 seconds.
260
+ It will not claim a feature is "not enabled" because of a lapse.
261
+
262
+ ### Diagnostics
263
+
264
+ ```bash
265
+ gh-glance --doctor > report.txt
266
+ ```
267
+
268
+ Collects, in one plain-text block: the `gh-glance`, Node and `gh` versions;
269
+ which hosts `gh` is authenticated for; how the repository target resolved and
270
+ from where; the relevant environment variables; and one probe per endpoint with
271
+ the exact argv it sent, its outcome, and how any error was classified
272
+ (`unavailable`, `rate-limited`, `auth-problem` or `other`).
273
+
274
+ It exits 0 and prints a report even when `gh` is missing or you are outside a
275
+ git repository -- those are conditions worth reporting rather than failing on --
276
+ and it works through a pipe, unlike the dashboard itself.
277
+
278
+ **Tokens are never printed.** Token-valued environment variables are reported
279
+ as `set` or `not set` and never by value, not even a prefix; anything
280
+ token-shaped anywhere in the captured text is replaced; proxy and remote URL
281
+ credentials are stripped to scheme and host; and no API response bodies are
282
+ included, only their sizes. The report is safe to attach to a bug report.
283
+
220
284
  ## Rate limit
221
285
 
222
286
  The visible tab refreshes every 5 seconds; the other three refresh every 60
223
- seconds, purely to keep their counts honest. That works out at roughly 500
224
- REST requests an hour, about 10% of the 5,000/hour authenticated limit — so
225
- leaving a pane open all day does not exhaust the budget your other `gh`
226
- commands share.
287
+ seconds, purely to keep their counts honest.
288
+
289
+ With Actions, Issues or Pull Requests as the visible tab that works out at
290
+ roughly 1,000 REST requests an hour — about 20% of the 5,000/hour
291
+ authenticated limit. The Security tab is the expensive one, because it is
292
+ three endpoints rather than one: watching it costs roughly 2,300 an hour,
293
+ just under half the budget.
294
+
295
+ Two things pull the real number down. An endpoint that is not enabled for the
296
+ repository — code scanning and secret scanning without Advanced Security —
297
+ backs off after its first refusal rather than being re-asked every tick, which
298
+ removes two of the Security tab's three calls. And `--refresh` scales the
299
+ whole figure: doubling the interval halves it.
300
+
301
+ So a pane left open all day shares the budget with your other `gh` commands
302
+ rather than exhausting it, but it is not free, and parking it on the Security
303
+ tab is the case worth knowing about.
227
304
 
228
305
  ## Limitations
229
306
 
@@ -253,6 +330,10 @@ commands share.
253
330
  - macOS/Linux terminals with ANSI + alternate-screen-buffer support. Not
254
331
  tested on Windows -- `npm` will not stop you installing it there, but the
255
332
  alternate-screen handling is unverified.
333
+ - **No account pinning.** `gh`'s active account is global, so with two accounts
334
+ authenticated on the same host `gh-glance` follows whichever one `gh auth
335
+ switch` last selected -- it has no way to pin a pane to one of them. If you
336
+ need two panes on two accounts at once, give each its own `GH_CONFIG_DIR`.
256
337
 
257
338
  ### Icons without a Nerd Font
258
339
 
@@ -278,7 +359,10 @@ in some terminals, which would shift every column to their right.
278
359
  | `the gh CLI is not installed` | Install it from [cli.github.com](https://cli.github.com), then `gh auth login`. |
279
360
  | `not inside a git repository` | Run it from a cloned GitHub repository, or set `GH_REPO=owner/name`. |
280
361
  | Status icons are blank boxes | Your terminal font is not a Nerd Font. Use `GH_GLANCE_ICONS=unicode`. |
281
- | Security tab shows a "not enabled" note | Code scanning and secret scanning need GitHub Advanced Security. Dependabot alerts work independently. A genuine auth or network failure shows the real error instead. |
362
+ | Security tab shows a "not enabled" note | Code scanning and secret scanning need GitHub Advanced Security. Dependabot alerts work independently. The note now appears only when the feature genuinely is unavailable: auth, SSO and network failures show the real error instead. |
363
+ | `none of the git remotes ... point to a known GitHub host` | `gh` is not authenticated for that host. Run `gh auth login --hostname <host>`. |
364
+ | Security tab empty on an enterprise host, other tabs fine | The alert endpoints were sent to the wrong host. Use `--repo host/owner/name` or set `GH_HOST` -- a host-qualified `GH_REPO` alone does not route them. |
365
+ | Tabs start failing after working for a while | The enterprise SAML session lapsed. Re-authorize in the browser; the dashboard recovers within about 30 seconds. Run `gh-glance --doctor` to confirm. |
282
366
  | A tab's count is red | That tab's last fetch failed. The error itself is shown when you switch to it. |
283
367
  | `stale 2m` in the status bar | The visible tab has not refreshed successfully for a while — usually a network drop. |
284
368
  | It exits immediately when piped | Intentional. It is a full-screen dashboard, not a reporting command. |
package/index.mjs CHANGED
@@ -47,6 +47,12 @@ const IS_MAIN = detectMainModule();
47
47
 
48
48
  const REFRESH_MS = 5000;
49
49
 
50
+ // The row cursor otherwise persists forever once you touch a movement key --
51
+ // useful while you're actually scanning a list, noise once you've moved on and
52
+ // left the pane running in a corner of the screen. 60s idle (measured from the
53
+ // last movement, not from tab switches or Enter) quietly drops it.
54
+ const SELECTION_IDLE_MS = 60_000;
55
+
50
56
  // `gh run list` costs roughly linearly in --limit (measured: ~1.2s at 20 runs,
51
57
  // ~3.0s at 100, ~4.9s at 150), so asking for a fixed 150 to render ~35 visible
52
58
  // rows was paying several seconds per refresh for rows nobody sees. Actions is
@@ -103,6 +109,13 @@ const GH_MAX_BUFFER = 16 * 1024 * 1024;
103
109
  // a lie about a security surface.
104
110
  const BACKOFF_STEPS_MS = [60_000, 300_000, 1_800_000, 3_600_000];
105
111
 
112
+ // An auth failure is the opposite kind of thing: the user fixes it in seconds
113
+ // by re-authorizing in the browser, so a ladder measured in half-hours would
114
+ // leave the tab blank long after the cause was gone. A single short step keeps
115
+ // recovery bounded at ~30s while still bounding what a lapse that lasts all
116
+ // night costs -- two probes a minute per endpoint rather than one per tick.
117
+ const AUTH_RETRY_MS = [30_000];
118
+
106
119
  // Past this, the active tab's data is old enough to say so. Deliberately
107
120
  // coarse: `now` only advances on minute boundaries when nothing is in progress,
108
121
  // so a minute-granular staleness label costs zero extra redraws, whereas a
@@ -115,6 +128,11 @@ const STALE_AFTER_MS = 30_000;
115
128
  // the zero-argument invocation the README documents behaves exactly as before.
116
129
  const runtime = {
117
130
  repo: null, // null means "let gh infer it from the git remote", as today
131
+ // null unless the target was host-qualified. `gh` accepts [HOST/]OWNER/REPO;
132
+ // when a host is present the list subcommands get it inside --repo and the
133
+ // `gh api` calls get it as --hostname, because gh api has no --repo and would
134
+ // otherwise silently query github.com while the other tabs read the tenant.
135
+ host: null,
118
136
  refreshMs: REFRESH_MS,
119
137
  verbose: false,
120
138
  initialTabIndex: 0,
@@ -124,7 +142,13 @@ const runtime = {
124
142
  // table itself is built.
125
143
  const TAB_KEYS = ["actions", "issues", "prs", "security"];
126
144
 
127
- const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
145
+ // The classic "dots" braille spinner lights only 1-2 of a cell's 8 dot
146
+ // positions per frame, and different frames light different corners --
147
+ // so next to the solid Nerd Font circle icons used for completed runs, it
148
+ // visibly jitters instead of holding a steady center. This set lights 6-7
149
+ // dots per frame, reading as a filled blob that matches the circle icons'
150
+ // visual weight while staying in the same width-1 braille block.
151
+ const SPINNER = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
128
152
  const SPINNER_MS = 100;
129
153
 
130
154
  // Motion opt-out. This pane is designed to sit in peripheral vision for hours,
@@ -236,17 +260,70 @@ function shortErr(err) {
236
260
  return text.length > MAX_ERR_LENGTH ? `${text.slice(0, MAX_ERR_LENGTH - 1)}…` : text;
237
261
  }
238
262
 
263
+ // Where gh's diagnosis actually lives on a rejected execFile: stderr carries
264
+ // the HTTP line, and message is all a missing binary or a spawn failure leaves
265
+ // behind. One expression rather than one per predicate, so a fourth predicate
266
+ // cannot pick a fifth answer to the same question.
267
+ function errText(err) {
268
+ return String(err?.stderr ?? err?.message ?? "");
269
+ }
270
+
239
271
  // `gh api` puts the HTTP status in stderr and exits 1, so err.code is the
240
272
  // process exit code and says nothing about the cause. 403/404 is the honest
241
273
  // "you can't see this here" -- everything else (auth expiry, rate limiting,
242
274
  // DNS, a 502) is a real failure that used to be reported as a confident,
243
275
  // plausible, and wrong claim that the feature was switched off.
244
276
  function isUnavailable(err) {
245
- return /HTTP (403|404)/.test(String(err?.stderr ?? err?.message ?? ""));
277
+ return /HTTP (403|404)/.test(errText(err));
246
278
  }
247
279
 
248
280
  function isRateLimited(err) {
249
- return /rate limit|secondary rate|API rate limit/i.test(String(err?.stderr ?? err?.message ?? ""));
281
+ return /rate limit|secondary rate|API rate limit/i.test(errText(err));
282
+ }
283
+
284
+ // On an enterprise or EMU tenant a 403 carries meanings it never carries on a
285
+ // personal account: an expired SAML session, a credential not authorized for
286
+ // the org, a token missing a scope. None of those are statements about the
287
+ // repository's configuration, and all of them are fixed by the user in seconds
288
+ // -- so they must surface as themselves rather than as "not enabled", and must
289
+ // not latch the hour-long backoff.
290
+ //
291
+ // Written broad on purpose. A message that fails to match degrades to the old
292
+ // behaviour, and `--doctor` reports the verbatim text plus the classification
293
+ // it received, so the pattern is tightened from evidence. A false positive
294
+ // merely retries every 30s instead of backing off, which is cheap and
295
+ // self-correcting. The asymmetry favours breadth.
296
+ // `access restriction` is what catches the OAuth App restrictions form, which
297
+ // says "you appear to have the correct authorization credentials" and so
298
+ // matches none of the negative markers -- it is an authorization failure phrased
299
+ // entirely in the positive. It stays clear of the genuine not-enabled messages,
300
+ // which talk about features rather than access.
301
+ const AUTH_MARKERS =
302
+ /SAML|single[- ]sign[- ]on|\bSSO\b|must grant|not authoriz|unauthoriz|access restriction|Bad credentials|requires authentication|re-?authoriz|token .*scope|missing .*scope|insufficient/i;
303
+
304
+ function isAuthProblem(err) {
305
+ return AUTH_MARKERS.test(errText(err));
306
+ }
307
+
308
+ // The one place the three predicates are turned into a verdict. Both consumers
309
+ // go through it -- the fetcher, to choose a note and a backoff ladder, and
310
+ // `--doctor`, to report what gh-glance concluded -- so the report cannot claim
311
+ // a classification the dashboard does not actually make. Deriving the order
312
+ // twice would let exactly that drift in the moment it mattered most: AUTH_MARKERS
313
+ // is deliberately broad and expected to grow, and the day it grows into a
314
+ // message the rate-limit pattern also matches is the day two copies disagree.
315
+ //
316
+ // The order is a priority, not a sequence of independent tests. Rate limiting
317
+ // arrives as a 403 and means the opposite of a permissions problem, so it
318
+ // outranks the auth markers; both outrank "unavailable", which is the reading
319
+ // of last resort for a 403/404 and the only one that makes a claim about the
320
+ // repository's configuration.
321
+ function classify(err) {
322
+ if (err == null) return "ok";
323
+ if (isRateLimited(err)) return "rate-limited";
324
+ if (isAuthProblem(err)) return "auth-problem";
325
+ if (isUnavailable(err)) return "unavailable";
326
+ return "other";
250
327
  }
251
328
 
252
329
  // Some pty wrappers (and a terminal mid-resize) report a size of 0 or
@@ -312,35 +389,58 @@ async function runGh(args, { signal } = {}) {
312
389
  }
313
390
  }
314
391
 
392
+ // The target as `gh` spells it: host-qualified when a host was given, the bare
393
+ // slug otherwise -- which is exactly the [HOST/]OWNER/REPO form `gh --repo`
394
+ // documents.
395
+ function qualifiedRepo() {
396
+ return runtime.host ? `${runtime.host}/${runtime.repo}` : runtime.repo;
397
+ }
398
+
315
399
  // `--repo` for the list subcommands. Empty when unset, so the argv vector stays
316
400
  // byte-identical to what shipped before.
317
401
  function repoArgs() {
318
- return runtime.repo ? ["--repo", runtime.repo] : [];
402
+ return runtime.repo ? ["--repo", qualifiedRepo()] : [];
403
+ }
404
+
405
+ // `gh api` has no --repo, so a host-qualified target has nowhere to put its
406
+ // host: GH_REPO=host/owner/repo supplies the owner and repo and *ignores* the
407
+ // host (verified against gh 2.97.0), which is the one combination where the
408
+ // list tabs and the alert endpoints disagree about which server they are
409
+ // talking to. --hostname is the flag gh api does have. Empty when no host was
410
+ // given, so the default argv vector is unchanged.
411
+ function apiHostArgs() {
412
+ return runtime.host ? ["--hostname", runtime.host] : [];
319
413
  }
320
414
 
321
415
  // `gh api` has no --repo; it resolves the {owner}/{repo} placeholder from the
322
416
  // working directory. Substituting the validated value is what makes --repo work
323
417
  // for the alert endpoints. REPO_PATTERN is why this is safe to interpolate into
324
- // a request path.
418
+ // a request path -- and note it is the bare slug that goes in, never the host,
419
+ // which travels as an argument rather than as path text.
325
420
  function apiPath(path) {
326
421
  return runtime.repo ? path.replace("{owner}/{repo}", runtime.repo) : path;
327
422
  }
328
423
 
329
424
  // ---------- Data fetchers ----------
330
425
 
426
+ // The argv vector for each endpoint is built by a named function rather than
427
+ // inline, because `--doctor` reports these vectors and a second copy of them
428
+ // would be a report that drifts away from what the dashboard actually sends --
429
+ // which is the failure mode the whole diagnostics command exists to rule out.
430
+ function actionsArgs(limit) {
431
+ return [
432
+ "run",
433
+ "list",
434
+ ...repoArgs(),
435
+ "--limit",
436
+ String(limit),
437
+ "--json",
438
+ "databaseId,displayTitle,workflowName,number,headBranch,status,conclusion,startedAt,updatedAt",
439
+ ];
440
+ }
441
+
331
442
  async function fetchActions(limit, signal) {
332
- const raw = await runGh(
333
- [
334
- "run",
335
- "list",
336
- ...repoArgs(),
337
- "--limit",
338
- String(limit),
339
- "--json",
340
- "databaseId,displayTitle,workflowName,number,headBranch,status,conclusion,startedAt,updatedAt",
341
- ],
342
- { signal },
343
- );
443
+ const raw = await runGh(actionsArgs(limit), { signal });
344
444
  return {
345
445
  raw,
346
446
  limit,
@@ -369,22 +469,23 @@ async function fetchActions(limit, signal) {
369
469
  // returns the same result count.
370
470
  const SORT_RECENT = ["--search", "sort:updated-desc"];
371
471
 
472
+ function issuesArgs() {
473
+ return [
474
+ "issue",
475
+ "list",
476
+ ...repoArgs(),
477
+ "--state",
478
+ "open",
479
+ "--limit",
480
+ String(LIST_LIMIT),
481
+ ...SORT_RECENT,
482
+ "--json",
483
+ "number,title,author,labels,createdAt,updatedAt",
484
+ ];
485
+ }
486
+
372
487
  async function fetchIssues(signal) {
373
- const raw = await runGh(
374
- [
375
- "issue",
376
- "list",
377
- ...repoArgs(),
378
- "--state",
379
- "open",
380
- "--limit",
381
- String(LIST_LIMIT),
382
- ...SORT_RECENT,
383
- "--json",
384
- "number,title,author,labels,createdAt,updatedAt",
385
- ],
386
- { signal },
387
- );
488
+ const raw = await runGh(issuesArgs(), { signal });
388
489
  return {
389
490
  raw,
390
491
  limit: LIST_LIMIT,
@@ -399,22 +500,23 @@ async function fetchIssues(signal) {
399
500
  };
400
501
  }
401
502
 
503
+ function prsArgs() {
504
+ return [
505
+ "pr",
506
+ "list",
507
+ ...repoArgs(),
508
+ "--state",
509
+ "open",
510
+ "--limit",
511
+ String(LIST_LIMIT),
512
+ ...SORT_RECENT,
513
+ "--json",
514
+ "number,title,author,headRefName,isDraft,reviewDecision,createdAt,updatedAt",
515
+ ];
516
+ }
517
+
402
518
  async function fetchPRs(signal) {
403
- const raw = await runGh(
404
- [
405
- "pr",
406
- "list",
407
- ...repoArgs(),
408
- "--state",
409
- "open",
410
- "--limit",
411
- String(LIST_LIMIT),
412
- ...SORT_RECENT,
413
- "--json",
414
- "number,title,author,headRefName,isDraft,reviewDecision,createdAt,updatedAt",
415
- ],
416
- { signal },
417
- );
519
+ const raw = await runGh(prsArgs(), { signal });
418
520
  return {
419
521
  raw,
420
522
  limit: LIST_LIMIT,
@@ -495,6 +597,10 @@ const ALERT_SOURCES = [
495
597
  },
496
598
  ];
497
599
 
600
+ function alertArgs(source) {
601
+ return ["api", apiPath(source.path), ...apiHostArgs(), "--jq", source.jq];
602
+ }
603
+
498
604
  // Per-source backoff. Keyed by source so one unavailable endpoint cannot slow
499
605
  // the other two, and capped rather than permanent so enabling Advanced Security
500
606
  // mid-session is picked up within the hour.
@@ -505,10 +611,19 @@ function backoffActive(key, now) {
505
611
  return Boolean(state) && now < state.until;
506
612
  }
507
613
 
508
- function recordFailure(key, now) {
614
+ // Which ladder each verdict takes. "rate-limited" and "other" are absent on
615
+ // purpose: a rate limit clears on its own schedule and a network drop clears
616
+ // when the network does, so both keep today's behaviour of re-asking every tick
617
+ // with the real message on screen.
618
+ const FAILURE_LADDER = { unavailable: BACKOFF_STEPS_MS, "auth-problem": AUTH_RETRY_MS };
619
+
620
+ // The ladder is a parameter rather than a second near-identical function,
621
+ // which is the drift the ALERT_SOURCES comment below warns about: two copies of
622
+ // this would diverge the first time one of them was fixed.
623
+ function recordFailure(key, now, steps = BACKOFF_STEPS_MS) {
509
624
  const previous = alertBackoff.get(key);
510
- const step = Math.min((previous?.step ?? -1) + 1, BACKOFF_STEPS_MS.length - 1);
511
- alertBackoff.set(key, { step, until: now + BACKOFF_STEPS_MS[step] });
625
+ const step = Math.min((previous?.step ?? -1) + 1, steps.length - 1);
626
+ alertBackoff.set(key, { step, until: now + steps[step] });
512
627
  }
513
628
 
514
629
  function clearBackoff(key) {
@@ -521,7 +636,7 @@ async function fetchAlertSource(source, signal, now) {
521
636
  return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
522
637
  }
523
638
  try {
524
- const raw = await runGh(["api", apiPath(source.path), "--jq", source.jq], { signal });
639
+ const raw = await runGh(alertArgs(source), { signal });
525
640
  clearBackoff(source.key);
526
641
  return {
527
642
  raw,
@@ -535,14 +650,15 @@ async function fetchAlertSource(source, signal, now) {
535
650
  },
536
651
  };
537
652
  } catch (err) {
538
- // A 403/404 means the feature genuinely is not available here and is worth
539
- // backing off. Anything else -- expired token, rate limit, network -- is
540
- // transient and must surface as itself rather than as a confident and wrong
541
- // claim about the repo's configuration, and must not be latched.
542
- const unavailable = isUnavailable(err) && !isRateLimited(err);
543
- const note = unavailable ? source.unavailable : `${source.name}: ${shortErr(err)}`;
544
- if (unavailable) {
545
- recordFailure(source.key, now);
653
+ // Only "unavailable" is a statement about the repository's configuration,
654
+ // so it is the only verdict allowed to replace gh's message with the
655
+ // source's fixed note. Everything else -- an expired SAML session, a rate
656
+ // limit, a network drop -- surfaces as itself.
657
+ const verdict = classify(err);
658
+ const note = verdict === "unavailable" ? source.unavailable : `${source.name}: ${shortErr(err)}`;
659
+ const steps = pick(FAILURE_LADDER, verdict, null);
660
+ if (steps) {
661
+ recordFailure(source.key, now, steps);
546
662
  alertBackoff.get(source.key).note = note;
547
663
  }
548
664
  return { raw: `unavailable:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
@@ -605,11 +721,18 @@ const OPENABLE = { actions: "run", issues: "issue", prs: "pr" };
605
721
  // Output is captured rather than inherited. `gh ... --web` prints "Opening ...
606
722
  // in your browser" to stdout, and stdout is ink's frame stream -- letting that
607
723
  // through would corrupt the diff and the alternate screen.
724
+ //
725
+ // `gh run view` takes the run's databaseId, not its display `number` -- the
726
+ // two are different ID spaces (databaseId is global across GitHub, number is
727
+ // per-workflow and restarts near 1 in every repo), so a run's `number` is
728
+ // almost never a valid databaseId elsewhere and 404s. `gh issue view` and
729
+ // `gh pr view` are the opposite: they take the issue/PR number, and neither
730
+ // row shape carries a databaseId to prefer instead.
608
731
  async function openInBrowser(tabKey, item, signal) {
609
732
  const kind = pick(OPENABLE, tabKey, null);
610
- const number = item?.number ?? item?.databaseId;
611
- if (!kind || number == null) return;
612
- await runGh([kind, "view", ...repoArgs(), String(number), "--web"], { signal });
733
+ const id = kind === "run" ? (item?.databaseId ?? item?.number) : (item?.number ?? item?.databaseId);
734
+ if (!kind || id == null) return;
735
+ await runGh([kind, "view", ...repoArgs(), String(id), "--web"], { signal });
613
736
  }
614
737
 
615
738
  // ---------- Startup preflight ----------
@@ -650,6 +773,213 @@ async function preflight() {
650
773
  return null;
651
774
  }
652
775
 
776
+ // ---------- Diagnostics (--doctor) ----------
777
+
778
+ // This report is written to be pasted into a bug report or a chat window, which
779
+ // makes it a disclosure surface before it is anything else. So redaction is not
780
+ // a review note applied per call site -- it is applied once, at the single point
781
+ // where the report is assembled, the same way safe() is the one boundary for
782
+ // untrusted remote strings rather than six.
783
+ //
784
+ // Token shapes are gh's own prefixes (gho_/ghp_/ghu_/ghs_/ghr_ and
785
+ // github_pat_). URL userinfo covers a proxy variable, a git remote with an
786
+ // embedded credential, and anything else that carries `user:password@` -- gh
787
+ // error messages quote the URL they failed on, so that is a real path for one
788
+ // to arrive here.
789
+ function redact(text) {
790
+ return String(text)
791
+ .replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, "<redacted-token>")
792
+ .replace(/github_pat_[A-Za-z0-9_]{16,}/g, "<redacted-token>")
793
+ .replace(/\/\/[^/\s:@]+:[^/\s@]+@/g, "//<redacted>@");
794
+ }
795
+
796
+ // Values that are the thing being diagnosed, so they are printed as-is.
797
+ const DOCTOR_ENV_PLAIN = [
798
+ "GH_HOST",
799
+ "GH_REPO",
800
+ "GH_CONFIG_DIR",
801
+ "NO_PROXY",
802
+ "GH_GLANCE_ICONS",
803
+ "GH_GLANCE_NO_ANIMATION",
804
+ "NO_COLOR",
805
+ "NODE_ENV",
806
+ ];
807
+ const DOCTOR_ENV_PROXY = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"];
808
+ // Reported as present or absent and never printed, not even a prefix. Matched
809
+ // by name so a variable nobody listed here -- GITHUB_EMU_TOKEN, some wrapper's
810
+ // GH_API_KEY -- is still caught by shape rather than by having been foreseen.
811
+ const SECRET_NAME = /_(TOKEN|SECRET|PASSWORD|KEY)$/i;
812
+ const DOCTOR_ENV_SECRET = ["GH_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_TOKEN"];
813
+
814
+ const NOT_SET = "not set";
815
+
816
+ // Scheme and host only. A proxy URL is one of the few env values that routinely
817
+ // carries a credential inline, and the host is the entire diagnostic value.
818
+ function proxySummary(value) {
819
+ for (const candidate of [value, `http://${value}`]) {
820
+ try {
821
+ const url = new URL(candidate);
822
+ if (url.host) return `${url.protocol}//${url.host}`;
823
+ } catch {
824
+ // Not a URL in this form; try the next one, then give up.
825
+ }
826
+ }
827
+ return "(set, unparseable)";
828
+ }
829
+
830
+ // Every gh/GITHUB variable that is actually set gets a line, so the report shows
831
+ // the environment as it is rather than as this list imagined it. Token-shaped
832
+ // names still take the presence-only path below.
833
+ function doctorEnvNames() {
834
+ const named = [...DOCTOR_ENV_PLAIN, ...DOCTOR_ENV_PROXY, ...DOCTOR_ENV_SECRET];
835
+ const known = new Set(named);
836
+ const extra = Object.keys(process.env).filter(
837
+ (name) => /^(GH|GITHUB)_/.test(name) && process.env[name] && !known.has(name),
838
+ );
839
+ return [...named, ...extra];
840
+ }
841
+
842
+ function envValue(name) {
843
+ const value = process.env[name];
844
+ if (SECRET_NAME.test(name)) return value ? "set" : NOT_SET;
845
+ if (!value) return NOT_SET;
846
+ if (DOCTOR_ENV_PROXY.includes(name)) return proxySummary(value);
847
+ return value;
848
+ }
849
+
850
+ // gh writes some of this to stdout and some to stderr depending on version and
851
+ // on whether it succeeded, and a non-zero exit is itself worth reporting rather
852
+ // than throwing. Both streams, whatever happened.
853
+ async function captureGh(args) {
854
+ try {
855
+ return (await runGh(args)).trim();
856
+ } catch (err) {
857
+ const both = `${err?.stdout ?? ""}${err?.stderr ?? ""}`.trim();
858
+ return both || shortErr(err);
859
+ }
860
+ }
861
+
862
+ const PROBE_STDERR_LIMIT = 400;
863
+
864
+ async function probe(name, args) {
865
+ const startedAt = Date.now();
866
+ try {
867
+ const stdout = await runGh(args);
868
+ return { name, args, ms: Date.now() - startedAt, bytes: stdout.length, classified: "ok" };
869
+ } catch (err) {
870
+ return {
871
+ name,
872
+ args,
873
+ ms: Date.now() - startedAt,
874
+ failed: true,
875
+ // Bodies are never included, only sizes -- and a failing endpoint's stderr
876
+ // is the one place the verbatim tenant message can be captured, which is
877
+ // the reason this command exists.
878
+ stderr: String(err?.stderr ?? "").trim().slice(0, PROBE_STDERR_LIMIT),
879
+ http: /HTTP (\d{3})/.exec(errText(err))?.[1] ?? null,
880
+ classified: classify(err),
881
+ };
882
+ }
883
+ }
884
+
885
+ const DOCTOR_LABEL_WIDTH = 18;
886
+ const PROBE_LABEL_WIDTH = 12;
887
+
888
+ // The underline is derived rather than typed, so renaming a heading cannot
889
+ // leave a rule that is the wrong length underneath it.
890
+ function section(title) {
891
+ return [title, "-".repeat(title.length)];
892
+ }
893
+
894
+ // A label longer than its column (GH_GLANCE_NO_ANIMATION, GH_ENTERPRISE_TOKEN)
895
+ // would otherwise butt straight against its value with no separator at all,
896
+ // which is unreadable exactly where the report is being skim-read for a
897
+ // "set" / "not set".
898
+ function field(label, value, width = DOCTOR_LABEL_WIDTH) {
899
+ return label.length < width ? `${label.padEnd(width)}${value}` : `${label} ${value}`;
900
+ }
901
+
902
+ async function gitRemote() {
903
+ try {
904
+ const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
905
+ timeout: GH_TIMEOUT_MS,
906
+ });
907
+ return stdout.trim() || "(no origin remote)";
908
+ } catch {
909
+ return "not a git repository (or no origin remote)";
910
+ }
911
+ }
912
+
913
+ function targetSource() {
914
+ if (runtime.repo) return "flag";
915
+ if (process.env.GH_REPO) return "GH_REPO";
916
+ return "git remote (or none, if this is not a checkout)";
917
+ }
918
+
919
+ async function runDoctor() {
920
+ // A live backoff would make an alert probe silently skip and report nothing,
921
+ // which is the opposite of what a diagnostic run is for.
922
+ alertBackoff.clear();
923
+
924
+ const probes = [
925
+ ["Actions (run list)", actionsArgs(MIN_RUN_LIMIT)],
926
+ ["Issues (issue list)", issuesArgs()],
927
+ ["Pull requests (pr list)", prsArgs()],
928
+ ...ALERT_SOURCES.map((source) => [source.name, alertArgs(source)]),
929
+ ];
930
+
931
+ // allSettled, and every probe already resolves rather than rejects, so one
932
+ // slow endpoint cannot suppress the other five. runGh's own GH_TIMEOUT_MS
933
+ // bounds each one.
934
+ const [ghVersion, authStatus, remote, results] = await Promise.all([
935
+ captureGh(["--version"]),
936
+ captureGh(["auth", "status"]),
937
+ gitRemote(),
938
+ Promise.all(probes.map(([name, args]) => probe(name, args))),
939
+ ]);
940
+
941
+ const lines = [
942
+ "gh-glance doctor",
943
+ "================",
944
+ field("gh-glance", version),
945
+ field("node", `${process.version} ${process.platform}/${process.arch}`),
946
+ field("gh", ghVersion.split("\n")[0] || "NOT FOUND"),
947
+ "",
948
+ ...section("Authenticated hosts"),
949
+ authStatus || "(no output)",
950
+ "",
951
+ ...section("Repository target"),
952
+ field("source", targetSource()),
953
+ field("host", runtime.host ?? "(default -- gh infers it)"),
954
+ field("slug", runtime.repo ?? "(inferred from the working directory)"),
955
+ field("git remote", remote),
956
+ "",
957
+ ...section("Environment"),
958
+ ...doctorEnvNames().map((name) => field(name, envValue(name))),
959
+ "",
960
+ ...section("Endpoint probes"),
961
+ ];
962
+
963
+ const probeLine = (label, value) => lines.push(` ${field(label, value, PROBE_LABEL_WIDTH)}`);
964
+ for (const result of results) {
965
+ lines.push(` ${result.name}`);
966
+ probeLine("argv", `gh ${result.args.join(" ")}`);
967
+ probeLine(
968
+ "outcome",
969
+ result.failed ? `FAILED in ${result.ms}ms` : `ok ${result.bytes}B in ${result.ms}ms`,
970
+ );
971
+ if (result.http) probeLine("http", result.http);
972
+ probeLine("classified", result.classified);
973
+ if (result.stderr) probeLine("stderr", result.stderr);
974
+ lines.push("");
975
+ }
976
+
977
+ // The single redaction boundary. Everything above may have captured a token,
978
+ // a proxy credential or a URL with userinfo; nothing above is responsible for
979
+ // removing it.
980
+ return redact(lines.join("\n"));
981
+ }
982
+
653
983
  // ---------- Command line ----------
654
984
 
655
985
  // The repository name is the only user-supplied value that reaches a subprocess
@@ -660,6 +990,40 @@ async function preflight() {
660
990
  // an owner or repository name.
661
991
  const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
662
992
 
993
+ // A dot is mandatory, and that is the safety property rather than a nicety.
994
+ // Without it "owner/name/extra" -- already in the hostile-input list -- would
995
+ // stop being a rejected typo and quietly become "the repo name/extra on the
996
+ // host named owner", i.e. a slip of the finger turning into a request to
997
+ // somewhere else entirely. With it, every value in that list stays rejected.
998
+ // Labels may not start or end with a hyphen and the string may not have an
999
+ // empty, leading or trailing label, which is what rules out "-bad.host",
1000
+ // "host..com" and "host.com.".
1001
+ const HOST_PATTERN =
1002
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
1003
+
1004
+ const repoMessage = (value) => `--repo must look like owner/name or host/owner/name, got: ${value}`;
1005
+
1006
+ // `gh` itself accepts [HOST/]OWNER/REPO for --repo and GH_REPO; this accepts
1007
+ // the same shape and splits it into the two things that are used differently --
1008
+ // the slug, which is interpolated into a `gh api` path, and the host, which
1009
+ // never is. REPO_PATTERN keeps its exact meaning: it validates the owner/name
1010
+ // half, here as before.
1011
+ function parseRepoTarget(value) {
1012
+ const parts = String(value).split("/");
1013
+ let host = null;
1014
+ let slug;
1015
+ if (parts.length === 3 && HOST_PATTERN.test(parts[0])) {
1016
+ host = parts[0];
1017
+ slug = `${parts[1]}/${parts[2]}`;
1018
+ } else if (parts.length === 2) {
1019
+ slug = String(value);
1020
+ } else {
1021
+ throw new Error(repoMessage(value));
1022
+ }
1023
+ if (!REPO_PATTERN.test(slug)) throw new Error(repoMessage(value));
1024
+ return { host, slug };
1025
+ }
1026
+
663
1027
  // Below roughly two seconds a fetch cannot finish before the next tick, so the
664
1028
  // in-flight guard absorbs every other one and the effective rate is whatever
665
1029
  // `gh` can sustain -- the requested interval silently stops being real. Clamping
@@ -672,7 +1036,15 @@ const MAX_REFRESH_SECONDS = 3600;
672
1036
  // ignored -- so widening it keeps the same shape: every flag is named here, and
673
1037
  // anything else still exits 2.
674
1038
  function parseArgs(argv) {
675
- const opts = { help: false, showVersion: false, repo: null, refresh: null, tab: null, verbose: false };
1039
+ const opts = {
1040
+ help: false,
1041
+ showVersion: false,
1042
+ doctor: false,
1043
+ repo: null,
1044
+ refresh: null,
1045
+ tab: null,
1046
+ verbose: false,
1047
+ };
676
1048
  for (let i = 0; i < argv.length; i += 1) {
677
1049
  const arg = argv[i];
678
1050
  const takeValue = (name) => {
@@ -686,6 +1058,7 @@ function parseArgs(argv) {
686
1058
  if (arg === "--help" || arg === "-h") opts.help = true;
687
1059
  else if (arg === "--version" || arg === "-v") opts.showVersion = true;
688
1060
  else if (arg === "--verbose") opts.verbose = true;
1061
+ else if (arg === "--doctor") opts.doctor = true;
689
1062
  else if (arg === "--repo" || arg === "-R" || arg.startsWith("--repo=")) {
690
1063
  opts.repo = takeValue("--repo");
691
1064
  } else if (arg === "--refresh" || arg.startsWith("--refresh=")) {
@@ -702,9 +1075,7 @@ function parseArgs(argv) {
702
1075
  // Returns { repo, refreshMs, tabKey, verbose } or throws with a message that
703
1076
  // says what to do about it.
704
1077
  function validateArgs(opts, tabKeys) {
705
- if (opts.repo !== null && !REPO_PATTERN.test(opts.repo)) {
706
- throw new Error(`--repo must look like owner/name, got: ${opts.repo}`);
707
- }
1078
+ const { host, slug } = opts.repo !== null ? parseRepoTarget(opts.repo) : { host: null, slug: null };
708
1079
 
709
1080
  let refreshMs = null;
710
1081
  if (opts.refresh !== null) {
@@ -727,7 +1098,9 @@ function validateArgs(opts, tabKeys) {
727
1098
  return {
728
1099
  help: opts.help,
729
1100
  showVersion: opts.showVersion,
730
- repo: opts.repo,
1101
+ doctor: opts.doctor,
1102
+ repo: slug,
1103
+ host,
731
1104
  refreshMs,
732
1105
  tabKey: opts.tab,
733
1106
  verbose: opts.verbose,
@@ -746,12 +1119,18 @@ Usage:
746
1119
  gh-glance --refresh 15 Poll the active tab every 15 seconds
747
1120
  gh-glance --tab security Start on a specific tab
748
1121
  gh-glance --verbose 2>log Log every gh call to a file (see below)
1122
+ gh-glance --doctor Print a diagnostic report and exit
749
1123
  gh-glance --help Show this help
750
1124
  gh-glance --version Show the version
751
1125
 
752
1126
  Options:
753
- -R, --repo <owner/name> Repository to watch. Without it the repo is inferred
754
- from the git remote, the same way \`gh\` does it.
1127
+ -R, --repo [host/]owner/name
1128
+ Repository to watch. Without it the repo is inferred
1129
+ from the git remote, the same way \`gh\` does it. The
1130
+ host form targets a GitHub Enterprise or EMU
1131
+ data-residency tenant (e.g. tenant.ghe.com/acme/api)
1132
+ and is unnecessary when running inside a clone of
1133
+ that repository.
755
1134
  --refresh <seconds> Active-tab poll interval (${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS},
756
1135
  default ${REFRESH_MS / 1000}). Background tabs stay at
757
1136
  ${BACKGROUND_EVERY}x this.
@@ -760,6 +1139,11 @@ Options:
760
1139
  must be redirected -- writing it to the terminal
761
1140
  would draw over the dashboard, so this refuses to
762
1141
  start otherwise.
1142
+ --doctor Gather versions, authenticated hosts, the resolved
1143
+ repo target and one probe per endpoint, then exit.
1144
+ Safe to redirect to a file and share -- tokens are
1145
+ never printed, proxy credentials are stripped, and
1146
+ no response bodies are included.
763
1147
 
764
1148
  Run it from inside a locally cloned GitHub repository; the repo is inferred
765
1149
  from the git remote, the same way \`gh\` does it. Requires the \`gh\` CLI
@@ -781,8 +1165,13 @@ Keys:
781
1165
  r Refresh the current tab now
782
1166
  q / Esc / Ctrl+C Quit
783
1167
 
1168
+ The cursor clears itself after 60s with no movement.
1169
+
784
1170
  Environment:
785
1171
  GH_REPO=owner/name Watch a specific repository (--repo takes precedence)
1172
+ GH_HOST=host GitHub Enterprise or EMU host to send every call to.
1173
+ A host-qualified GH_REPO does not route \`gh api\`;
1174
+ this and --repo host/owner/name both do.
786
1175
  GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
787
1176
  GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
788
1177
  NO_COLOR=1 Disable colour (status stays readable)
@@ -818,6 +1207,19 @@ if (IS_MAIN) {
818
1207
  process.exit(0);
819
1208
  }
820
1209
 
1210
+ // A reporting command, like --help and --version: gather, print, exit. It sits
1211
+ // here on purpose -- ahead of the non-TTY refusal, because the whole point is
1212
+ // `gh-glance --doctor > report.txt`, and ahead of preflight(), because a
1213
+ // missing gh and a cwd outside a repository are exactly the conditions worth
1214
+ // reporting rather than exiting 3 over. The repo target is applied first so
1215
+ // the probes exercise the real one.
1216
+ if (opts.doctor) {
1217
+ runtime.repo = opts.repo;
1218
+ runtime.host = opts.host;
1219
+ console.log(await runDoctor());
1220
+ process.exit(0);
1221
+ }
1222
+
821
1223
  // Verbose output must never reach stdout -- that is ink's frame stream, and
822
1224
  // anything else in it corrupts the diff and the alternate-screen state. stderr
823
1225
  // is safe only once it is redirected somewhere; while it is still the
@@ -832,6 +1234,7 @@ if (IS_MAIN) {
832
1234
  }
833
1235
 
834
1236
  runtime.repo = opts.repo;
1237
+ runtime.host = opts.host;
835
1238
  runtime.verbose = opts.verbose;
836
1239
  if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
837
1240
  if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
@@ -1007,6 +1410,14 @@ function PanelEdge({ width, top, label, labelColor }) {
1007
1410
  );
1008
1411
  }
1009
1412
 
1413
+ // A plain rule under the tab bar, distinct from PanelEdge's corners so it
1414
+ // reads as a separator rather than another frame. Without it the tab labels
1415
+ // sat flush against the panel's top border -- readable, but dense enough that
1416
+ // the two rows scanned as one.
1417
+ function Divider({ width }) {
1418
+ return e(Text, { color: BORDER_COLOR, dimColor: true, "aria-hidden": true }, "─".repeat(Math.max(0, width)));
1419
+ }
1420
+
1010
1421
  // ---------- Error containment ----------
1011
1422
 
1012
1423
  // Every field on screen is defined by GitHub and will keep changing shape. With
@@ -1389,18 +1800,22 @@ function TabBar({ activeIndex, counts, firstLoad, failed, spin, useShort }) {
1389
1800
 
1390
1801
  // ---------- Status bar ----------
1391
1802
 
1392
- // Every glyph here is width-1 ASCII, deliberately. The arrows, the return
1393
- // symbol and the box-drawing separator are all East-Asian-Ambiguous: ink
1394
- // measures them as two columns in its width model, and a status bar built from
1395
- // them overflowed an 80-column terminal by six columns once selection added a
1396
- // hint. Same trap the unicode icon table already documents -- prefer strictly
1397
- // narrow ASCII over pretty-but-ambiguous.
1803
+ // Every glyph here is width-1 ASCII, deliberately, with one exception: the
1804
+ // Move arrows. The return symbol and the box-drawing separator are still
1805
+ // East-Asian-Ambiguous and stay out for that reason -- ink measures them as
1806
+ // two columns in its width model, and a status bar built from them overflowed
1807
+ // an 80-column terminal by six columns once selection added a hint. Same trap
1808
+ // the unicode icon table already documents -- prefer strictly narrow ASCII
1809
+ // over pretty-but-ambiguous. The arrow pair is a deliberate, tested exception:
1810
+ // `npm run test:pty` asserts the rendered frame stays within the terminal
1811
+ // width, so a font/terminal combo that renders them double-width fails loudly
1812
+ // there instead of silently shifting columns.
1398
1813
  //
1399
1814
  // Tab switching is not listed: the tab bar already renders "1:Actions", so the
1400
1815
  // digits document themselves, and the arrow keys are in --help. The hints that
1401
1816
  // survive are the ones nothing else on screen reveals.
1402
1817
  const KEY_HINTS = [
1403
- { label: "Move", keys: "jk" },
1818
+ { label: "Move", keys: "↑↓" },
1404
1819
  { label: "Open", keys: "Ent" },
1405
1820
  { label: "Refresh", keys: "r" },
1406
1821
  { label: "Quit", keys: "q" },
@@ -1409,8 +1824,6 @@ const KEY_HINTS = [
1409
1824
  // Reserved so the hints don't shift sideways every time a refresh starts and
1410
1825
  // finishes. Wide enough for the spinner, a space and "Fetching".
1411
1826
  const FETCHING_WIDTH = 12;
1412
- // Likewise for the staleness slot, which is empty most of the time.
1413
- const STALE_WIDTH = 10;
1414
1827
 
1415
1828
  // Two tones rather than one flat gray: the keys you press are the part worth
1416
1829
  // finding at a glance, so they get the accent colour and the words describing
@@ -1439,11 +1852,13 @@ function StatusBar({ fetching, spin, stale, interactive }) {
1439
1852
  `${fetching && spin ? spin : SPINNER[0]} Fetching`,
1440
1853
  ),
1441
1854
  ),
1442
- e(
1443
- Box,
1444
- { width: STALE_WIDTH, flexShrink: 0 },
1445
- stale ? e(Text, { color: ATTENTION }, stale) : null,
1446
- ),
1855
+ // No reserved width here, unlike the fetching slot above: this only
1856
+ // toggles on a real problem (a stalled poll, a laptop that just woke up),
1857
+ // not every refresh cycle, so letting the hints shift on that rare event
1858
+ // is worth getting the column back for the other 99% of the time.
1859
+ stale
1860
+ ? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { color: ATTENTION }, stale))
1861
+ : null,
1447
1862
  ...hints
1448
1863
  .flatMap((hint, i) => [
1449
1864
  i > 0 && e(Text, { key: `sep${i}`, color: BORDER_COLOR }, " | "),
@@ -1531,10 +1946,11 @@ function App() {
1531
1946
 
1532
1947
  const tab = TABS[activeIndex];
1533
1948
  const extraLines = (errors[tab.key] ? 1 : 0) + (tab.key === "security" ? securityNotes.length : 0);
1534
- // Reserve lines for: the panel's top and bottom edges (2), the column header
1535
- // and its separator (2), the tab bar (1) and the status line (1), plus a
1536
- // 1-line safety margin. Slack is absorbed by the spacer in the tree below.
1537
- const bodyRows = Math.max(1, rows - 7 - extraLines);
1949
+ // Reserve lines for: the tab bar and the divider under it (2), the panel's
1950
+ // top and bottom edges (2), the column header and its separator (2), and
1951
+ // the status line (1), plus a 1-line safety margin. Slack is absorbed by
1952
+ // the spacer in the tree below.
1953
+ const bodyRows = Math.max(1, rows - 8 - extraLines);
1538
1954
 
1539
1955
  // Read at fetch time rather than being a hook dependency, so dragging the
1540
1956
  // pane wider doesn't cancel and restart in-flight requests -- the next tick
@@ -1606,6 +2022,17 @@ function App() {
1606
2022
  });
1607
2023
  }
1608
2024
 
2025
+ // Rearms on every call to moveSelection (it's the only thing that changes
2026
+ // `selected`), so this fires exactly 60s after the *last* movement -- tab
2027
+ // switches and Enter don't count as activity and don't push it back. Clears
2028
+ // every tab's cursor at once rather than just the visible one, so a tab you
2029
+ // switch back to after being idle doesn't still show a stale row marked.
2030
+ useEffect(() => {
2031
+ if (Object.keys(selected).length === 0) return;
2032
+ const timer = setTimeout(() => setSelected({}), SELECTION_IDLE_MS);
2033
+ return () => clearTimeout(timer);
2034
+ }, [selected]);
2035
+
1609
2036
  function openSelected() {
1610
2037
  const { items, key: currentKey, tabKey } = navRef.current;
1611
2038
  const item = items.find((i) => itemKey(i) === currentKey);
@@ -1862,17 +2289,35 @@ function App() {
1862
2289
  ? `${tabOffset + 1}-${tabOffset + visibleItems.length} of ${counts[tab.key]}`
1863
2290
  : `${visibleItems.length} of ${counts[tab.key]}`;
1864
2291
 
2292
+ // One column short of the reported width, not the full width: some
2293
+ // terminals (observed in Ghostty, split-pane) clip or misrender whatever
2294
+ // glyph lands on the pane's absolute last column -- for this frame that is
2295
+ // always the right border. Stopping one column early costs a blank column
2296
+ // of slack but keeps the border visible everywhere, regardless of which
2297
+ // terminal is doing the clipping.
2298
+ const frameCols = Math.max(1, cols - 1);
2299
+
1865
2300
  // Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
1866
2301
  // at ordinary widths. Narrow panes drop columns instead, which keeps the
1867
2302
  // frame, the tab bar and the quit hint on screen -- the previous behaviour
1868
- // pushed all three off the bottom.
1869
- const compact = cols < MIN_TABLE_WIDTH;
2303
+ // pushed all three off the bottom. Measured against frameCols, the width the
2304
+ // frame itself actually gets, not the raw terminal width.
2305
+ const compact = frameCols < MIN_TABLE_WIDTH;
1870
2306
  const header = compact ? tab.compactHeader : tab.header;
1871
2307
 
1872
2308
  return e(
1873
2309
  Box,
1874
- { flexDirection: "column", width: "100%", height: Math.min(rows, usableSize(stdout?.rows, rows)) },
1875
- e(PanelEdge, { width: cols, top: true, label: tab.label, labelColor: TITLE_COLOR }),
2310
+ { flexDirection: "column", width: frameCols, height: Math.min(rows, usableSize(stdout?.rows, rows)) },
2311
+ e(TabBar, {
2312
+ activeIndex,
2313
+ counts,
2314
+ firstLoad,
2315
+ failed,
2316
+ spin,
2317
+ useShort: useShortLabels,
2318
+ }),
2319
+ e(Divider, { width: frameCols }),
2320
+ e(PanelEdge, { width: frameCols, top: true, label: tab.label, labelColor: TITLE_COLOR }),
1876
2321
  e(
1877
2322
  Box,
1878
2323
  {
@@ -1932,15 +2377,7 @@ function App() {
1932
2377
  // tab that only has a handful of rows.
1933
2378
  e(Box, { flexGrow: 1 }),
1934
2379
  ),
1935
- e(PanelEdge, { width: cols, top: false, label: countLabel, labelColor: BORDER_COLOR }),
1936
- e(TabBar, {
1937
- activeIndex,
1938
- counts,
1939
- firstLoad,
1940
- failed,
1941
- spin,
1942
- useShort: useShortLabels,
1943
- }),
2380
+ e(PanelEdge, { width: frameCols, top: false, label: countLabel, labelColor: BORDER_COLOR }),
1944
2381
  e(StatusBar, {
1945
2382
  fetching: Object.values(loading).some(Boolean),
1946
2383
  spin: showSpinner ? spin : null,
@@ -2046,7 +2483,9 @@ if (IS_MAIN) {
2046
2483
  export {
2047
2484
  parseArgs,
2048
2485
  validateArgs,
2486
+ parseRepoTarget,
2049
2487
  REPO_PATTERN,
2488
+ HOST_PATTERN,
2050
2489
  MIN_REFRESH_SECONDS,
2051
2490
  MAX_REFRESH_SECONDS,
2052
2491
  TAB_KEYS,
@@ -2054,6 +2493,11 @@ export {
2054
2493
  shortErr,
2055
2494
  isUnavailable,
2056
2495
  isRateLimited,
2496
+ isAuthProblem,
2497
+ AUTH_RETRY_MS,
2498
+ BACKOFF_STEPS_MS,
2499
+ redact,
2500
+ classify,
2057
2501
  formatAge,
2058
2502
  formatDuration,
2059
2503
  usableSize,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-glance",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "A live-refreshing GitHub dashboard for your terminal: Actions, Issues, Pull Requests, and Security alerts in one narrow pane",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,7 +51,7 @@
51
51
  "@eslint/js": "^10.0.1",
52
52
  "eslint": "^10.8.0",
53
53
  "eslint-plugin-react-hooks": "^7.1.0",
54
- "globals": "^16.5.0",
54
+ "globals": "^17.8.0",
55
55
  "husky": "^9.1.7"
56
56
  }
57
57
  }