gh-glance 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +117 -14
  3. package/index.mjs +514 -93
  4. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,119 @@ 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.0] - 2026-08-04
9
+
10
+ Correctness on GitHub Enterprise and Enterprise Managed Users tenants, and a
11
+ diagnostic command built to settle what a bug report cannot describe. The two
12
+ enterprise entries below are the same failure: the dashboard stating something
13
+ confident and wrong, which is precisely what its error classifier exists to
14
+ prevent. The rest of the release is a run of layout and interaction fixes.
15
+
16
+ ### Fixed
17
+
18
+ - **A lapsed enterprise SAML session was reported as "not enabled for this
19
+ repository", and latched for up to an hour.** EMU tenants expire the SAML
20
+ session periodically; while lapsed the API answers 403, which the classifier
21
+ could not tell apart from "this feature is switched off". So the Security tab
22
+ went blank under a false explanation, and the escalating backoff
23
+ (60s -> 300s -> 1800s -> 3600s) kept it that way long after the user had
24
+ re-authorized in the browser. Auth failures -- SAML, SSO, a credential not
25
+ authorized for the org, a missing token scope -- now surface `gh`'s real
26
+ message and take a short fixed 30s retry instead of the escalating ladder, so
27
+ recovery is bounded at about half a minute. A 403 with no auth markers still
28
+ produces the existing note and ladder.
29
+ - **On a non-default host the three security-alert endpoints were queried
30
+ against `github.com`.** `gh api` has no `--repo`, so a host-qualified target
31
+ routed the four list-driven tabs to the enterprise host while the alert calls
32
+ went to `github.com`, 404ed, and -- via the defect above -- rendered as "not
33
+ enabled". The Security tab could therefore report that a feature was off for a
34
+ repository whose alerts it had never asked for. The host now travels as
35
+ `--hostname`, and both halves of the dashboard talk to the same server. The
36
+ fixture-log assertions in `test/pty/routing.test.mjs` are the guard against it
37
+ recurring.
38
+ - **`Enter` on an Actions row opened the wrong run, or none at all.** Every
39
+ openable row passed its display `number` to `gh <kind> view`, which is right
40
+ for an issue or a pull request and wrong for a workflow run: runs are
41
+ addressed by `databaseId`, a different and global ID space, so a run's number
42
+ is almost never a valid one and the browser got a 404. Runs now open by
43
+ `databaseId`; issues and pull requests are unchanged.
44
+ - **The panel's right border could be clipped at the terminal edge.** One
45
+ trailing column is now reserved for it, so the frame closes on terminals that
46
+ treat the final cell as the wrap boundary.
47
+ - **The stale indicator reserved its column even when there was nothing to
48
+ say.** An idle dashboard now gives that width back to the table instead of
49
+ holding it empty against a label that only appears after 30 seconds without a
50
+ successful refresh.
51
+ - **The spinner jittered against the status icons beside it.** The classic
52
+ "dots" braille set lights only one or two of a cell's eight dot positions per
53
+ frame, and in different corners each frame, so next to the solid circle glyphs
54
+ used for completed runs it visibly wandered rather than holding a centre. The
55
+ replacement lights six or seven dots per frame, reading as a filled blob of
56
+ the same visual weight, and stays width-1.
57
+
58
+ ### Changed
59
+
60
+ - **The tab bar moved to the top of the pane**, above the table rather than
61
+ below it, with a divider separating the two. Below the table it was being
62
+ missed entirely; the status and key hints stay at the bottom.
63
+ - **The rate-limit figures in the README were wrong and are now stated per
64
+ tab.** The documented "roughly 500 requests an hour, about 10% of the limit"
65
+ did not follow from the constants it described. A visible Actions, Issues or
66
+ Pull Requests tab costs about 1,000 an hour (20% of the 5,000/hour
67
+ authenticated limit); the Security tab, being three endpoints rather than one,
68
+ costs about 2,300 (47%). Both figures fall when an endpoint backs off because
69
+ the feature is not enabled, and `--refresh` scales the whole number.
70
+
71
+ ### Added
72
+
73
+ - **`--repo` accepts `[host/]owner/name`**, the same form `gh --repo` itself
74
+ accepts, so a GitHub Enterprise or EMU data-residency tenant can be watched
75
+ from outside a clone: `gh-glance --repo tenant.ghe.com/acme/widget`. A host
76
+ must contain a dot, which is what keeps `owner/name/extra` a rejected typo
77
+ rather than a silent request to a host named `owner`. The host is validated
78
+ separately from the `owner/name` slug and is never interpolated into a request
79
+ path.
80
+ - **`--doctor`**, a reporting command that prints versions, authenticated hosts,
81
+ how the repository target resolved, the relevant environment, and one probe
82
+ per endpoint -- with the exact argv sent and how any error was classified.
83
+ It exits 0 and reports rather than failing when `gh` is missing or the working
84
+ directory is not a repository, and it works through a pipe. Tokens are never
85
+ printed: token-valued variables are reported as present or absent, anything
86
+ token-shaped in captured text is replaced, URL credentials are stripped, and
87
+ no response bodies are included -- so the report is safe to attach to a bug
88
+ report.
89
+
90
+ ## [0.3.1] - 2026-08-04
91
+
92
+ First release published by CI rather than from a laptop, and the first carrying
93
+ an [npm provenance
94
+ attestation](https://docs.npmjs.com/generating-provenance-statements) linking
95
+ the tarball to the workflow run and commit that built it.
96
+
97
+ ### Fixed
98
+
99
+ - **The install instructions on the npm page told you to clone the
100
+ repository.** `README.md` ships inside the tarball, and the copy published
101
+ with `0.3.0` still said "Not yet published to npm -- clone + `npm link` is
102
+ the current install path". It was written before there was a package and was
103
+ false the moment one existed. `npm install -g gh-glance` is now the first
104
+ thing the page shows.
105
+
106
+ ### Added
107
+
108
+ - **Publishing is automatic and credential-free.** Publishing a GitHub release
109
+ builds and publishes from the tagged commit via OIDC trusted publishing.
110
+ There is no `NPM_TOKEN` in the repository and there will not be one: npm
111
+ revoked all classic tokens in December 2025, removed account and package
112
+ management from bypass-2FA granular tokens in August 2026, and removes their
113
+ direct-publish ability in January 2027.
114
+ - The release workflow now installs the packed tarball globally and runs the
115
+ binary before publishing, so a dropped `files` entry or a broken bin shebang
116
+ fails the release rather than the next person's install. It also refuses a
117
+ tag that is not reachable from `main` or that does not point at the commit
118
+ being built, and skips a version that is already on the registry rather than
119
+ hard-failing on `E403`.
120
+
8
121
  ## [0.3.0] - 2026-08-03
9
122
 
10
123
  The dashboard stops being read-only: you can now move through the table and open
@@ -196,5 +309,7 @@ engineering, security, QA and UX. What follows is what changed as a result.
196
309
  - The `main` field from `package.json`. It advertised the file as importable,
197
310
  but importing it took over the terminal or exited the host process.
198
311
 
312
+ [0.4.0]: https://github.com/juan294/gh-glance/releases/tag/v0.4.0
313
+ [0.3.1]: https://github.com/juan294/gh-glance/releases/tag/v0.3.1
199
314
  [0.3.0]: https://github.com/juan294/gh-glance/releases/tag/v0.3.0
200
315
  [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: jk | 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,13 +88,35 @@ 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))
