gh-glance 0.5.1 → 0.6.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 CHANGED
@@ -5,6 +5,32 @@ 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
+ ## [Unreleased]
9
+
10
+ ## [0.6.0] - 2026-08-06
11
+
12
+ ### Added
13
+
14
+ - **An interactive missing-remote onboarding prompt.** Starting gh-glance in a
15
+ local repository with no remotes now offers to hand off to the user-confirmed
16
+ interactive `gh repo create` flow, while keeping quit and explicit `--repo`
17
+ paths visible. Declining the prompt makes no repository change.
18
+ - **Failure-triggered account and repository context.** After an ambiguous
19
+ repository-access failure, gh-glance performs read-only active-account and
20
+ repository checks through `gh`, and `--doctor` now includes a `Repository
21
+ access` probe.
22
+
23
+ ### Fixed
24
+
25
+ - **Clean no-login failures now explain the delegated authentication path.**
26
+ Previously these were unclassified raw `gh` errors. They now direct the user
27
+ to login or authorization evidence through their own `gh` configuration and
28
+ remain retryable.
29
+ - **Inaccessible repository errors now converge on honest wording.** Actions'
30
+ REST 404 and Issues' GraphQL repository-resolution failure described the same
31
+ inaccessible target through unrelated messages. List tabs now say “not found
32
+ or inaccessible” without claiming whether the target is missing or private.
33
+
8
34
  ## [0.5.1] - 2026-08-04
9
35
 
10
36
  ### Changed
@@ -531,6 +557,7 @@ engineering, security, QA and UX. What follows is what changed as a result.
531
557
  - The `main` field from `package.json`. It advertised the file as importable,
532
558
  but importing it took over the terminal or exited the host process.
533
559
 
560
+ [0.6.0]: https://github.com/juan294/gh-glance/releases/tag/v0.6.0
534
561
  [0.5.1]: https://github.com/juan294/gh-glance/releases/tag/v0.5.1
535
562
  [0.5.0]: https://github.com/juan294/gh-glance/releases/tag/v0.5.0
536
563
  [0.4.1]: https://github.com/juan294/gh-glance/releases/tag/v0.4.1
