gh-glance 0.3.1 → 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.
- package/CHANGELOG.md +83 -0
- package/README.md +94 -12
- package/index.mjs +514 -93
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,88 @@ 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
|
+
|
|
8
90
|
## [0.3.1] - 2026-08-04
|
|
9
91
|
|
|
10
92
|
First release published by CI rather than from a laptop, and the first carrying
|
|
@@ -227,6 +309,7 @@ engineering, security, QA and UX. What follows is what changed as a result.
|
|
|
227
309
|
- The `main` field from `package.json`. It advertised the file as importable,
|
|
228
310
|
but importing it took over the terminal or exited the host process.
|
|
229
311
|
|
|
312
|
+
[0.4.0]: https://github.com/juan294/gh-glance/releases/tag/v0.4.0
|
|
230
313
|
[0.3.1]: https://github.com/juan294/gh-glance/releases/tag/v0.3.1
|
|
231
314
|
[0.3.0]: https://github.com/juan294/gh-glance/releases/tag/v0.3.0
|
|
232
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
|
-
|
|
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
|
|
70
|
-
|
|
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
|
|
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))
|
|
@@ -200,10 +204,11 @@ pane definition. Flags are there when you want them:
|
|
|
200
204
|
|
|
201
205
|
| Flag | Effect |
|
|
202
206
|
|---|---|
|
|
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. |
|
|
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`. |
|
|
204
208
|
| `--refresh <seconds>` | Active-tab poll interval, 2-3600, default 5. Background tabs stay at 12x this. |
|
|
205
209
|
| `--tab <name>` | Start on `actions`, `issues`, `prs` or `security`. |
|
|
206
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). |
|
|
207
212
|
|
|
208
213
|
An unrecognised flag exits 2 rather than being ignored, so a typo fails loudly.
|
|
209
214
|
|
|
@@ -211,19 +216,89 @@ Environment variables work too, and the flags take precedence:
|
|
|
211
216
|
|
|
212
217
|
| Variable | Effect |
|
|
213
218
|
|---|---|
|
|
214
|
-
| `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 |
|
|
215
222
|
| `GH_GLANCE_ICONS=unicode` | Plain ASCII status icons, for terminals without a Nerd Font |
|
|
216
223
|
| `GH_GLANCE_NO_ANIMATION=1` | Freeze the spinner — no motion at all |
|
|
217
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 |
|
|
218
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. |
|
|
219
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
|
+
|
|
220
282
|
## Rate limit
|
|
221
283
|
|
|
222
284
|
The visible tab refreshes every 5 seconds; the other three refresh every 60
|
|
223
|
-
seconds, purely to keep their counts honest.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
227
302
|
|
|
228
303
|
## Limitations
|
|
229
304
|
|
|
@@ -253,6 +328,10 @@ commands share.
|
|
|
253
328
|
- macOS/Linux terminals with ANSI + alternate-screen-buffer support. Not
|
|
254
329
|
tested on Windows -- `npm` will not stop you installing it there, but the
|
|
255
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`.
|
|
256
335
|
|
|
257
336
|
### Icons without a Nerd Font
|
|
258
337
|
|
|
@@ -278,7 +357,10 @@ in some terminals, which would shift every column to their right.
|
|
|
278
357
|
| `the gh CLI is not installed` | Install it from [cli.github.com](https://cli.github.com), then `gh auth login`. |
|
|
279
358
|
| `not inside a git repository` | Run it from a cloned GitHub repository, or set `GH_REPO=owner/name`. |
|
|
280
359
|
| 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.
|
|
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. |
|
|
282
364
|
| A tab's count is red | That tab's last fetch failed. The error itself is shown when you switch to it. |
|
|
283
365
|
| `stale 2m` in the status bar | The visible tab has not refreshed successfully for a while — usually a network drop. |
|
|
284
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
|
-
|
|
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(
|
|
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(
|
|
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",
|
|
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
|
-
|
|
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,
|
|
511
|
-
alertBackoff.set(key, { step, until: now +
|
|
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(
|
|
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
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
const
|
|
543
|
-
const note = unavailable ? source.unavailable : `${source.name}: ${shortErr(err)}`;
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
611
|
-
if (!kind ||
|
|
612
|
-
await runGh([kind, "view", ...repoArgs(), String(
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
754
|
-
|
|
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
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
|
1535
|
-
// and
|
|
1536
|
-
// 1-line safety margin. Slack is absorbed by
|
|
1537
|
-
|
|
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
|
-
|
|
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:
|
|
1875
|
-
e(
|
|
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:
|
|
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
|
+
"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": "^
|
|
54
|
+
"globals": "^17.8.0",
|
|
55
55
|
"husky": "^9.1.7"
|
|
56
56
|
}
|
|
57
57
|
}
|