94
98
 
95
99
  ## Install
96
100
 
101
+ ```bash
102
+ npm install -g gh-glance
103
+ ```
104
+
105
+ That is the whole install. `gh-glance` ships as a single `index.mjs` with no
106
+ build step, so there is nothing to compile and nothing to configure -- the
107
+ package is the source you can read in this repository.
108
+
109
+ Every published version is built and signed by GitHub Actions from a tagged
110
+ commit on `main`, never from a maintainer's laptop, and carries [npm
111
+ provenance](https://docs.npmjs.com/generating-provenance-statements) linking
112
+ the tarball back to the workflow run and commit that produced it. The
113
+ "Provenance" section on the [package
114
+ page](https://www.npmjs.com/package/gh-glance) shows the exact source.
115
+
116
+ ### From a clone
117
+
118
+ For hacking on it, or to run an unreleased revision:
119
+
97
120
  ```bash
98
121
  git clone https://github.com/juan294/gh-glance.git
99
122
  cd gh-glance
@@ -101,8 +124,6 @@ npm install
101
124
  npm link # makes the `gh-glance` command available globally
102
125
  ```
103
126
 
104
- (Not yet published to npm -- clone + `npm link` is the current install path.)
105
-
106
127
  `npm link` is what puts `gh-glance` on your `PATH`; it symlinks the global
107
128
  bin at your Node version's `bin/` directory through to this checkout:
108
129
 
@@ -117,6 +138,10 @@ it, and both are fixed by re-running `npm link` from the clone:
117
138
  - switching Node versions (nvm/fnm install their own global `bin/`)
118
139
  - setting up on a fresh machine, or after `npm unlink -g gh-glance`
119
140
 
141
+ A linked clone shadows a global install, since both write the same bin name.
142
+ Run `npm unlink -g gh-glance` before `npm install -g gh-glance` if you want
143
+ to go back to the published version.
144
+
120
145
  ### Using it as a workspace pane
121
146
 
122
147
  `gh-glance` takes no arguments and infers the repo from the working
@@ -179,10 +204,11 @@ pane definition. Flags are there when you want them:
179
204
 
180
205
  | Flag | Effect |
181
206
  |---|---|
182
- | `-R`, `--repo owner/name` | Watch a specific repository instead of the current directory's. Works from anywhere -- you do not need a local clone. |
207
+ | `-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`. |
183
208
  | `--refresh <seconds>` | Active-tab poll interval, 2-3600, default 5. Background tabs stay at 12x this. |
184
209
  | `--tab <name>` | Start on `actions`, `issues`, `prs` or `security`. |
185
210
  | `--verbose` | Log one line per `gh` call to stderr, with timing and outcome. stderr must be redirected: `gh-glance --verbose 2>gh-glance.log`. |
211
+ | `--doctor` | Print a diagnostic report and exit. See [Diagnostics](#diagnostics). |
186
212
 
187
213
  An unrecognised flag exits 2 rather than being ignored, so a typo fails loudly.
188
214
 
@@ -190,19 +216,89 @@ Environment variables work too, and the flags take precedence:
190
216
 
191
217
  | Variable | Effect |
192
218
  |---|---|
193
- | `GH_REPO=owner/name` | Watch a specific repository instead of the current directory's |
219
+ | `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 |
220
+ | `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 |
221
+ | `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 |
194
222
  | `GH_GLANCE_ICONS=unicode` | Plain ASCII status icons, for terminals without a Nerd Font |
195
223
  | `GH_GLANCE_NO_ANIMATION=1` | Freeze the spinner — no motion at all |
196
224
  | `NO_COLOR=1` | Disable colour. Status stays readable: severity has its own column, run states have distinct glyphs, and the active tab is bracketed |
197
225
  | `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. |
198
226
 
227
+ ## GitHub Enterprise and EMU
228
+
229
+ **The common case needs no configuration.** Authenticate once --
230
+ `gh auth login --hostname <your-host>` -- then run `gh-glance` inside a clone of
231
+ a work repository. The repository and its host are both resolved from the git
232
+ remote, the same way `gh` does it.
233
+
234
+ **There are two EMU forms.** Standard Enterprise Managed Users lives on
235
+ `github.com`; EMU with data residency lives on `<slug>.ghe.com`. `gh auth
236
+ status` tells you which you have, and only the second needs a host mentioned
237
+ anywhere.
238
+
239
+ **To watch a repository you have not cloned**, on a non-default host:
240
+
241
+ ```bash
242
+ gh-glance --repo tenant.ghe.com/acme/widget
243
+ ```
244
+
245
+ The `[host/]owner/name` form is the same one `gh --repo` accepts. The host must
246
+ contain a dot, so `owner/name/extra` is still rejected as the typo it is rather
247
+ than read as a request to a host named `owner`.
248
+
249
+ **`GH_HOST` works too**, and is the better lever when every repository you watch
250
+ lives on one enterprise host -- it routes the list commands and the alert
251
+ endpoints alike. A host-qualified **`GH_REPO`**, by contrast, does *not* route
252
+ `gh api`: it supplies the owner and repository and ignores the host, which is
253
+ why `--repo` is the right tool for a one-off cross-host target.
254
+
255
+ **SAML sessions expire.** When an enterprise session lapses, the endpoints
256
+ answer 403 and the tab shows the real `gh` message. Re-authorize in the browser
257
+ (or run `gh auth refresh`) and the dashboard recovers within about 30 seconds.
258
+ It will not claim a feature is "not enabled" because of a lapse.
259
+
260
+ ### Diagnostics
261
+
262
+ ```bash
263
+ gh-glance --doctor > report.txt
264
+ ```
265
+
266
+ Collects, in one plain-text block: the `gh-glance`, Node and `gh` versions;
267
+ which hosts `gh` is authenticated for; how the repository target resolved and
268
+ from where; the relevant environment variables; and one probe per endpoint with
269
+ the exact argv it sent, its outcome, and how any error was classified
270
+ (`unavailable`, `rate-limited`, `auth-problem` or `other`).
271
+
272
+ It exits 0 and prints a report even when `gh` is missing or you are outside a
273
+ git repository -- those are conditions worth reporting rather than failing on --
274
+ and it works through a pipe, unlike the dashboard itself.
275
+
276
+ **Tokens are never printed.** Token-valued environment variables are reported
277
+ as `set` or `not set` and never by value, not even a prefix; anything
278
+ token-shaped anywhere in the captured text is replaced; proxy and remote URL
279
+ credentials are stripped to scheme and host; and no API response bodies are
280
+ included, only their sizes. The report is safe to attach to a bug report.
281
+
199
282
  ## Rate limit
200
283
 
201
284
  The visible tab refreshes every 5 seconds; the other three refresh every 60
202
- seconds, purely to keep their counts honest. That works out at roughly 500
203
- REST requests an hour, about 10% of the 5,000/hour authenticated limit — so
204
- leaving a pane open all day does not exhaust the budget your other `gh`
205
- commands share.
285
+ seconds, purely to keep their counts honest.
286
+
287
+ With Actions, Issues or Pull Requests as the visible tab that works out at
288
+ roughly 1,000 REST requests an hour — about 20% of the 5,000/hour
289
+ authenticated limit. The Security tab is the expensive one, because it is
290
+ three endpoints rather than one: watching it costs roughly 2,300 an hour,
291
+ just under half the budget.
292
+
293
+ Two things pull the real number down. An endpoint that is not enabled for the
294
+ repository — code scanning and secret scanning without Advanced Security —
295
+ backs off after its first refusal rather than being re-asked every tick, which
296
+ removes two of the Security tab's three calls. And `--refresh` scales the
297
+ whole figure: doubling the interval halves it.
298
+
299
+ So a pane left open all day shares the budget with your other `gh` commands
300
+ rather than exhausting it, but it is not free, and parking it on the Security
301
+ tab is the case worth knowing about.
206
302
 
207
303
  ## Limitations
208
304
 
@@ -232,6 +328,10 @@ commands share.
232
328
  - macOS/Linux terminals with ANSI + alternate-screen-buffer support. Not
233
329
  tested on Windows -- `npm` will not stop you installing it there, but the
234
330
  alternate-screen handling is unverified.
331
+ - **No account pinning.** `gh`'s active account is global, so with two accounts
332
+ authenticated on the same host `gh-glance` follows whichever one `gh auth
333
+ switch` last selected -- it has no way to pin a pane to one of them. If you
334
+ need two panes on two accounts at once, give each its own `GH_CONFIG_DIR`.
235
335
 
236
336
  ### Icons without a Nerd Font
237
337
 
@@ -257,7 +357,10 @@ in some terminals, which would shift every column to their right.
257
357
  | `the gh CLI is not installed` | Install it from [cli.github.com](https://cli.github.com), then `gh auth login`. |
258
358
  | `not inside a git repository` | Run it from a cloned GitHub repository, or set `GH_REPO=owner/name`. |
259
359
  | Status icons are blank boxes | Your terminal font is not a Nerd Font. Use `GH_GLANCE_ICONS=unicode`. |
260
- | 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. |
360
+ | 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. |
361
+ | `none of the git remotes ... point to a known GitHub host` | `gh` is not authenticated for that host. Run `gh auth login --hostname <host>`. |
362
+ | 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. |
363
+ | 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. |
261
364
  | A tab's count is red | That tab's last fetch failed. The error itself is shown when you switch to it. |
262
365
  | `stale 2m` in the status bar | The visible tab has not refreshed successfully for a while — usually a network drop. |
263
366
  | It exits immediately when piped | Intentional. It is a full-screen dashboard, not a reporting command. |
package/index.mjs CHANGED
@@ -103,6 +103,13 @@ const GH_MAX_BUFFER = 16 * 1024 * 1024;
103
103
  // a lie about a security surface.
104
104
  const BACKOFF_STEPS_MS = [60_000, 300_000, 1_800_000, 3_600_000];
105
105
 
106
+ // An auth failure is the opposite kind of thing: the user fixes it in seconds
107
+ // by re-authorizing in the browser, so a ladder measured in half-hours would
108
+ // leave the tab blank long after the cause was gone. A single short step keeps
109
+ // recovery bounded at ~30s while still bounding what a lapse that lasts all
110
+ // night costs -- two probes a minute per endpoint rather than one per tick.
111
+ const AUTH_RETRY_MS = [30_000];
112
+
106
113
  // Past this, the active tab's data is old enough to say so. Deliberately
107
114
  // coarse: `now` only advances on minute boundaries when nothing is in progress,
108
115
  // so a minute-granular staleness label costs zero extra redraws, whereas a
@@ -115,6 +122,11 @@ const STALE_AFTER_MS = 30_000;
115
122
  // the zero-argument invocation the README documents behaves exactly as before.
116
123
  const runtime = {
117
124
  repo: null, // null means "let gh infer it from the git remote", as today
125
+ // null unless the target was host-qualified. `gh` accepts [HOST/]OWNER/REPO;
126
+ // when a host is present the list subcommands get it inside --repo and the
127
+ // `gh api` calls get it as --hostname, because gh api has no --repo and would
128
+ // otherwise silently query github.com while the other tabs read the tenant.
129
+ host: null,
118
130
  refreshMs: REFRESH_MS,
119
131
  verbose: false,
120
132
  initialTabIndex: 0,
@@ -124,7 +136,13 @@ const runtime = {
124
136
  // table itself is built.
125
137
  const TAB_KEYS = ["actions", "issues", "prs", "security"];
126
138
 
127
- const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
139
+ // The classic "dots" braille spinner lights only 1-2 of a cell's 8 dot
140
+ // positions per frame, and different frames light different corners --
141
+ // so next to the solid Nerd Font circle icons used for completed runs, it
142
+ // visibly jitters instead of holding a steady center. This set lights 6-7
143
+ // dots per frame, reading as a filled blob that matches the circle icons'
144
+ // visual weight while staying in the same width-1 braille block.
145
+ const SPINNER = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
128
146
  const SPINNER_MS = 100;
129
147
 
130
148
  // Motion opt-out. This pane is designed to sit in peripheral vision for hours,
@@ -236,17 +254,70 @@ function shortErr(err) {
236
254
  return text.length > MAX_ERR_LENGTH ? `${text.slice(0, MAX_ERR_LENGTH - 1)}…` : text;
237
255
  }
238
256
 
257
+ // Where gh's diagnosis actually lives on a rejected execFile: stderr carries
258
+ // the HTTP line, and message is all a missing binary or a spawn failure leaves
259
+ // behind. One expression rather than one per predicate, so a fourth predicate
260
+ // cannot pick a fifth answer to the same question.
261
+ function errText(err) {
262
+ return String(err?.stderr ?? err?.message ?? "");
263
+ }
264
+
239
265
  // `gh api` puts the HTTP status in stderr and exits 1, so err.code is the
240
266
  // process exit code and says nothing about the cause. 403/404 is the honest
241
267
  // "you can't see this here" -- everything else (auth expiry, rate limiting,
242
268
  // DNS, a 502) is a real failure that used to be reported as a confident,
243
269
  // plausible, and wrong claim that the feature was switched off.
244
270
  function isUnavailable(err) {
245
- return /HTTP (403|404)/.test(String(err?.stderr ?? err?.message ?? ""));
271
+ return /HTTP (403|404)/.test(errText(err));
246
272
  }
247
273
 
248
274
  function isRateLimited(err) {
249
- return /rate limit|secondary rate|API rate limit/i.test(String(err?.stderr ?? err?.message ?? ""));
275
+ return /rate limit|secondary rate|API rate limit/i.test(errText(err));
276
+ }
277
+
278
+ // On an enterprise or EMU tenant a 403 carries meanings it never carries on a
279
+ // personal account: an expired SAML session, a credential not authorized for
280
+ // the org, a token missing a scope. None of those are statements about the
281
+ // repository's configuration, and all of them are fixed by the user in seconds
282
+ // -- so they must surface as themselves rather than as "not enabled", and must
283
+ // not latch the hour-long backoff.
284
+ //
285
+ // Written broad on purpose. A message that fails to match degrades to the old
286
+ // behaviour, and `--doctor` reports the verbatim text plus the classification
287
+ // it received, so the pattern is tightened from evidence. A false positive
288
+ // merely retries every 30s instead of backing off, which is cheap and
289
+ // self-correcting. The asymmetry favours breadth.
290
+ // `access restriction` is what catches the OAuth App restrictions form, which
291
+ // says "you appear to have the correct authorization credentials" and so
292
+ // matches none of the negative markers -- it is an authorization failure phrased
293
+ // entirely in the positive. It stays clear of the genuine not-enabled messages,
294
+ // which talk about features rather than access.
295
+ const AUTH_MARKERS =
296
+ /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;
297
+
298
+ function isAuthProblem(err) {
299
+ return AUTH_MARKERS.test(errText(err));
300
+ }
301
+
302
+ // The one place the three predicates are turned into a verdict. Both consumers
303
+ // go through it -- the fetcher, to choose a note and a backoff ladder, and
304
+ // `--doctor`, to report what gh-glance concluded -- so the report cannot claim
305
+ // a classification the dashboard does not actually make. Deriving the order
306
+ // twice would let exactly that drift in the moment it mattered most: AUTH_MARKERS
307
+ // is deliberately broad and expected to grow, and the day it grows into a
308
+ // message the rate-limit pattern also matches is the day two copies disagree.
309
+ //
310
+ // The order is a priority, not a sequence of independent tests. Rate limiting
311
+ // arrives as a 403 and means the opposite of a permissions problem, so it
312
+ // outranks the auth markers; both outrank "unavailable", which is the reading
313
+ // of last resort for a 403/404 and the only one that makes a claim about the
314
+ // repository's configuration.
315
+ function classify(err) {
316
+ if (err == null) return "ok";
317
+ if (isRateLimited(err)) return "rate-limited";
318
+ if (isAuthProblem(err)) return "auth-problem";
319
+ if (isUnavailable(err)) return "unavailable";
320
+ return "other";
250
321
  }
251
322
 
252
323
  // Some pty wrappers (and a terminal mid-resize) report a size of 0 or
@@ -312,35 +383,58 @@ async function runGh(args, { signal } = {}) {
312
383
  }
313
384
  }
314
385
 
386
+ // The target as `gh` spells it: host-qualified when a host was given, the bare
387
+ // slug otherwise -- which is exactly the [HOST/]OWNER/REPO form `gh --repo`
388
+ // documents.
389
+ function qualifiedRepo() {
390
+ return runtime.host ? `${runtime.host}/${runtime.repo}` : runtime.repo;
391
+ }
392
+
315
393
  // `--repo` for the list subcommands. Empty when unset, so the argv vector stays
316
394
  // byte-identical to what shipped before.
317
395
  function repoArgs() {
318
- return runtime.repo ? ["--repo", runtime.repo] : [];
396
+ return runtime.repo ? ["--repo", qualifiedRepo()] : [];
397
+ }
398
+
399
+ // `gh api` has no --repo, so a host-qualified target has nowhere to put its
400
+ // host: GH_REPO=host/owner/repo supplies the owner and repo and *ignores* the
401
+ // host (verified against gh 2.97.0), which is the one combination where the
402
+ // list tabs and the alert endpoints disagree about which server they are
403
+ // talking to. --hostname is the flag gh api does have. Empty when no host was
404
+ // given, so the default argv vector is unchanged.
405
+ function apiHostArgs() {
406
+ return runtime.host ? ["--hostname", runtime.host] : [];
319
407
  }
320
408
 
321
409
  // `gh api` has no --repo; it resolves the {owner}/{repo} placeholder from the
322
410
  // working directory. Substituting the validated value is what makes --repo work
323
411
  // for the alert endpoints. REPO_PATTERN is why this is safe to interpolate into
324
- // a request path.
412
+ // a request path -- and note it is the bare slug that goes in, never the host,
413
+ // which travels as an argument rather than as path text.
325
414
  function apiPath(path) {
326
415
  return runtime.repo ? path.replace("{owner}/{repo}", runtime.repo) : path;
327
416
  }
328
417
 
329
418
  // ---------- Data fetchers ----------
330
419
 
420
+ // The argv vector for each endpoint is built by a named function rather than
421
+ // inline, because `--doctor` reports these vectors and a second copy of them
422
+ // would be a report that drifts away from what the dashboard actually sends --
423
+ // which is the failure mode the whole diagnostics command exists to rule out.
424
+ function actionsArgs(limit) {
425
+ return [
426
+ "run",
427
+ "list",
428
+ ...repoArgs(),
429
+ "--limit",
430
+ String(limit),
431
+ "--json",
432
+ "databaseId,displayTitle,workflowName,number,headBranch,status,conclusion,startedAt,updatedAt",
433
+ ];
434
+ }
435
+
331
436
  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
- );
437
+ const raw = await runGh(actionsArgs(limit), { signal });
344
438
  return {
345
439
  raw,
346
440
  limit,
@@ -369,22 +463,23 @@ async function fetchActions(limit, signal) {
369
463
  // returns the same result count.
370
464
  const SORT_RECENT = ["--search", "sort:updated-desc"];
371
465
 
466
+ function issuesArgs() {
467
+ return [
468
+ "issue",
469
+ "list",
470
+ ...repoArgs(),
471
+ "--state",
472
+ "open",
473
+ "--limit",
474
+ String(LIST_LIMIT),
475
+ ...SORT_RECENT,
476
+ "--json",
477
+ "number,title,author,labels,createdAt,updatedAt",
478
+ ];
479
+ }
480
+
372
481
  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
- );
482
+ const raw = await runGh(issuesArgs(), { signal });
388
483
  return {
389
484
  raw,
390
485
  limit: LIST_LIMIT,
@@ -399,22 +494,23 @@ async function fetchIssues(signal) {
399
494
  };
400
495
  }
401
496
 
497
+ function prsArgs() {
498
+ return [
499
+ "pr",
500
+ "list",
501
+ ...repoArgs(),
502
+ "--state",
503
+ "open",
504
+ "--limit",
505
+ String(LIST_LIMIT),
506
+ ...SORT_RECENT,
507
+ "--json",
508
+ "number,title,author,headRefName,isDraft,reviewDecision,createdAt,updatedAt",
509
+ ];
510
+ }
511
+
402
512
  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
- );
513
+ const raw = await runGh(prsArgs(), { signal });
418
514
  return {
419
515
  raw,
420
516
  limit: LIST_LIMIT,
@@ -495,6 +591,10 @@ const ALERT_SOURCES = [
495
591
  },
496
592
  ];
497
593
 
594
+ function alertArgs(source) {
595
+ return ["api", apiPath(source.path), ...apiHostArgs(), "--jq", source.jq];
596
+ }
597
+
498
598
  // Per-source backoff. Keyed by source so one unavailable endpoint cannot slow
499
599
  // the other two, and capped rather than permanent so enabling Advanced Security
500
600
  // mid-session is picked up within the hour.
@@ -505,10 +605,19 @@ function backoffActive(key, now) {
505
605
  return Boolean(state) && now < state.until;
506
606
  }
507
607
 
508
- function recordFailure(key, now) {
608
+ // Which ladder each verdict takes. "rate-limited" and "other" are absent on
609
+ // purpose: a rate limit clears on its own schedule and a network drop clears
610
+ // when the network does, so both keep today's behaviour of re-asking every tick
611
+ // with the real message on screen.
612
+ const FAILURE_LADDER = { unavailable: BACKOFF_STEPS_MS, "auth-problem": AUTH_RETRY_MS };
613
+
614
+ // The ladder is a parameter rather than a second near-identical function,
615
+ // which is the drift the ALERT_SOURCES comment below warns about: two copies of
616
+ // this would diverge the first time one of them was fixed.
617
+ function recordFailure(key, now, steps = BACKOFF_STEPS_MS) {
509
618
  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] });
619
+ const step = Math.min((previous?.step ?? -1) + 1, steps.length - 1);
620
+ alertBackoff.set(key, { step, until: now + steps[step] });
512
621
  }
513
622
 
514
623
  function clearBackoff(key) {
@@ -521,7 +630,7 @@ async function fetchAlertSource(source, signal, now) {
521
630
  return { raw: `backoff:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
522
631
  }
523
632
  try {
524
- const raw = await runGh(["api", apiPath(source.path), "--jq", source.jq], { signal });
633
+ const raw = await runGh(alertArgs(source), { signal });
525
634
  clearBackoff(source.key);
526
635
  return {
527
636
  raw,
@@ -535,14 +644,15 @@ async function fetchAlertSource(source, signal, now) {
535
644
  },
536
645
  };
537
646
  } 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);
647
+ // Only "unavailable" is a statement about the repository's configuration,
648
+ // so it is the only verdict allowed to replace gh's message with the
649
+ // source's fixed note. Everything else -- an expired SAML session, a rate
650
+ // limit, a network drop -- surfaces as itself.
651
+ const verdict = classify(err);
652
+ const note = verdict === "unavailable" ? source.unavailable : `${source.name}: ${shortErr(err)}`;
653
+ const steps = pick(FAILURE_LADDER, verdict, null);
654
+ if (steps) {
655
+ recordFailure(source.key, now, steps);
546
656
  alertBackoff.get(source.key).note = note;
547
657
  }
548
658
  return { raw: `unavailable:${note}`, parse: () => ({ alerts: [], note, truncated: false }) };
@@ -605,11 +715,18 @@ const OPENABLE = { actions: "run", issues: "issue", prs: "pr" };
605
715
  // Output is captured rather than inherited. `gh ... --web` prints "Opening ...
606
716
  // in your browser" to stdout, and stdout is ink's frame stream -- letting that
607
717
  // through would corrupt the diff and the alternate screen.
718
+ //
719
+ // `gh run view` takes the run's databaseId, not its display `number` -- the
720
+ // two are different ID spaces (databaseId is global across GitHub, number is
721
+ // per-workflow and restarts near 1 in every repo), so a run's `number` is
722
+ // almost never a valid databaseId elsewhere and 404s. `gh issue view` and
723
+ // `gh pr view` are the opposite: they take the issue/PR number, and neither
724
+ // row shape carries a databaseId to prefer instead.
608
725
  async function openInBrowser(tabKey, item, signal) {
609
726
  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 });
727
+ const id = kind === "run" ? (item?.databaseId ?? item?.number) : (item?.number ?? item?.databaseId);
728
+ if (!kind || id == null) return;
729
+ await runGh([kind, "view", ...repoArgs(), String(id), "--web"], { signal });
613
730
  }
614
731
 
615
732
  // ---------- Startup preflight ----------
@@ -650,6 +767,213 @@ async function preflight() {
650
767
  return null;
651
768
  }
652
769
 
770
+ // ---------- Diagnostics (--doctor) ----------
771
+
772
+ // This report is written to be pasted into a bug report or a chat window, which
773
+ // makes it a disclosure surface before it is anything else. So redaction is not
774
+ // a review note applied per call site -- it is applied once, at the single point
775
+ // where the report is assembled, the same way safe() is the one boundary for
776
+ // untrusted remote strings rather than six.
777
+ //
778
+ // Token shapes are gh's own prefixes (gho_/ghp_/ghu_/ghs_/ghr_ and
779
+ // github_pat_). URL userinfo covers a proxy variable, a git remote with an
780
+ // embedded credential, and anything else that carries `user:password@` -- gh
781
+ // error messages quote the URL they failed on, so that is a real path for one
782
+ // to arrive here.
783
+ function redact(text) {
784
+ return String(text)
785
+ .replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, "<redacted-token>")
786
+ .replace(/github_pat_[A-Za-z0-9_]{16,}/g, "<redacted-token>")
787
+ .replace(/\/\/[^/\s:@]+:[^/\s@]+@/g, "//<redacted>@");
788
+ }
789
+
790
+ // Values that are the thing being diagnosed, so they are printed as-is.
791
+ const DOCTOR_ENV_PLAIN = [
792
+ "GH_HOST",
793
+ "GH_REPO",
794
+ "GH_CONFIG_DIR",
795
+ "NO_PROXY",
796
+ "GH_GLANCE_ICONS",
797
+ "GH_GLANCE_NO_ANIMATION",
798
+ "NO_COLOR",
799
+ "NODE_ENV",
800
+ ];
801
+ const DOCTOR_ENV_PROXY = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"];
802
+ // Reported as present or absent and never printed, not even a prefix. Matched
803
+ // by name so a variable nobody listed here -- GITHUB_EMU_TOKEN, some wrapper's
804
+ // GH_API_KEY -- is still caught by shape rather than by having been foreseen.
805
+ const SECRET_NAME = /_(TOKEN|SECRET|PASSWORD|KEY)$/i;
806
+ const DOCTOR_ENV_SECRET = ["GH_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_TOKEN"];
807
+
808
+ const NOT_SET = "not set";
809
+
810
+ // Scheme and host only. A proxy URL is one of the few env values that routinely
811
+ // carries a credential inline, and the host is the entire diagnostic value.
812
+ function proxySummary(value) {
813
+ for (const candidate of [value, `http://${value}`]) {
814
+ try {
815
+ const url = new URL(candidate);
816
+ if (url.host) return `${url.protocol}//${url.host}`;
817
+ } catch {
818
+ // Not a URL in this form; try the next one, then give up.
819
+ }
820
+ }
821
+ return "(set, unparseable)";
822
+ }
823
+
824
+ // Every gh/GITHUB variable that is actually set gets a line, so the report shows
825
+ // the environment as it is rather than as this list imagined it. Token-shaped
826
+ // names still take the presence-only path below.
827
+ function doctorEnvNames() {
828
+ const named = [...DOCTOR_ENV_PLAIN, ...DOCTOR_ENV_PROXY, ...DOCTOR_ENV_SECRET];
829
+ const known = new Set(named);
830
+ const extra = Object.keys(process.env).filter(
831
+ (name) => /^(GH|GITHUB)_/.test(name) && process.env[name] && !known.has(name),
832
+ );
833
+ return [...named, ...extra];
834
+ }
835
+
836
+ function envValue(name) {
837
+ const value = process.env[name];
838
+ if (SECRET_NAME.test(name)) return value ? "set" : NOT_SET;
839
+ if (!value) return NOT_SET;
840
+ if (DOCTOR_ENV_PROXY.includes(name)) return proxySummary(value);
841
+ return value;
842
+ }
843
+
844
+ // gh writes some of this to stdout and some to stderr depending on version and
845
+ // on whether it succeeded, and a non-zero exit is itself worth reporting rather
846
+ // than throwing. Both streams, whatever happened.
847
+ async function captureGh(args) {
848
+ try {
849
+ return (await runGh(args)).trim();
850
+ } catch (err) {
851
+ const both = `${err?.stdout ?? ""}${err?.stderr ?? ""}`.trim();
852
+ return both || shortErr(err);
853
+ }
854
+ }
855
+
856
+ const PROBE_STDERR_LIMIT = 400;
857
+
858
+ async function probe(name, args) {
859
+ const startedAt = Date.now();
860
+ try {
861
+ const stdout = await runGh(args);
862
+ return { name, args, ms: Date.now() - startedAt, bytes: stdout.length, classified: "ok" };
863
+ } catch (err) {
864
+ return {
865
+ name,
866
+ args,
867
+ ms: Date.now() - startedAt,
868
+ failed: true,
869
+ // Bodies are never included, only sizes -- and a failing endpoint's stderr
870
+ // is the one place the verbatim tenant message can be captured, which is
871
+ // the reason this command exists.
872
+ stderr: String(err?.stderr ?? "").trim().slice(0, PROBE_STDERR_LIMIT),
873
+ http: /HTTP (\d{3})/.exec(errText(err))?.[1] ?? null,
874
+ classified: classify(err),
875
+ };
876
+ }
877
+ }
878
+
879
+ const DOCTOR_LABEL_WIDTH = 18;
880
+ const PROBE_LABEL_WIDTH = 12;
881
+
882
+ // The underline is derived rather than typed, so renaming a heading cannot
883
+ // leave a rule that is the wrong length underneath it.
884
+ function section(title) {
885
+ return [title, "-".repeat(title.length)];
886
+ }
887
+
888
+ // A label longer than its column (GH_GLANCE_NO_ANIMATION, GH_ENTERPRISE_TOKEN)
889
+ // would otherwise butt straight against its value with no separator at all,
890
+ // which is unreadable exactly where the report is being skim-read for a
891
+ // "set" / "not set".
892
+ function field(label, value, width = DOCTOR_LABEL_WIDTH) {
893
+ return label.length < width ? `${label.padEnd(width)}${value}` : `${label} ${value}`;
894
+ }
895
+
896
+ async function gitRemote() {
897
+ try {
898
+ const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
899
+ timeout: GH_TIMEOUT_MS,
900
+ });
901
+ return stdout.trim() || "(no origin remote)";
902
+ } catch {
903
+ return "not a git repository (or no origin remote)";
904
+ }
905
+ }
906
+
907
+ function targetSource() {
908
+ if (runtime.repo) return "flag";
909
+ if (process.env.GH_REPO) return "GH_REPO";
910
+ return "git remote (or none, if this is not a checkout)";
911
+ }
912
+
913
+ async function runDoctor() {
914
+ // A live backoff would make an alert probe silently skip and report nothing,
915
+ // which is the opposite of what a diagnostic run is for.
916
+ alertBackoff.clear();
917
+
918
+ const probes = [
919
+ ["Actions (run list)", actionsArgs(MIN_RUN_LIMIT)],
920
+ ["Issues (issue list)", issuesArgs()],
921
+ ["Pull requests (pr list)", prsArgs()],
922
+ ...ALERT_SOURCES.map((source) => [source.name, alertArgs(source)]),
923
+ ];
924
+
925
+ // allSettled, and every probe already resolves rather than rejects, so one
926
+ // slow endpoint cannot suppress the other five. runGh's own GH_TIMEOUT_MS
927
+ // bounds each one.
928
+ const [ghVersion, authStatus, remote, results] = await Promise.all([
929
+ captureGh(["--version"]),
930
+ captureGh(["auth", "status"]),
931
+ gitRemote(),
932
+ Promise.all(probes.map(([name, args]) => probe(name, args))),
933
+ ]);
934
+
935
+ const lines = [
936
+ "gh-glance doctor",
937
+ "================",
938
+ field("gh-glance", version),
939
+ field("node", `${process.version} ${process.platform}/${process.arch}`),
940
+ field("gh", ghVersion.split("\n")[0] || "NOT FOUND"),
941
+ "",
942
+ ...section("Authenticated hosts"),
943
+ authStatus || "(no output)",
944
+ "",
945
+ ...section("Repository target"),
946
+ field("source", targetSource()),
947
+ field("host", runtime.host ?? "(default -- gh infers it)"),
948
+ field("slug", runtime.repo ?? "(inferred from the working directory)"),
949
+ field("git remote", remote),
950
+ "",
951
+ ...section("Environment"),
952
+ ...doctorEnvNames().map((name) => field(name, envValue(name))),
953
+ "",
954
+ ...section("Endpoint probes"),
955
+ ];
956
+
957
+ const probeLine = (label, value) => lines.push(` ${field(label, value, PROBE_LABEL_WIDTH)}`);
958
+ for (const result of results) {
959
+ lines.push(` ${result.name}`);
960
+ probeLine("argv", `gh ${result.args.join(" ")}`);
961
+ probeLine(
962
+ "outcome",
963
+ result.failed ? `FAILED in ${result.ms}ms` : `ok ${result.bytes}B in ${result.ms}ms`,
964
+ );
965
+ if (result.http) probeLine("http", result.http);
966
+ probeLine("classified", result.classified);
967
+ if (result.stderr) probeLine("stderr", result.stderr);
968
+ lines.push("");
969
+ }
970
+
971
+ // The single redaction boundary. Everything above may have captured a token,
972
+ // a proxy credential or a URL with userinfo; nothing above is responsible for
973
+ // removing it.
974
+ return redact(lines.join("\n"));
975
+ }
976
+
653
977
  // ---------- Command line ----------
654
978
 
655
979
  // The repository name is the only user-supplied value that reaches a subprocess
@@ -660,6 +984,40 @@ async function preflight() {
660
984
  // an owner or repository name.
661
985
  const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
662
986
 
987
+ // A dot is mandatory, and that is the safety property rather than a nicety.
988
+ // Without it "owner/name/extra" -- already in the hostile-input list -- would
989
+ // stop being a rejected typo and quietly become "the repo name/extra on the
990
+ // host named owner", i.e. a slip of the finger turning into a request to
991
+ // somewhere else entirely. With it, every value in that list stays rejected.
992
+ // Labels may not start or end with a hyphen and the string may not have an
993
+ // empty, leading or trailing label, which is what rules out "-bad.host",
994
+ // "host..com" and "host.com.".
995
+ const HOST_PATTERN =
996
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
997
+
998
+ const repoMessage = (value) => `--repo must look like owner/name or host/owner/name, got: ${value}`;
999
+
1000
+ // `gh` itself accepts [HOST/]OWNER/REPO for --repo and GH_REPO; this accepts
1001
+ // the same shape and splits it into the two things that are used differently --
1002
+ // the slug, which is interpolated into a `gh api` path, and the host, which
1003
+ // never is. REPO_PATTERN keeps its exact meaning: it validates the owner/name
1004
+ // half, here as before.
1005
+ function parseRepoTarget(value) {
1006
+ const parts = String(value).split("/");
1007
+ let host = null;
1008
+ let slug;
1009
+ if (parts.length === 3 && HOST_PATTERN.test(parts[0])) {
1010
+ host = parts[0];
1011
+ slug = `${parts[1]}/${parts[2]}`;
1012
+ } else if (parts.length === 2) {
1013
+ slug = String(value);
1014
+ } else {
1015
+ throw new Error(repoMessage(value));
1016
+ }
1017
+ if (!REPO_PATTERN.test(slug)) throw new Error(repoMessage(value));
1018
+ return { host, slug };
1019
+ }
1020
+
663
1021
  // Below roughly two seconds a fetch cannot finish before the next tick, so the
664
1022
  // in-flight guard absorbs every other one and the effective rate is whatever
665
1023
  // `gh` can sustain -- the requested interval silently stops being real. Clamping
@@ -672,7 +1030,15 @@ const MAX_REFRESH_SECONDS = 3600;
672
1030
  // ignored -- so widening it keeps the same shape: every flag is named here, and
673
1031
  // anything else still exits 2.
674
1032
  function parseArgs(argv) {
675
- const opts = { help: false, showVersion: false, repo: null, refresh: null, tab: null, verbose: false };
1033
+ const opts = {
1034
+ help: false,
1035
+ showVersion: false,
1036
+ doctor: false,
1037
+ repo: null,
1038
+ refresh: null,
1039
+ tab: null,
1040
+ verbose: false,
1041
+ };
676
1042
  for (let i = 0; i < argv.length; i += 1) {
677
1043
  const arg = argv[i];
678
1044
  const takeValue = (name) => {
@@ -686,6 +1052,7 @@ function parseArgs(argv) {
686
1052
  if (arg === "--help" || arg === "-h") opts.help = true;
687
1053
  else if (arg === "--version" || arg === "-v") opts.showVersion = true;
688
1054
  else if (arg === "--verbose") opts.verbose = true;
1055
+ else if (arg === "--doctor") opts.doctor = true;
689
1056
  else if (arg === "--repo" || arg === "-R" || arg.startsWith("--repo=")) {
690
1057
  opts.repo = takeValue("--repo");
691
1058
  } else if (arg === "--refresh" || arg.startsWith("--refresh=")) {
@@ -702,9 +1069,7 @@ function parseArgs(argv) {
702
1069
  // Returns { repo, refreshMs, tabKey, verbose } or throws with a message that
703
1070
  // says what to do about it.
704
1071
  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
- }
1072
+ const { host, slug } = opts.repo !== null ? parseRepoTarget(opts.repo) : { host: null, slug: null };
708
1073
 
709
1074
  let refreshMs = null;
710
1075
  if (opts.refresh !== null) {
@@ -727,7 +1092,9 @@ function validateArgs(opts, tabKeys) {
727
1092
  return {
728
1093
  help: opts.help,
729
1094
  showVersion: opts.showVersion,
730
- repo: opts.repo,
1095
+ doctor: opts.doctor,
1096
+ repo: slug,
1097
+ host,
731
1098
  refreshMs,
732
1099
  tabKey: opts.tab,
733
1100
  verbose: opts.verbose,
@@ -746,12 +1113,18 @@ Usage:
746
1113
  gh-glance --refresh 15 Poll the active tab every 15 seconds
747
1114
  gh-glance --tab security Start on a specific tab
748
1115
  gh-glance --verbose 2>log Log every gh call to a file (see below)
1116
+ gh-glance --doctor Print a diagnostic report and exit
749
1117
  gh-glance --help Show this help
750
1118
  gh-glance --version Show the version
751
1119
 
752
1120
  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.
1121
+ -R, --repo [host/]owner/name
1122
+ Repository to watch. Without it the repo is inferred
1123
+ from the git remote, the same way \`gh\` does it. The
1124
+ host form targets a GitHub Enterprise or EMU
1125
+ data-residency tenant (e.g. tenant.ghe.com/acme/api)
1126
+ and is unnecessary when running inside a clone of
1127
+ that repository.
755
1128
  --refresh <seconds> Active-tab poll interval (${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS},
756
1129
  default ${REFRESH_MS / 1000}). Background tabs stay at
757
1130
  ${BACKGROUND_EVERY}x this.
@@ -760,6 +1133,11 @@ Options:
760
1133
  must be redirected -- writing it to the terminal
761
1134
  would draw over the dashboard, so this refuses to
762
1135
  start otherwise.
1136
+ --doctor Gather versions, authenticated hosts, the resolved
1137
+ repo target and one probe per endpoint, then exit.
1138
+ Safe to redirect to a file and share -- tokens are
1139
+ never printed, proxy credentials are stripped, and
1140
+ no response bodies are included.
763
1141
 
764
1142
  Run it from inside a locally cloned GitHub repository; the repo is inferred
765
1143
  from the git remote, the same way \`gh\` does it. Requires the \`gh\` CLI
@@ -783,6 +1161,9 @@ Keys:
783
1161
 
784
1162
  Environment:
785
1163
  GH_REPO=owner/name Watch a specific repository (--repo takes precedence)
1164
+ GH_HOST=host GitHub Enterprise or EMU host to send every call to.
1165
+ A host-qualified GH_REPO does not route \`gh api\`;
1166
+ this and --repo host/owner/name both do.
786
1167
  GH_GLANCE_ICONS=unicode Plain-unicode status icons (no Nerd Font needed)
787
1168
  GH_GLANCE_NO_ANIMATION=1 Freeze the spinner (no motion)
788
1169
  NO_COLOR=1 Disable colour (status stays readable)
@@ -818,6 +1199,19 @@ if (IS_MAIN) {
818
1199
  process.exit(0);
819
1200
  }
820
1201
 
1202
+ // A reporting command, like --help and --version: gather, print, exit. It sits
1203
+ // here on purpose -- ahead of the non-TTY refusal, because the whole point is
1204
+ // `gh-glance --doctor > report.txt`, and ahead of preflight(), because a
1205
+ // missing gh and a cwd outside a repository are exactly the conditions worth
1206
+ // reporting rather than exiting 3 over. The repo target is applied first so
1207
+ // the probes exercise the real one.
1208
+ if (opts.doctor) {
1209
+ runtime.repo = opts.repo;
1210
+ runtime.host = opts.host;
1211
+ console.log(await runDoctor());
1212
+ process.exit(0);
1213
+ }
1214
+
821
1215
  // Verbose output must never reach stdout -- that is ink's frame stream, and
822
1216
  // anything else in it corrupts the diff and the alternate-screen state. stderr
823
1217
  // is safe only once it is redirected somewhere; while it is still the
@@ -832,6 +1226,7 @@ if (IS_MAIN) {
832
1226
  }
833
1227
 
834
1228
  runtime.repo = opts.repo;
1229
+ runtime.host = opts.host;
835
1230
  runtime.verbose = opts.verbose;
836
1231
  if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
837
1232
  if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
@@ -1007,6 +1402,14 @@ function PanelEdge({ width, top, label, labelColor }) {
1007
1402
  );
1008
1403
  }
1009
1404
 
1405
+ // A plain rule under the tab bar, distinct from PanelEdge's corners so it
1406
+ // reads as a separator rather than another frame. Without it the tab labels
1407
+ // sat flush against the panel's top border -- readable, but dense enough that
1408
+ // the two rows scanned as one.
1409
+ function Divider({ width }) {
1410
+ return e(Text, { color: BORDER_COLOR, dimColor: true, "aria-hidden": true }, "─".repeat(Math.max(0, width)));
1411
+ }
1412
+
1010
1413
  // ---------- Error containment ----------
1011
1414
 
1012
1415
  // Every field on screen is defined by GitHub and will keep changing shape. With
@@ -1409,8 +1812,6 @@ const KEY_HINTS = [
1409
1812
  // Reserved so the hints don't shift sideways every time a refresh starts and
1410
1813
  // finishes. Wide enough for the spinner, a space and "Fetching".
1411
1814
  const FETCHING_WIDTH = 12;
1412
- // Likewise for the staleness slot, which is empty most of the time.
1413
- const STALE_WIDTH = 10;
1414
1815
 
1415
1816
  // Two tones rather than one flat gray: the keys you press are the part worth
1416
1817
  // finding at a glance, so they get the accent colour and the words describing
@@ -1439,11 +1840,13 @@ function StatusBar({ fetching, spin, stale, interactive }) {
1439
1840
  `${fetching && spin ? spin : SPINNER[0]} Fetching`,
1440
1841
  ),
1441
1842
  ),
1442
- e(
1443
- Box,
1444
- { width: STALE_WIDTH, flexShrink: 0 },
1445
- stale ? e(Text, { color: ATTENTION }, stale) : null,
1446
- ),
1843
+ // No reserved width here, unlike the fetching slot above: this only
1844
+ // toggles on a real problem (a stalled poll, a laptop that just woke up),
1845
+ // not every refresh cycle, so letting the hints shift on that rare event
1846
+ // is worth getting the column back for the other 99% of the time.
1847
+ stale
1848
+ ? e(Box, { marginRight: 1, flexShrink: 0 }, e(Text, { color: ATTENTION }, stale))
1849
+ : null,
1447
1850
  ...hints
1448
1851
  .flatMap((hint, i) => [
1449
1852
  i > 0 && e(Text, { key: `sep${i}`, color: BORDER_COLOR }, " | "),
@@ -1531,10 +1934,11 @@ function App() {
1531
1934
 
1532
1935
  const tab = TABS[activeIndex];
1533
1936
  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);
1937
+ // Reserve lines for: the tab bar and the divider under it (2), the panel's
1938
+ // top and bottom edges (2), the column header and its separator (2), and
1939
+ // the status line (1), plus a 1-line safety margin. Slack is absorbed by
1940
+ // the spacer in the tree below.
1941
+ const bodyRows = Math.max(1, rows - 8 - extraLines);
1538
1942
 
1539
1943
  // Read at fetch time rather than being a hook dependency, so dragging the
1540
1944
  // pane wider doesn't cancel and restart in-flight requests -- the next tick
@@ -1862,17 +2266,35 @@ function App() {
1862
2266
  ? `${tabOffset + 1}-${tabOffset + visibleItems.length} of ${counts[tab.key]}`
1863
2267
  : `${visibleItems.length} of ${counts[tab.key]}`;
1864
2268
 
2269
+ // One column short of the reported width, not the full width: some
2270
+ // terminals (observed in Ghostty, split-pane) clip or misrender whatever
2271
+ // glyph lands on the pane's absolute last column -- for this frame that is
2272
+ // always the right border. Stopping one column early costs a blank column
2273
+ // of slack but keeps the border visible everywhere, regardless of which
2274
+ // terminal is doing the clipping.
2275
+ const frameCols = Math.max(1, cols - 1);
2276
+
1865
2277
  // Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
1866
2278
  // at ordinary widths. Narrow panes drop columns instead, which keeps the
1867
2279
  // 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;
2280
+ // pushed all three off the bottom. Measured against frameCols, the width the
2281
+ // frame itself actually gets, not the raw terminal width.
2282
+ const compact = frameCols < MIN_TABLE_WIDTH;
1870
2283
  const header = compact ? tab.compactHeader : tab.header;
1871
2284
 
1872
2285
  return e(
1873
2286
  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 }),
2287
+ { flexDirection: "column", width: frameCols, height: Math.min(rows, usableSize(stdout?.rows, rows)) },
2288
+ e(TabBar, {
2289
+ activeIndex,
2290
+ counts,
2291
+ firstLoad,
2292
+ failed,
2293
+ spin,
2294
+ useShort: useShortLabels,
2295
+ }),
2296
+ e(Divider, { width: frameCols }),
2297
+ e(PanelEdge, { width: frameCols, top: true, label: tab.label, labelColor: TITLE_COLOR }),
1876
2298
  e(
1877
2299
  Box,
1878
2300
  {
@@ -1932,15 +2354,7 @@ function App() {
1932
2354
  // tab that only has a handful of rows.
1933
2355
  e(Box, { flexGrow: 1 }),
1934
2356
  ),
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
- }),
2357
+ e(PanelEdge, { width: frameCols, top: false, label: countLabel, labelColor: BORDER_COLOR }),
1944
2358
  e(StatusBar, {
1945
2359
  fetching: Object.values(loading).some(Boolean),
1946
2360
  spin: showSpinner ? spin : null,
@@ -2046,7 +2460,9 @@ if (IS_MAIN) {
2046
2460
  export {
2047
2461
  parseArgs,
2048
2462
  validateArgs,
2463
+ parseRepoTarget,
2049
2464
  REPO_PATTERN,
2465
+ HOST_PATTERN,
2050
2466
  MIN_REFRESH_SECONDS,
2051
2467
  MAX_REFRESH_SECONDS,
2052
2468
  TAB_KEYS,
@@ -2054,6 +2470,11 @@ export {
2054
2470
  shortErr,
2055
2471
  isUnavailable,
2056
2472
  isRateLimited,
2473
+ isAuthProblem,
2474
+ AUTH_RETRY_MS,
2475
+ BACKOFF_STEPS_MS,
2476
+ redact,
2477
+ classify,
2057
2478
  formatAge,
2058
2479
  formatDuration,
2059
2480
  usableSize,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-glance",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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
  }