package/README.md CHANGED
@@ -121,6 +121,41 @@ the tarball back to the workflow run and commit that produced it. The
121
121
  "Provenance" section on the [package
122
122
  page](https://www.npmjs.com/package/gh-glance) shows the exact source.
123
123
 
124
+ ### First run
125
+
126
+ `gh-glance` uses the credentials and active account already selected by your
127
+ local `gh` CLI. It does not request, inspect, or store the token itself. Check
128
+ that account before starting the dashboard:
129
+
130
+ ```bash
131
+ gh auth status
132
+
133
+ # Only when no account is active:
134
+ gh auth login
135
+
136
+ gh-glance
137
+ ```
138
+
139
+ Cloning over SSH or HTTPS and authenticating `gh` are separate credential
140
+ paths, so a successful clone does not prove that the active `gh` account can
141
+ access the repository through the API. For a non-default host, authenticate it
142
+ explicitly with `gh auth login --hostname <host>`.
143
+
144
+ If several accounts are authenticated on the same host, inspect and select the
145
+ one you intend to use:
146
+
147
+ ```bash
148
+ gh auth status
149
+ gh auth switch --hostname github.com --user <login>
150
+ ```
151
+
152
+ `gh auth switch` is an explicit user command, not a gh-glance action. It changes
153
+ the active account globally for that `gh` configuration. If `gh auth switch
154
+ --help` is unavailable, update `gh` to use this optional multi-account workflow;
155
+ the core gh-glance minimum remains unchanged. To keep simultaneous panes on
156
+ different accounts, use separate `GH_CONFIG_DIR` values as described under
157
+ [No account pinning](#limitations).
158
+
124
159
  ### From a clone
125
160
 
126
161
  For hacking on it, or to run an unreleased revision:
@@ -193,7 +228,7 @@ stdout isn't a terminal rather than streaming redraw frames into a pipe.
193
228
  | `Tab` / `Shift+Tab` | Next / previous tab |
194
229
  | `↑` / `↓` or `j` / `k` | Move the cursor between rows |
195
230
  | `PgUp` / `PgDn` | Move a page at a time |
196
- | `Enter` | Open the selected item in your browser |
231
+ | `Enter` | Open the selected item, or accept an onboarding prompt |
197
232
  | `r` | Refresh the current tab now |
198
233
  | `?` | Show the keys without leaving the dashboard (any key closes it) |
199
234
  | `q` / `Esc` / `Ctrl+C` | Quit |
@@ -205,6 +240,14 @@ itself after 60s with no cursor movement, so a pane left idle in the corner of
205
240
  a screen doesn't sit there marked forever -- and the next arrow key picks up
206
241
  from what is on screen rather than jumping back to the top of the list.
207
242
 
243
+ If the current local repository has no GitHub remote, gh-glance shows a setup
244
+ prompt instead of raw `gh` stderr. Press `Enter` to leave the dashboard and run
245
+ the interactive `gh repo create` flow, then choose **Push an existing local
246
+ repository**. Plain `gh repo create` is intentional: adding `--source` would
247
+ switch `gh` into non-interactive mode and require choosing a visibility in
248
+ advance. Press `q` or `Esc` to decline. To watch an existing repository without
249
+ attaching this folder, restart with `gh-glance --repo owner/name`.
250
+
208
251
  When the repository was chosen explicitly, with `--repo` or `GH_REPO`, the panel
209
252
  says so: `╭─ Actions · acme/widget ─`. That is what tells two side-by-side panes
210
253
  apart. It is dropped before the tab name when the pane is too narrow for both.
@@ -234,7 +277,7 @@ Environment variables work too, and the flags take precedence:
234
277
  |---|---|
235
278
  | `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 |
236
279
  | `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 |
237
- | `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 |
280
+ | `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. Separate `GH_CONFIG_DIR` values can isolate simultaneous panes on different accounts; see [No account pinning](#limitations) |
238
281
  | `GH_GLANCE_ICONS=unicode` | Plain ASCII status icons, for terminals without a Nerd Font |
239
282
  | `GH_GLANCE_NO_ANIMATION=1` | Freeze the spinner — no motion at all |
240
283
  | `NO_COLOR=1` | Disable colour. Status stays readable: severity has its own column, a failing newest run puts a `!` on the Actions tab, and the active tab is bracketed. Note that run-state glyphs are not all distinct -- see the feature list above |
@@ -282,9 +325,15 @@ gh-glance --doctor > report.txt
282
325
  Collects, in one plain-text block: the `gh-glance`, Node and `gh` versions;
283
326
  which hosts `gh` is authenticated for; how the repository target resolved and
284
327
  from where; your remaining REST and GraphQL budget plus what this configuration
285
- will spend per hour; the relevant environment variables; and one probe per
286
- endpoint with the exact argv it sent, its outcome, and how any error was
287
- classified (`unavailable`, `rate-limited`, `auth-problem` or `other`).
328
+ will spend per hour; the relevant environment variables; a read-only
329
+ `Repository access` probe; and one probe per dashboard endpoint with the exact
330
+ argv it sent, its outcome, and how any error was classified (`unavailable`,
331
+ `rate-limited`, `auth-problem` or `other`).
332
+
333
+ The `Repository access` probe shows whether the target resolves for the active
334
+ `gh` credentials. A failed GitHub resolution response cannot distinguish a
335
+ nonexistent, renamed, or stale target from a private repository hidden from
336
+ that identity.
288
337
 
289
338
  Add `--verbose` to get a log of every `gh` call it makes alongside the report.
290
339
 
@@ -401,6 +450,11 @@ in some terminals, which would shift every column to their right.
401
450
  |---|---|
402
451
  | `the gh CLI is not installed` | Install it from [cli.github.com](https://cli.github.com), then `gh auth login`. |
403
452
  | `not inside a git repository` | Run it from a cloned GitHub repository, or set `GH_REPO=owner/name`. |
453
+ | `No GitHub remote found` | Press `Enter` to start `gh repo create`, then choose **Push an existing local repository**. Or press `q` and run `gh-glance --repo owner/name` to watch an existing repository without attaching this folder. |
454
+ | `GitHub login or authorization required` | Run `gh auth status`. With no account, run `gh auth login`; with an expired authorization, run `gh auth refresh`; then press `r`. |
455
+ | `Repository not found or inaccessible to the active gh account` | The active identity cannot resolve the target. Check `gh auth status`, `git remote -v` or the explicit `--repo`, and use `gh auth switch` only if the wrong account is active. |
456
+ | `GraphQL: Could not resolve to a Repository...` in older gh-glance versions | The response has the same ambiguity: a missing or renamed target, or a private repository not visible to the active account. Run `gh-glance --doctor`. |
457
+ | Actions says `not available for this repository` while Repository access is `ok` | The repository resolved, but that endpoint is unavailable; inspect the corresponding doctor block. |
404
458
  | Status icons are blank boxes | Your terminal font is not a Nerd Font. Use `GH_GLANCE_ICONS=unicode`. |
405
459
  | 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. |
406
460
  | `none of the git remotes ... point to a known GitHub host` | `gh` is not authenticated for that host. Run `gh auth login --hostname <host>`. |
package/index.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  // disarmDevBuildLeak() for how that path is kept survivable rather than fatal.
23
23
  process.env.NODE_ENV ??= "production";
24
24
 
25
- import { execFile } from "node:child_process";
25
+ import { execFile, spawn } from "node:child_process";
26
26
  import { readFileSync, realpathSync } from "node:fs";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { promisify } from "node:util";
@@ -334,7 +334,11 @@ function errText(err) {
334
334
  // DNS, a 502) is a real failure that used to be reported as a confident,
335
335
  // plausible, and wrong claim that the feature was switched off.
336
336
  function isUnavailable(err) {
337
- return /HTTP (403|404)/.test(errText(err));
337
+ const text = errText(err);
338
+ return (
339
+ /HTTP (403|404)/.test(text) ||
340
+ /Could not resolve to a Repository with the name .* \(repository\)/i.test(text)
341
+ );
338
342
  }
339
343
 
340
344
  function isRateLimited(err) {
@@ -359,12 +363,20 @@ function isRateLimited(err) {
359
363
  // entirely in the positive. It stays clear of the genuine not-enabled messages,
360
364
  // which talk about features rather than access.
361
365
  const AUTH_MARKERS =
362
- /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;
366
+ /SAML|single[- ]sign[- ]on|\bSSO\b|must grant|not authoriz|unauthoriz|access restriction|Bad credentials|requires authentication|re-?authoriz|token .*scope|missing .*scope|insufficient|not logged into any GitHub hosts|To get started with GitHub CLI|run: gh auth login|none of the git remotes[\s\S]*known GitHub host/i;
363
367
 
364
368
  function isAuthProblem(err) {
365
369
  return AUTH_MARKERS.test(errText(err));
366
370
  }
367
371
 
372
+ // A local repository with no remote is an onboarding state, not an API or
373
+ // authentication failure. Both strings are emitted by current gh commands:
374
+ // list commands include the "failed to determine base repo" prefix, while
375
+ // `gh repo view` returns the shorter form.
376
+ function isMissingRemote(err) {
377
+ return /(?:failed to determine base repo:\s*)?no git remotes found/i.test(errText(err));
378
+ }
379
+
368
380
  // The one place the three predicates are turned into a verdict. Both consumers
369
381
  // go through it -- the fetcher, to choose a note and a backoff ladder, and
370
382
  // `--doctor`, to report what gh-glance concluded -- so the report cannot claim
@@ -380,6 +392,7 @@ function isAuthProblem(err) {
380
392
  // repository's configuration.
381
393
  function classify(err) {
382
394
  if (err == null) return "ok";
395
+ if (isMissingRemote(err)) return "no-remote";
383
396
  if (isRateLimited(err)) return "rate-limited";
384
397
  if (isAuthProblem(err)) return "auth-problem";
385
398
  if (isUnavailable(err)) return "unavailable";
@@ -401,11 +414,77 @@ function classify(err) {
401
414
  // positive that only picked a retry ladder was cheap, whereas one that also
402
415
  // rewrites the on-screen text turns into a confidently wrong instruction.
403
416
  const VERDICT_REMEDY = {
404
- "auth-problem": "GitHub authorization failed -- try `gh auth login` or `gh auth refresh`",
417
+ "no-remote":
418
+ "No GitHub remote found -- press Enter to create one, or use `gh-glance --repo owner/name`",
419
+ "auth-problem":
420
+ "GitHub login or authorization required -- run `gh auth status`, then `gh auth login` or `gh auth refresh`",
405
421
  "rate-limited": "GitHub rate limit reached -- backing off, this clears on its own",
406
- unavailable: "not available for this repository",
422
+ unavailable:
423
+ "Repository not found or inaccessible to the active `gh` account -- check `gh auth status` and the repository target",
407
424
  };
408
425
 
426
+ function toTabError(err) {
427
+ return { kind: "fetch", verdict: classify(err), raw: shortErr(err) };
428
+ }
429
+
430
+ function textTabError(err) {
431
+ return { kind: "text", text: shortErr(err) };
432
+ }
433
+
434
+ function failureTargetHost({ runtimeHost, ghHost, ghRepo, accounts } = {}) {
435
+ let ghRepoHost = null;
436
+ if (ghRepo) {
437
+ try {
438
+ ghRepoHost = parseRepoTarget(ghRepo).host;
439
+ } catch {
440
+ // GH_REPO is external process state, not validated argv. An invalid value
441
+ // is not evidence about which host an unavailable repository targeted.
442
+ }
443
+ }
444
+
445
+ const accountHosts = Array.isArray(accounts)
446
+ ? accounts
447
+ .map((account) => (typeof account?.host === "string" ? safe(account.host) : ""))
448
+ .filter(Boolean)
449
+ : [];
450
+ const distinctHosts = new Map(accountHosts.map((host) => [host.toLowerCase(), host]));
451
+ const soleAccountHost = distinctHosts.size === 1 ? distinctHosts.values().next().value : null;
452
+
453
+ return runtimeHost || ghHost || ghRepoHost || soleAccountHost || null;
454
+ }
455
+
456
+ function unavailableRemedy(accounts, targetHost) {
457
+ if (!Array.isArray(accounts) || !targetHost) return VERDICT_REMEDY.unavailable;
458
+ const normalizedTarget = String(targetHost).toLowerCase();
459
+ let matching = null;
460
+ for (const account of accounts) {
461
+ if (typeof account?.host !== "string" || typeof account?.login !== "string") continue;
462
+ const host = safe(account.host);
463
+ const login = safe(account.login);
464
+ if (!host || !login || host.toLowerCase() !== normalizedTarget) continue;
465
+ if (matching !== null) return VERDICT_REMEDY.unavailable;
466
+ matching = { host, login };
467
+ }
468
+ if (matching === null) return VERDICT_REMEDY.unavailable;
469
+
470
+ const { host, login } = matching;
471
+ const candidate = `Repository not found or inaccessible to ${login}@${host} -- check the target or run \`gh auth switch\``;
472
+ return candidate.length <= MAX_ERR_LENGTH ? candidate : VERDICT_REMEDY.unavailable;
473
+ }
474
+
475
+ function formatTabError(error, failureContext = null) {
476
+ if (error == null) return null;
477
+ if (error.kind === "text") return error.text;
478
+ if (error.verdict === "other") return error.raw;
479
+ if (error.verdict === "unavailable" && failureContext?.repo?.ok) {
480
+ return "not available for this repository";
481
+ }
482
+ if (error.verdict === "unavailable") {
483
+ return unavailableRemedy(failureContext?.accounts, failureContext?.targetHost);
484
+ }
485
+ return pick(VERDICT_REMEDY, error.verdict, null) ?? error.raw;
486
+ }
487
+
409
488
  // Some pty wrappers (and a terminal mid-resize) report a size of 0 or
410
489
  // undefined. Taking that literally collapses the table to a single row (or
411
490
  // draws a zero-width border), so fall back to sane defaults until real
@@ -478,6 +557,7 @@ function logGh(args, startedAt, outcome) {
478
557
  // outside React entirely -- there is no component left to read a ref from. Kept
479
558
  // in this section, above every consumer, so nothing has to reach downward for it.
480
559
  let liveAbort = null;
560
+ let setupChild = null;
481
561
  function registerLiveAbort(controller) {
482
562
  liveAbort = controller;
483
563
  }
@@ -486,6 +566,13 @@ function abortLiveRequests() {
486
566
  liveAbort = null;
487
567
  }
488
568
 
569
+ function forwardSignalToChild(child, signal) {
570
+ // child.killed means only that kill() was called successfully; it becomes
571
+ // true before the process exits and therefore cannot gate SIGKILL escalation.
572
+ if (!child || child.exitCode !== null || child.signalCode !== null) return false;
573
+ return child.kill(signal);
574
+ }
575
+
489
576
  async function runGh(args, { signal } = {}) {
490
577
  const startedAt = Date.now();
491
578
  try {
@@ -542,6 +629,146 @@ function apiPath(path) {
542
629
  // inline, because `--doctor` reports these vectors and a second copy of them
543
630
  // would be a report that drifts away from what the dashboard actually sends --
544
631
  // which is the failure mode the whole diagnostics command exists to rule out.
632
+ function repoContextArgs() {
633
+ const target = runtime.repo ? qualifiedRepo() : null;
634
+ return [
635
+ "repo",
636
+ "view",
637
+ ...(target ? [target] : []),
638
+ "--json",
639
+ "nameWithOwner,url,viewerPermission",
640
+ ];
641
+ }
642
+
643
+ function authContextArgs() {
644
+ return [
645
+ "auth",
646
+ "status",
647
+ "--active",
648
+ "--json",
649
+ "hosts",
650
+ "--jq",
651
+ ".hosts | to_entries | map(.key as $host | .value[] | select(.active == true) | {host: $host, login: .login})",
652
+ ];
653
+ }
654
+
655
+ function parseRepoContext(raw) {
656
+ try {
657
+ const value = JSON.parse(raw);
658
+ if (
659
+ value == null ||
660
+ Array.isArray(value) ||
661
+ typeof value !== "object" ||
662
+ typeof value.nameWithOwner !== "string" ||
663
+ typeof value.url !== "string" ||
664
+ typeof value.viewerPermission !== "string"
665
+ ) {
666
+ return null;
667
+ }
668
+ return {
669
+ ok: true,
670
+ nameWithOwner: safe(value.nameWithOwner),
671
+ url: safe(value.url),
672
+ viewerPermission: safe(value.viewerPermission),
673
+ };
674
+ } catch {
675
+ return null;
676
+ }
677
+ }
678
+
679
+ function parseAuthContext(raw) {
680
+ try {
681
+ const value = JSON.parse(raw);
682
+ if (!Array.isArray(value)) return null;
683
+ return value
684
+ .filter(
685
+ (row) =>
686
+ row != null &&
687
+ !Array.isArray(row) &&
688
+ typeof row === "object" &&
689
+ typeof row.host === "string" &&
690
+ typeof row.login === "string",
691
+ )
692
+ .map((row) => ({ host: safe(row.host), login: safe(row.login) }));
693
+ } catch {
694
+ return null;
695
+ }
696
+ }
697
+
698
+ function failedContext(err) {
699
+ return { ok: false, verdict: classify(err), raw: shortErr(err) };
700
+ }
701
+
702
+ function missingFailureContext() {
703
+ return {
704
+ repo: { ok: false, verdict: "other", raw: "Repository context unavailable" },
705
+ accounts: null,
706
+ };
707
+ }
708
+
709
+ function buildFailureContext(repoSettlement, authSettlement) {
710
+ const parsedRepo =
711
+ repoSettlement?.status === "fulfilled" ? parseRepoContext(repoSettlement.value) : null;
712
+ const repo =
713
+ parsedRepo ??
714
+ (repoSettlement?.status === "rejected"
715
+ ? failedContext(repoSettlement.reason)
716
+ : missingFailureContext().repo);
717
+ const accounts =
718
+ authSettlement?.status === "fulfilled" ? parseAuthContext(authSettlement.value) : null;
719
+ return { repo, accounts };
720
+ }
721
+
722
+ async function resolveFailureContext(signal) {
723
+ const [repo, auth] = await Promise.allSettled([
724
+ runGh(repoContextArgs(), { signal }),
725
+ runGh(authContextArgs(), { signal }),
726
+ ]);
727
+ return buildFailureContext(repo, auth);
728
+ }
729
+
730
+ function createFailureContextCoordinator({ resolve, commit, fallback }) {
731
+ let epoch = 0;
732
+ let value = null;
733
+ let inFlight = null;
734
+
735
+ function ensure(signal) {
736
+ const captured = epoch;
737
+ if (value !== null) return Promise.resolve(true);
738
+ if (inFlight?.epoch === captured) return inFlight.promise;
739
+
740
+ const promise = Promise.resolve()
741
+ .then(() => resolve(signal))
742
+ .then(
743
+ (result) => {
744
+ if (epoch !== captured) return false;
745
+ commit(result);
746
+ value = result;
747
+ return true;
748
+ },
749
+ () => {
750
+ if (epoch !== captured) return false;
751
+ commit(fallback);
752
+ value = fallback;
753
+ return false;
754
+ },
755
+ )
756
+ .finally(() => {
757
+ if (inFlight?.epoch === captured) inFlight = null;
758
+ });
759
+ inFlight = { epoch: captured, promise };
760
+ return promise;
761
+ }
762
+
763
+ function invalidate() {
764
+ epoch += 1;
765
+ value = null;
766
+ commit(null);
767
+ }
768
+
769
+ return { ensure, invalidate };
770
+ }
771
+
545
772
  function actionsArgs(limit) {
546
773
  return [
547
774
  "run",
@@ -731,6 +958,7 @@ function backoffActive(key, now) {
731
958
  // comes back the moment it does. "rate-limited" used to be absent for the same
732
959
  // stated reason, which had it backwards -- see RATE_LIMIT_RETRY_MS.
733
960
  const FAILURE_LADDER = {
961
+ "no-remote": BACKOFF_STEPS_MS,
734
962
  unavailable: BACKOFF_STEPS_MS,
735
963
  "auth-problem": AUTH_RETRY_MS,
736
964
  "rate-limited": RATE_LIMIT_RETRY_MS,
@@ -1114,6 +1342,7 @@ async function runDoctor() {
1114
1342
  alertBackoff.clear();
1115
1343
 
1116
1344
  const probes = [
1345
+ ["Repository access", repoContextArgs()],
1117
1346
  ["Actions (run list)", actionsArgs(MIN_RUN_LIMIT)],
1118
1347
  ["Issues (issue list)", issuesArgs()],
1119
1348
  ["Pull requests (pr list)", prsArgs()],
@@ -1121,7 +1350,7 @@ async function runDoctor() {
1121
1350
  ];
1122
1351
 
1123
1352
  // allSettled, and every probe already resolves rather than rejects, so one
1124
- // slow endpoint cannot suppress the other five. runGh's own GH_TIMEOUT_MS
1353
+ // slow endpoint cannot suppress the other six. runGh's own GH_TIMEOUT_MS
1125
1354
  // bounds each one.
1126
1355
  const [ghVersion, authStatus, remote, budget, results] = await Promise.all([
1127
1356
  captureGh(["--version"]),
@@ -1343,7 +1572,7 @@ const KEY_TABLE = [
1343
1572
  ["Tab / Shift+Tab", "Next / previous tab"],
1344
1573
  ["Up / Down, j / k", "Move the cursor between rows"],
1345
1574
  ["PgUp / PgDn", "Move a page at a time"],
1346
- ["Enter", "Open the selected item in your browser"],
1575
+ ["Enter", "Open the selected item or accept a prompt"],
1347
1576
  ["r", "Refresh the current tab now"],
1348
1577
  ["?", "Show the keys (any key closes it)"],
1349
1578
  ["q / Esc / Ctrl+C", "Quit"],
@@ -2081,17 +2310,29 @@ const KEY_HINTS = [
2081
2310
  { label: "Quit", keys: "q" },
2082
2311
  ];
2083
2312
 
2313
+ const REMOTE_SETUP_HINTS = [
2314
+ { label: "Create remote", keys: "Ent" },
2315
+ { label: "Quit", keys: "q" },
2316
+ ];
2317
+
2318
+ const REMOTE_SETUP_LINES = [
2319
+ "No GitHub remote found",
2320
+ "gh-glance needs a GitHub repository to show this dashboard.",
2321
+ "Enter Start `gh repo create` (choose Push an existing local repository)",
2322
+ "q/Esc Quit, or restart with `gh-glance --repo owner/name`",
2323
+ ];
2324
+
2325
+ const REMOTE_SETUP_NONINTERACTIVE_LINES = [
2326
+ "No GitHub remote found",
2327
+ "Run `gh repo create` in an interactive terminal.",
2328
+ "Choose Push an existing local repository.",
2329
+ "Or use `gh-glance --repo owner/name`; Ctrl+C quits.",
2330
+ ];
2331
+
2084
2332
  // Reserved so the hints don't shift sideways every time a refresh starts and
2085
2333
  // finishes. Wide enough for the spinner, a space and "Fetching".
2086
2334
  const FETCHING_WIDTH = 12;
2087
2335
 
2088
- // Width the full hint set needs: every "Label: keys" plus a " | " between each.
2089
- // Derived from KEY_HINTS rather than written down, for the same reason
2090
- // minimumWidthFor() derives the table breakpoint from its header descriptors --
2091
- // a copy change must not be able to invalidate the number.
2092
- const HINTS_FULL_WIDTH =
2093
- KEY_HINTS.reduce((sum, h) => sum + h.label.length + 2 + [...h.keys].length, 0) +
2094
- (KEY_HINTS.length - 1) * 3;
2095
2336
  // Compact drops the labels and keeps the keys, separated by a space:
2096
2337
  // "↑↓ Ent r q". No constant for its width -- nothing branches on it, and the
2097
2338
  // keys are short enough that it fits anywhere the frame itself does.
@@ -2104,22 +2345,24 @@ const HINTS_FULL_WIDTH =
2104
2345
  // bar read "Move: | Open: | Refresh |Quit:…" -- the arrows gone, Refresh
2105
2346
  // missing its key, and Quit, the last entry, first to be cut. That is the one
2106
2347
  // hint a confused first-time user needs, in a full-screen alternate-screen app.
2107
- const STATUS_FULL_WIDTH = FETCHING_WIDTH + HINTS_FULL_WIDTH;
2108
-
2109
2348
  // Two tones rather than one flat gray: the keys you press are the part worth
2110
2349
  // finding at a glance, so they get the accent colour and the words describing
2111
2350
  // them stay dim. The accent is the panel-title cyan rather than the amber used
2112
2351
  // for in-progress status, so amber means exactly one thing across the product.
2113
- function StatusBar({ fetching, spin, stale, interactive, cols }) {
2352
+ function StatusBar({ fetching, spin, stale, interactive, cols, remoteSetup = false }) {
2114
2353
  // Without raw mode none of the key handlers run, so advertising them would be
2115
2354
  // telling the user something untrue about what the app can do. Ctrl+C still
2116
2355
  // works there, because the tty delivers a real SIGINT.
2117
2356
  const hints = interactive
2118
- ? KEY_HINTS
2357
+ ? remoteSetup
2358
+ ? REMOTE_SETUP_HINTS
2359
+ : KEY_HINTS
2119
2360
  : [{ label: "Quit", keys: "^C" }];
2120
- // Measured against the *full* set even when a compact one is rendered, so the
2121
- // breakpoint does not move as the bar's own contents change.
2122
- const compact = interactive && cols < STATUS_FULL_WIDTH;
2361
+ const hintsFullWidth =
2362
+ hints.reduce((sum, hint) => sum + hint.label.length + 2 + [...hint.keys].length, 0) +
2363
+ (hints.length - 1) * 3;
2364
+ // Measured against the active full set even when a compact one is rendered.
2365
+ const compact = interactive && cols < FETCHING_WIDTH + hintsFullWidth;
2123
2366
  return e(
2124
2367
  Box,
2125
2368
  { flexDirection: "row" },
@@ -2133,7 +2376,7 @@ function StatusBar({ fetching, spin, stale, interactive, cols }) {
2133
2376
  e(
2134
2377
  Text,
2135
2378
  { color: fetching ? TITLE_COLOR : undefined, dimColor: !fetching },
2136
- `${fetching && spin ? spin : SPINNER[0]} Fetching`,
2379
+ remoteSetup ? `${SPINNER[0]} Setup` : `${fetching && spin ? spin : SPINNER[0]} Fetching`,
2137
2380
  ),
2138
2381
  ),
2139
2382
  // No reserved width here, unlike the fetching slot above: this only
@@ -2203,7 +2446,7 @@ function useTerminalSize(stdout) {
2203
2446
 
2204
2447
  // ---------- App ----------
2205
2448
 
2206
- function App() {
2449
+ function App({ onCreateRemote = () => {} } = {}) {
2207
2450
  const { stdout } = useStdout();
2208
2451
  const { isRawModeSupported } = useStdin();
2209
2452
  const { exit } = useApp();
@@ -2229,6 +2472,7 @@ function App() {
2229
2472
  // clearing it would cost a second state write for no visible difference.
2230
2473
  const [iconHintDue, setIconHintDue] = useState(false);
2231
2474
  const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
2475
+ const [failureContext, setFailureContext] = useState(null);
2232
2476
  const [loading, setLoading] = useState({ actions: true, issues: true, prs: true, security: true });
2233
2477
  const [now, setNow] = useState(new Date());
2234
2478
  const { rows, cols, useShortLabels } = useTerminalSize(stdout);
@@ -2240,6 +2484,14 @@ function App() {
2240
2484
  const [offset, setOffset] = useState({});
2241
2485
 
2242
2486
  const tab = TABS[activeIndex];
2487
+ const tabError = errors[tab.key];
2488
+ // Once any endpoint proves the folder has no remote, the whole dashboard is
2489
+ // in setup mode. Keeping this tab-local made a quick tab switch replace the
2490
+ // onboarding prompt with a second raw fetch failure while another command
2491
+ // was still settling.
2492
+ const remoteSetup = Object.values(errors).some(
2493
+ (error) => error?.kind === "fetch" && error.verdict === "no-remote",
2494
+ );
2243
2495
  // The not-enabled notes are collapsed into a single line. Each unavailable
2244
2496
  // alert source used to contribute a full-width row above the column header,
2245
2497
  // and on any repo without Advanced Security that is two permanent lines --
@@ -2262,7 +2514,8 @@ function App() {
2262
2514
  // Counts the lines actually rendered, not the notes collected -- getting this
2263
2515
  // wrong by one row is what makes ink repaint the whole frame.
2264
2516
  const extraLines =
2265
- (errors[tab.key] ? 1 : 0) + (tab.key === "security" ? securityLines.length : 0);
2517
+ (tabError && !remoteSetup ? 1 : 0) +
2518
+ (tab.key === "security" && !remoteSetup ? securityLines.length : 0);
2266
2519
  // Reserve lines for: the tab bar and the divider under it (2), the panel's
2267
2520
  // top and bottom edges (2), the column header and its separator (2), and
2268
2521
  // the status line (1), plus a 1-line safety margin. Slack is absorbed by
@@ -2300,6 +2553,7 @@ function App() {
2300
2553
  // persistently failing tab would report itself fresh forever.
2301
2554
  const lastOkRef = useRef({});
2302
2555
  const fetchTabRef = useRef(null);
2556
+ const contextCoordinatorRef = useRef(null);
2303
2557
 
2304
2558
  const interactive = Boolean(isRawModeSupported);
2305
2559
 
@@ -2321,6 +2575,8 @@ function App() {
2321
2575
  // closure must see the current value without being rebuilt on every toggle.
2322
2576
  const showHelpRef = useRef(false);
2323
2577
  showHelpRef.current = showHelp;
2578
+ const remoteSetupRef = useRef(false);
2579
+ remoteSetupRef.current = remoteSetup;
2324
2580
 
2325
2581
  function moveSelection(delta) {
2326
2582
  const { items, key: currentKey, bodyRows: rows_, tabKey, offset: offsetRaw } = navRef.current;
@@ -2387,7 +2643,7 @@ function App() {
2387
2643
  // unhandled rejection.
2388
2644
  openInBrowser(tabKey, item)
2389
2645
  .catch((err) => {
2390
- setErrors((x) => ({ ...x, [tabKey]: shortErr(err) }));
2646
+ setErrors((x) => ({ ...x, [tabKey]: textTabError(err) }));
2391
2647
  })
2392
2648
  .finally(() => {
2393
2649
  delete openingRef.current[guard];
@@ -2415,13 +2671,15 @@ function App() {
2415
2671
  } else if (key.pageUp) {
2416
2672
  moveSelection(-pageStep);
2417
2673
  } else if (key.return) {
2418
- openSelected();
2674
+ if (remoteSetupRef.current) onCreateRemote();
2675
+ else openSelected();
2419
2676
  } else if (input === "r") {
2420
2677
  // Goes through the same per-tab in-flight guard as the poll loop, so
2421
2678
  // holding the key down cannot stack concurrent subprocesses. `force`
2422
2679
  // bypasses (and clears) any failure backoff: this key is the user saying
2423
2680
  // "try again now", and a refresh that silently declined to refresh would
2424
2681
  // be worse than no key at all.
2682
+ contextCoordinatorRef.current?.invalidate();
2425
2683
  fetchTabRef.current?.(TABS[activeIndexRef.current].key, { force: true });
2426
2684
  } else if (input >= "1" && input <= String(TABS.length)) {
2427
2685
  setActiveIndex(Number(input) - 1);
@@ -2457,6 +2715,23 @@ function App() {
2457
2715
  let ticks = 0;
2458
2716
  const controller = new AbortController();
2459
2717
  registerLiveAbort(controller);
2718
+ const withTargetHost = (context) => ({
2719
+ ...context,
2720
+ targetHost: failureTargetHost({
2721
+ runtimeHost: runtime.host,
2722
+ ghHost: process.env.GH_HOST,
2723
+ ghRepo: process.env.GH_REPO,
2724
+ accounts: context.accounts,
2725
+ }),
2726
+ });
2727
+ const coordinator = createFailureContextCoordinator({
2728
+ resolve: async (signal) => withTargetHost(await resolveFailureContext(signal)),
2729
+ commit: (context) => {
2730
+ if (!cancelled) setFailureContext(context);
2731
+ },
2732
+ fallback: withTargetHost(missingFailureContext()),
2733
+ });
2734
+ contextCoordinatorRef.current = coordinator;
2460
2735
 
2461
2736
  // Each tab commits its own result the moment it lands instead of waiting on
2462
2737
  // a Promise.allSettled barrier. Actions is by far the slowest fetch, so
@@ -2522,13 +2797,14 @@ function App() {
2522
2797
  .catch((err) => {
2523
2798
  if (cancelled || err?.name === "AbortError") return;
2524
2799
  cleanRef.current[key] = false;
2525
- // Classified, not dumped. The three list tabs used to put raw `gh`
2526
- // stderr on screen while the alert path one tab over translated the
2527
- // same verdicts into something actionable. VERDICT_REMEDY replaces the
2528
- // message rather than prefixing it, because the error slot is exactly
2529
- // one line and a prefix would truncate away the diagnostic half.
2530
- const verdict = classify(err);
2531
- setErrors((x) => ({ ...x, [key]: pick(VERDICT_REMEDY, verdict, null) ?? shortErr(err) }));
2800
+ // Preserve both the verdict and the bounded raw error in state. The
2801
+ // renderer translates recognized verdicts at draw time, which lets a
2802
+ // later repository/account context refine the one-line remedy without
2803
+ // throwing away the original evidence.
2804
+ const failure = toTabError(err);
2805
+ const verdict = failure.verdict;
2806
+ setErrors((x) => ({ ...x, [key]: failure }));
2807
+ if (verdict === "unavailable") coordinator.ensure(controller.signal);
2532
2808
  // ...and back off, which the list tabs never did at all. A tab wedged
2533
2809
  // on an expired token used to re-spawn `gh` every tick forever -- 720
2534
2810
  // subprocesses an hour, indefinitely, against a token that is already
@@ -2560,6 +2836,11 @@ function App() {
2560
2836
  fetchTabRef.current = fetchTab;
2561
2837
 
2562
2838
  async function tick() {
2839
+ // Setup mode owns the whole screen and can only finish by handing off to
2840
+ // gh or exiting. Hidden tab retries cannot make that state more useful,
2841
+ // so stop spawning subprocesses once any endpoint proves there is no
2842
+ // remote. The interval may still wake, but this branch is otherwise idle.
2843
+ if (remoteSetupRef.current) return;
2563
2844
  // Only the tab you're looking at needs to keep up with REFRESH_MS. The
2564
2845
  // other three exist to keep the tab-bar counts honest, which tolerates
2565
2846
  // being a minute behind -- so they refresh every BACKGROUND_EVERY ticks
@@ -2585,9 +2866,10 @@ function App() {
2585
2866
  return () => {
2586
2867
  cancelled = true;
2587
2868
  clearInterval(id);
2869
+ if (contextCoordinatorRef.current === coordinator) contextCoordinatorRef.current = null;
2588
2870
  // `cancelled` stops state updates from a promise that already resolved;
2589
2871
  // the signal stops the subprocess itself, so quitting doesn't orphan up to
2590
- // six `gh` children mid-request. They cover different windows and both are
2872
+ // eight `gh` children mid-request. They cover different windows and both are
2591
2873
  // needed.
2592
2874
  controller.abort();
2593
2875
  };
@@ -2621,7 +2903,7 @@ function App() {
2621
2903
  TABS.map((t) => [t.key, data[t.key] == null && !errors[t.key]]),
2622
2904
  );
2623
2905
  const anyFirstLoad = Object.values(firstLoad).some(Boolean);
2624
- const showSpinner = ANIMATE && (anyFirstLoad || hasRunningVisible);
2906
+ const showSpinner = !remoteSetup && ANIMATE && (anyFirstLoad || hasRunningVisible);
2625
2907
  useEffect(() => {
2626
2908
  if (!showSpinner) {
2627
2909
  // Park on a fixed frame rather than freezing wherever the animation
@@ -2647,7 +2929,7 @@ function App() {
2647
2929
  }, [anyFirstLoad, iconHintDue]);
2648
2930
 
2649
2931
  const items = data[tab.key];
2650
- const error = errors[tab.key];
2932
+ const displayError = formatTabError(tabError, failureContext);
2651
2933
  const spin = SPINNER[frame % SPINNER.length];
2652
2934
 
2653
2935
  const counts = Object.fromEntries(
@@ -2685,7 +2967,9 @@ function App() {
2685
2967
  ];
2686
2968
  }),
2687
2969
  );
2688
- const failed = Object.fromEntries(TABS.map((t) => [t.key, Boolean(errors[t.key])]));
2970
+ const failed = Object.fromEntries(
2971
+ TABS.map((t) => [t.key, Boolean(errors[t.key]) && errors[t.key]?.verdict !== "no-remote"]),
2972
+ );
2689
2973
 
2690
2974
  // Data can be arbitrarily old without anything on screen saying so: a failing
2691
2975
  // poll only surfaces on the tab you have selected, and after a laptop sleep
@@ -2822,15 +3106,37 @@ function App() {
2822
3106
  !showHelp &&
2823
3107
  tooNarrow &&
2824
3108
  e(Text, { dimColor: true, wrap: "truncate-end" }, "too narrow"),
2825
- !showHelp && !tooNarrow && error && e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, error),
2826
3109
  !showHelp &&
2827
3110
  !tooNarrow &&
3111
+ !remoteSetup &&
3112
+ displayError &&
3113
+ e(Text, { color: ERROR_TEXT, wrap: "truncate-end" }, displayError),
3114
+ !showHelp &&
3115
+ !tooNarrow &&
3116
+ !remoteSetup &&
2828
3117
  tab.key === "security" &&
2829
3118
  securityLines.map((note, i) =>
2830
3119
  e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
2831
3120
  ),
2832
- !showHelp && !tooNarrow && e(HeaderCells, { cells: header }),
2833
- ...(tooNarrow || showHelp ? [] : visibleItems).map((item) => {
3121
+ !showHelp &&
3122
+ !tooNarrow &&
3123
+ remoteSetup &&
3124
+ (interactive ? REMOTE_SETUP_LINES : REMOTE_SETUP_NONINTERACTIVE_LINES).map(
3125
+ (line, index) =>
3126
+ e(
3127
+ Text,
3128
+ {
3129
+ key: `remote-setup-${index}`,
3130
+ color: index === 0 ? TITLE_COLOR : undefined,
3131
+ bold: index === 0,
3132
+ dimColor: index === 1,
3133
+ wrap: "truncate-end",
3134
+ },
3135
+ line,
3136
+ ),
3137
+ ),
3138
+ !showHelp && !tooNarrow && !remoteSetup && e(HeaderCells, { cells: header }),
3139
+ ...(tooNarrow || showHelp || remoteSetup ? [] : visibleItems).map((item) => {
2834
3140
  const key = itemKey(item);
2835
3141
  return e(
2836
3142
  RowBoundary,
@@ -2860,8 +3166,9 @@ function App() {
2860
3166
  // repository rather than the absence of an answer.
2861
3167
  !showHelp &&
2862
3168
  !tooNarrow &&
3169
+ !remoteSetup &&
2863
3170
  visibleItems.length === 0 &&
2864
- !(error && items == null) &&
3171
+ !(tabError && items == null) &&
2865
3172
  e(
2866
3173
  Text,
2867
3174
  { dimColor: true },
@@ -2890,6 +3197,7 @@ function App() {
2890
3197
  stale: staleLabel,
2891
3198
  interactive,
2892
3199
  cols: frameCols,
3200
+ remoteSetup,
2893
3201
  }),
2894
3202
  );
2895
3203
  }
@@ -2963,7 +3271,42 @@ if (IS_MAIN) {
2963
3271
  // root box is the terminal height. Windows is already documented as untested
2964
3272
  // (README Limitations), and the pty harness covers the two platforms that are
2965
3273
  // supported, so the flag is verified where it is claimed to work.
2966
- const app = render(e(App), { incrementalRendering: true });
3274
+ let app;
3275
+ let remoteSetupStarted = false;
3276
+ const createRemote = () => {
3277
+ if (remoteSetupStarted) return;
3278
+ remoteSetupStarted = true;
3279
+ try {
3280
+ app?.unmount();
3281
+ } catch {
3282
+ // The interactive gh flow matters more than a best-effort final repaint.
3283
+ }
3284
+ restoreScreen();
3285
+ abortLiveRequests();
3286
+
3287
+ // Plain `gh repo create` is the interactive form. Supplying --source here
3288
+ // would switch gh to non-interactive mode and require gh-glance to choose a
3289
+ // visibility on the user's behalf, which this consent boundary must never
3290
+ // do. The prompt tells the user which interactive path matches this folder.
3291
+ const child = spawn("gh", ["repo", "create"], {
3292
+ stdio: "inherit",
3293
+ env: process.env,
3294
+ });
3295
+ setupChild = child;
3296
+ child.once("error", (err) => {
3297
+ if (setupChild === child) setupChild = null;
3298
+ console.error(`gh-glance: could not start repository setup: ${redact(shortErr(err))}`);
3299
+ process.exitCode = 1;
3300
+ });
3301
+ child.once("exit", (code, signal) => {
3302
+ if (setupChild === child) setupChild = null;
3303
+ process.exitCode = signal ? 1 : (code ?? 1);
3304
+ if (code === 0) {
3305
+ console.log("gh repo create finished. Run gh-glance again when this folder has a remote.");
3306
+ }
3307
+ });
3308
+ };
3309
+ app = render(e(App, { onCreateRemote: createRemote }), { incrementalRendering: true });
2967
3310
 
2968
3311
  // 128 + signal number, so a supervisor or `timeout` can tell an interrupted
2969
3312
  // run from a clean one. These fire on external `kill` and when raw mode is
@@ -2986,19 +3329,38 @@ if (IS_MAIN) {
2986
3329
  // exiting immediately afterwards keeps the prompt-exit guarantee: waiting for
2987
3330
  // the event loop to drain would let a hung `gh` turn Ctrl+C into an apparent
2988
3331
  // hang.
2989
- const bySignal = (code) => () => {
2990
- try {
2991
- app.unmount();
2992
- } catch {
2993
- // Teardown is best effort. restoreScreen below, and the `exit` listener
2994
- // above, both still run, so the terminal is handed back either way.
3332
+ let signalExitStarted = false;
3333
+ const bySignal = (code, signal) => () => {
3334
+ if (signalExitStarted) return;
3335
+ signalExitStarted = true;
3336
+ const finish = () => {
3337
+ try {
3338
+ app.unmount();
3339
+ } catch {
3340
+ // Teardown is best effort. restoreScreen below, and the `exit` listener
3341
+ // above, both still run, so the terminal is handed back either way.
3342
+ }
3343
+ restoreScreen();
3344
+ process.exit(code);
3345
+ };
3346
+
3347
+ const child = setupChild;
3348
+ if (forwardSignalToChild(child, signal)) {
3349
+ const force = setTimeout(() => {
3350
+ forwardSignalToChild(child, "SIGKILL");
3351
+ finish();
3352
+ }, 1000);
3353
+ child.once("exit", () => {
3354
+ clearTimeout(force);
3355
+ finish();
3356
+ });
3357
+ return;
2995
3358
  }
2996
- restoreScreen();
2997
- process.exit(code);
3359
+ finish();
2998
3360
  };
2999
- process.on("SIGINT", bySignal(130));
3000
- process.on("SIGTERM", bySignal(143));
3001
- process.on("SIGHUP", bySignal(129));
3361
+ process.on("SIGINT", bySignal(130, "SIGINT"));
3362
+ process.on("SIGTERM", bySignal(143, "SIGTERM"));
3363
+ process.on("SIGHUP", bySignal(129, "SIGHUP"));
3002
3364
  }
3003
3365
 
3004
3366
  // Exported for unit tests. The dashboard itself is still one file; these are
@@ -3017,6 +3379,16 @@ export {
3017
3379
  isUnavailable,
3018
3380
  isRateLimited,
3019
3381
  isAuthProblem,
3382
+ isMissingRemote,
3383
+ forwardSignalToChild,
3384
+ toTabError,
3385
+ formatTabError,
3386
+ parseRepoContext,
3387
+ parseAuthContext,
3388
+ buildFailureContext,
3389
+ failureTargetHost,
3390
+ unavailableRemedy,
3391
+ createFailureContextCoordinator,
3020
3392
  AUTH_RETRY_MS,
3021
3393
  BACKOFF_STEPS_MS,
3022
3394
  redact,
@@ -3037,6 +3409,9 @@ export {
3037
3409
  OCT_UNICODE,
3038
3410
  KEY_TABLE,
3039
3411
  KEY_HINTS,
3412
+ REMOTE_SETUP_HINTS,
3413
+ REMOTE_SETUP_LINES,
3414
+ REMOTE_SETUP_NONINTERACTIVE_LINES,
3040
3415
  VERDICT_REMEDY,
3041
3416
  RATE_LIMIT_RETRY_MS,
3042
3417
  FAILURE_LADDER,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-glance",
3
- "version": "0.5.1",
3
+ "version": "0.6.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": {