@carlesandres/house 0.4.0 → 0.4.2

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.
@@ -10,11 +10,19 @@ export type BrowserFocus = "sidebar" | "reader"
10
10
 
11
11
  export interface BrowserCtx {
12
12
  readonly files: readonly FileEntry[]
13
+ /** True iff `files[selectedIndex]` resolves to an entry. The honest
14
+ * predicate for File-group actions (`o`, `e`, `[`, `]`): with debounced
15
+ * filter and sticky auto-select, `files.length > 0` can be true while
16
+ * `selectedIndex` is invalid for the displayed list. See #115. */
17
+ readonly hasSelected: boolean
13
18
  readonly focus: BrowserFocus
14
19
  /** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
15
20
  readonly sidebarShown: boolean
16
21
  readonly helpVisible: boolean
17
22
  readonly filterOpen: boolean
23
+ /** Current applied/edited filter query. Used by `filter.clearOrOpen`'s
24
+ * hint gate so the hint only appears when there is something to clear. */
25
+ readonly filterQuery: string
18
26
  readonly paletteOpen: boolean
19
27
  readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
20
28
  readonly setSelectedIndex: (updater: (prev: number) => number) => void
@@ -22,12 +30,19 @@ export interface BrowserCtx {
22
30
  readonly toggleShown: () => void
23
31
  readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
24
32
  readonly openFilter: () => void
33
+ /** Clear the current filter query and open the filter modal in a single
34
+ * action. Bound to `\` so users can reset a stranded zero-match filter
35
+ * without first reopening with `/` and backspacing. */
36
+ readonly clearAndOpenFilter: () => void
25
37
  readonly openPalette: () => void
26
38
  readonly cycleTheme: (delta: 1 | -1) => void
27
39
  readonly toggleTone: () => void
28
40
  readonly quit: () => void
29
41
  /** Start (or retarget) the HTML preview server on the focused file. */
30
42
  readonly serveCurrent: () => void
43
+ /** Suspend the TUI, hand the TTY to `$EDITOR`, resume and re-read on
44
+ * exit. No-op when nothing is selected; gating is the binding's job. */
45
+ readonly editCurrent: () => void
31
46
  }
32
47
 
33
48
  /** Step size for shift+j/k and the space/b/page keys. Constant for v1; could
@@ -37,6 +52,7 @@ const JUMP = 8
37
52
  const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
38
53
  const lastIndex = (c: BrowserCtx) => Math.max(0, c.files.length - 1)
39
54
  const haveFiles = (c: BrowserCtx) => c.files.length > 0
55
+ const hasSelected = (c: BrowserCtx) => c.hasSelected
40
56
  const stepBy = (c: BrowserCtx, delta: number) =>
41
57
  c.setSelectedIndex((i) => clamp(i + delta, 0, lastIndex(c)))
42
58
 
@@ -45,7 +61,10 @@ const filterClosed = (c: BrowserCtx) => !c.filterOpen
45
61
  const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
46
62
  const inReader = (c: BrowserCtx) => c.focus === "reader"
47
63
  const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
48
- const inReaderWithFiles = (c: BrowserCtx) => inReader(c) && haveFiles(c)
64
+ /** Reader-only sibling-step gate: needs a current selection plus a sibling
65
+ * to step to. `hasSelected` implies `files.length >= 1`, so `>= 2` is the
66
+ * meaningful extra condition. */
67
+ const inReaderWithSibling = (c: BrowserCtx) => inReader(c) && hasSelected(c) && c.files.length >= 2
49
68
 
50
69
  export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
51
70
  // Global
@@ -93,6 +112,28 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
93
112
  when: filterClosed,
94
113
  run: (c) => c.openFilter(),
95
114
  },
115
+ {
116
+ id: "filter.clearOrOpen",
117
+ group: "Sidebar",
118
+ description: "Clear filter",
119
+ hint: "clear",
120
+ keys: ["ctrl+\\"],
121
+ // Fires from anywhere outside the filter modal via the keymap.
122
+ // Inside the filter modal it's intercepted directly in Browser.tsx
123
+ // (the filter mode owns key handling), but the action is the same —
124
+ // clear input, keep modal open. Palette/help branches short-circuit
125
+ // dispatch in Browser.tsx, so we don't need to gate on them for
126
+ // behavior; the `hintWhen` gate keeps the footer chip from showing
127
+ // when there's nothing to clear or when a modal owns the input.
128
+ // Chord chosen over single `\` so the binding works inside the
129
+ // filter input without colliding with the typed character; ctrl+u
130
+ // is deliberately left to its reader/sidebar half-page-up role to
131
+ // avoid overload.
132
+ when: filterClosed,
133
+ hintWhen: (c) =>
134
+ filterClosed(c) && !c.paletteOpen && !c.helpVisible && c.filterQuery.length > 0,
135
+ run: (c) => c.clearAndOpenFilter(),
136
+ },
96
137
  {
97
138
  id: "palette.open",
98
139
  group: "Global",
@@ -106,15 +147,6 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
106
147
  when: paletteClosed,
107
148
  run: (c) => c.openPalette(),
108
149
  },
109
- {
110
- id: "serve.current",
111
- group: "Global",
112
- description: "Open current file in browser as HTML",
113
- hint: "html",
114
- keys: ["o"],
115
- when: haveFiles,
116
- run: (c) => c.serveCurrent(),
117
- },
118
150
  {
119
151
  id: "theme.next",
120
152
  group: "Global",
@@ -213,32 +245,57 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
213
245
  run: (c) => c.setFocus("reader"),
214
246
  },
215
247
 
216
- // Reader
248
+ // File — actions on the currently-selected file. Gated on `hasSelected`
249
+ // (per #115) so they're available exactly when the reader has something
250
+ // to act on, regardless of focus or filter state.
217
251
  {
218
- id: "reader.back",
219
- group: "Reader",
220
- description: "Back to sidebar",
221
- hint: "back",
222
- keys: ["escape", "left", "h"],
223
- when: inReader,
224
- run: (c) => c.setFocus("sidebar"),
252
+ id: "serve.current",
253
+ group: "File",
254
+ description: "Open current file in browser as HTML",
255
+ hint: "html",
256
+ keys: ["o"],
257
+ when: hasSelected,
258
+ run: (c) => c.serveCurrent(),
225
259
  },
226
260
  {
261
+ id: "file.edit",
262
+ group: "File",
263
+ description: "Open current file in $EDITOR",
264
+ hint: "edit",
265
+ keys: ["e"],
266
+ when: hasSelected,
267
+ run: (c) => c.editCurrent(),
268
+ },
269
+ {
270
+ // `[`/`]` keep the `inReader` clause so they're only typed from the
271
+ // reader (sidebar uses j/k for stepping). The File-group predicate is
272
+ // additive: needs a selection *and* a sibling to step to.
227
273
  id: "reader.prevFile",
228
- group: "Reader",
274
+ group: "File",
229
275
  description: "Prev file",
230
276
  hint: "prev",
231
277
  keys: ["["],
232
- when: inReaderWithFiles,
278
+ when: inReaderWithSibling,
233
279
  run: (c) => stepBy(c, -1),
234
280
  },
235
281
  {
236
282
  id: "reader.nextFile",
237
- group: "Reader",
283
+ group: "File",
238
284
  description: "Next file",
239
285
  hint: "next",
240
286
  keys: ["]"],
241
- when: inReaderWithFiles,
287
+ when: inReaderWithSibling,
242
288
  run: (c) => stepBy(c, 1),
243
289
  },
290
+
291
+ // Reader
292
+ {
293
+ id: "reader.back",
294
+ group: "Reader",
295
+ description: "Back to sidebar",
296
+ hint: "back",
297
+ keys: ["escape", "left", "h"],
298
+ when: inReader,
299
+ run: (c) => c.setFocus("sidebar"),
300
+ },
244
301
  ]
@@ -34,6 +34,12 @@ export interface KeyBinding<C> {
34
34
  readonly hint?: string
35
35
  /** If present, the binding only fires when this returns true. */
36
36
  readonly when?: (ctx: C) => boolean
37
+ /** If present, the footer hint is shown only when this returns true. When
38
+ * absent, hint visibility falls back to `when`. Use when a binding's
39
+ * dispatch gate is broader than the situations where the hint is useful
40
+ * (e.g. "clear filter" fires from anywhere but only deserves a hint when
41
+ * there is actually a filter to clear). */
42
+ readonly hintWhen?: (ctx: C) => boolean
37
43
  readonly run: (ctx: C) => void
38
44
  }
39
45
 
@@ -18,8 +18,6 @@ export const SIDEBAR_MAX_WIDTH = 60
18
18
  export const READER_MIN_WIDTH = 40
19
19
  /** Column gap painted between the two panes when both are inline. */
20
20
  export const DIVIDER_WIDTH = 1
21
- /** Launch-bucket threshold for `--sidebar=auto`. < this → start hidden. */
22
- export const TIGHT_VIEWPORT_THRESHOLD = 80
23
21
 
24
22
  /**
25
23
  * Continuous clamp. `preferred` is the user's desired width (until #13 lands,
@@ -28,7 +26,7 @@ export const TIGHT_VIEWPORT_THRESHOLD = 80
28
26
  *
29
27
  * Result is not clamped *up* to SIDEBAR_MIN when the viewport itself is too
30
28
  * narrow to hold both panes — the caller decides whether to render at all
31
- * (e.g. drawer instead of inline). See `canFitInline`.
29
+ * (e.g. single-pane stack instead of inline). See `canFitInline`.
32
30
  */
33
31
  export const resolveSidebarWidth = (viewport: number, preferred: number): number => {
34
32
  const ceiling = viewport - DIVIDER_WIDTH - READER_MIN_WIDTH
@@ -46,15 +44,17 @@ export const defaultPreferredWidth = (viewport: number): number =>
46
44
 
47
45
  /**
48
46
  * True when an inline (side-by-side) layout still gives the reader at least
49
- * READER_MIN_WIDTH. When false, the caller should render the sidebar as a
50
- * drawer insteadsee DESIGN.md §7.1 Q2.
47
+ * READER_MIN_WIDTH. When false, the viewport is "narrow" and the UI runs in
48
+ * single-pane stack mode sidebar OR reader fills the pane area, never both.
49
+ * See DESIGN.md §7.1.
51
50
  */
52
51
  export const canFitInline = (viewport: number): boolean =>
53
52
  viewport >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + READER_MIN_WIDTH
54
53
 
55
54
  /**
56
- * Launch bucket decision for `--sidebar=auto`. Buckets are consulted once at
57
- * launch only; subsequent visibility changes go through `shown` + focus.
55
+ * Initial sidebar visibility for `--sidebar=auto`. Always true every
56
+ * viewport now boots on the sidebar (narrow: as the single visible screen;
57
+ * wide: as the focused inline pane). `--sidebar=off` is the only way to
58
+ * boot directly into the reader.
58
59
  */
59
- export const initialShownForAuto = (viewport: number): boolean =>
60
- viewport >= TIGHT_VIEWPORT_THRESHOLD
60
+ export const initialShownForAuto = (_viewport: number): boolean => true
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Sidebar row layout — basename-first with a dim parent suffix sized to the
3
+ * available width.
4
+ *
5
+ * Pure per-row formatting: a row's rendered shape depends only on its own
6
+ * path and the column budget — never on neighboring rows. That keeps the
7
+ * sidebar stable as filters change and the file set grows, and keeps the
8
+ * function trivially predictable. Disambiguation against same-basename
9
+ * siblings is the header's job (it shows the full relative path of the
10
+ * selected row); a future auto-scroll on the selected sidebar row can carry
11
+ * the same information without altering layout for the rest.
12
+ *
13
+ * Truncation policy (head-elide, segment-aware):
14
+ * - Full parent fits → render whole.
15
+ * - Else drop leading segments one at a time, prefixed with `…/`, until
16
+ * the remainder fits — never chops a segment mid-character.
17
+ * - When even the tail segment with `…/` overflows, drop the marker.
18
+ * - When the tail segment alone overflows, hard-truncate it from its head
19
+ * (leading `…`) as a last resort.
20
+ *
21
+ * Why head-elide: the immediate parent is the segment closest to the file
22
+ * and the most universally meaningful one when context shrinks.
23
+ */
24
+
25
+ export const SIDEBAR_ROW_SEPARATOR = " · "
26
+ const ELISION_PREFIX = "…/"
27
+ const MIN_PARENT_BUDGET = 3
28
+
29
+ export interface SidebarRowParts {
30
+ readonly basename: string
31
+ readonly separator: string
32
+ readonly parent: string
33
+ }
34
+
35
+ export const formatSidebarRow = (relativePath: string, totalWidth: number): SidebarRowParts => {
36
+ const slash = relativePath.lastIndexOf("/")
37
+ if (slash < 0) {
38
+ return { basename: fitTail(relativePath, totalWidth), separator: "", parent: "" }
39
+ }
40
+
41
+ const basename = relativePath.slice(slash + 1)
42
+ const parentFull = relativePath.slice(0, slash)
43
+
44
+ if (basename.length >= totalWidth) {
45
+ return { basename: fitTail(basename, totalWidth), separator: "", parent: "" }
46
+ }
47
+
48
+ const remaining = totalWidth - basename.length - SIDEBAR_ROW_SEPARATOR.length
49
+ if (remaining < MIN_PARENT_BUDGET || parentFull.length === 0) {
50
+ return { basename, separator: "", parent: "" }
51
+ }
52
+
53
+ if (parentFull.length <= remaining) {
54
+ return row(basename, parentFull)
55
+ }
56
+
57
+ const segments = parentFull.split("/")
58
+ for (let k = segments.length - 1; k >= 1; k--) {
59
+ const candidate = ELISION_PREFIX + segments.slice(segments.length - k).join("/")
60
+ if (candidate.length <= remaining) return row(basename, candidate)
61
+ }
62
+
63
+ // Even one segment with the `…/` marker doesn't fit. Try without the marker.
64
+ const tail = segments[segments.length - 1]!
65
+ if (tail.length <= remaining) return row(basename, tail)
66
+
67
+ // Hard-chop the tail segment from its head as a last resort.
68
+ return row(basename, "…" + tail.slice(tail.length - remaining + 1))
69
+ }
70
+
71
+ const row = (basename: string, parent: string): SidebarRowParts => ({
72
+ basename,
73
+ separator: SIDEBAR_ROW_SEPARATOR,
74
+ parent,
75
+ })
76
+
77
+ const fitTail = (s: string, width: number): string => {
78
+ if (s.length <= width) return s
79
+ // At width ≤ 1 there's no room for both a character and the ellipsis; emit
80
+ // a single char so the result respects the budget. Callers currently clamp
81
+ // width to ≥ 4, but the helper carries its own floor so a tighter future
82
+ // caller can't silently overflow the column.
83
+ if (width <= 1) return s.slice(0, 1)
84
+ return s.slice(0, width - 1) + "…"
85
+ }
@@ -86,9 +86,12 @@ export const startServer = ({ path, port = 0 }: StartOptions): ServerHandle => {
86
86
  // user's local files, so leaking them to the network would be a
87
87
  // surprise. URL strings are localhost-only by construction below.
88
88
  hostname: "127.0.0.1",
89
- async fetch(req) {
89
+ async fetch(req, server) {
90
90
  const url = new URL(req.url)
91
91
  if (url.pathname === "/__reload") {
92
+ // SSE stream is silent between file changes; without this Bun
93
+ // closes the request at the default 10s idleTimeout and warns.
94
+ server.timeout(req, 0)
92
95
  // `cancel` receives a reason, not the controller — capture
93
96
  // the controller in `start` so we can remove it from the set
94
97
  // on disconnect. Without this, dead clients accumulate.
@@ -9,24 +9,60 @@ import type { ColorPalette, ResolvedTheme, ThemeDefinition, Tone } from "./types
9
9
  * - UI tokens map name-for-name where they overlap.
10
10
  * - `surface` ← `backgroundPanel`, `selectedBg` ← `backgroundElement`,
11
11
  * `selectedBgInactive` ← `borderSubtle`.
12
- * - `textStrong` borrows `markdownStrong` (the brightest/most-emphasized
13
- * text token in opencode's palette).
12
+ * - `textStrong` `primary` (opencode's convention for emphasized brand
13
+ * text in UI chrome). Markdown rendering still reads `markdownStrong`
14
+ * directly via the syntax map.
14
15
  * - `syntax` is a fully populated opentui tree-sitter scope map built from
15
16
  * `markdown*` and `syntax*` tokens.
16
17
  */
17
- const buildPalette = (r: ResolvedTheme): ColorPalette => ({
18
- background: r.background,
19
- surface: r.backgroundPanel,
20
- text: r.text,
21
- textStrong: r.markdownStrong,
22
- textMuted: r.textMuted,
23
- border: r.border,
24
- borderActive: r.borderActive,
25
- selectedBg: r.backgroundElement,
26
- selectedBgInactive: r.borderSubtle,
27
- error: r.error,
28
- syntax: buildSyntaxMap(r),
29
- })
18
+ /** Relative luminance of a `#rrggbb` color (0..1). Sufficient for ordering
19
+ * two near-neutral chrome colors — not a full WCAG contrast calculation.
20
+ * Non-hex inputs fall back to 0.5 so the caller's ordering is a no-op. */
21
+ const luminance = (hex: string): number => {
22
+ const m = /^#([0-9a-fA-F]{6})$/.exec(hex)
23
+ if (!m) return 0.5
24
+ const h = m[1]!
25
+ const r = parseInt(h.slice(0, 2), 16)
26
+ const g = parseInt(h.slice(2, 4), 16)
27
+ const b = parseInt(h.slice(4, 6), 16)
28
+ // Rec. 601 weighting — perceptual ordering, not the linearized WCAG variant.
29
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255
30
+ }
31
+
32
+ /** Pane chrome assumes the active/raised pane sits on the darker of the two
33
+ * background tokens and the dim chrome on the lighter one. Most themes
34
+ * define `background` darker than `backgroundPanel` and this is a no-op;
35
+ * some (e.g. cursor) flip the polarity, in which case we swap so the
36
+ * active-pane convention stays consistent across themes. */
37
+ const orientChrome = (r: ResolvedTheme): { raised: string; dim: string } => {
38
+ const bg = r.background
39
+ const panel = r.backgroundPanel
40
+ return luminance(bg) <= luminance(panel) ? { raised: bg, dim: panel } : { raised: panel, dim: bg }
41
+ }
42
+
43
+ const buildPalette = (r: ResolvedTheme): ColorPalette => {
44
+ const { raised, dim } = orientChrome(r)
45
+ return {
46
+ background: raised,
47
+ surface: dim,
48
+ text: r.text,
49
+ textStrong: r.primary,
50
+ textMuted: r.textMuted,
51
+ border: r.border,
52
+ borderActive: r.borderActive,
53
+ selectedBg: r.backgroundElement,
54
+ selectedBgInactive: r.borderSubtle,
55
+ selectedListItemText: r.selectedListItemText,
56
+ primary: r.primary,
57
+ secondary: r.secondary,
58
+ accent: r.accent,
59
+ error: r.error,
60
+ warning: r.warning,
61
+ success: r.success,
62
+ info: r.info,
63
+ syntax: buildSyntaxMap(r),
64
+ }
65
+ }
30
66
 
31
67
  const buildSyntaxMap = (r: ResolvedTheme): Record<string, StyleDefinitionInput> => {
32
68
  const codeBg = r.backgroundPanel
@@ -97,7 +97,14 @@ export interface ColorPalette {
97
97
  readonly borderActive: string
98
98
  readonly selectedBg: string
99
99
  readonly selectedBgInactive: string
100
+ readonly selectedListItemText: string
101
+ readonly primary: string
102
+ readonly secondary: string
103
+ readonly accent: string
100
104
  readonly error: string
105
+ readonly warning: string
106
+ readonly success: string
107
+ readonly info: string
101
108
  readonly syntax: Record<string, StyleDefinitionInput>
102
109
  }
103
110
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Update-check cache. Stores the last npm-registry probe result on disk so
3
+ * we hit the registry at most once per TTL window.
4
+ *
5
+ * Layout: `$XDG_CACHE_HOME/house/update-check.json` (fallback
6
+ * `~/.cache/house/update-check.json`). All IO failures are non-fatal — a
7
+ * missing or unparseable cache simply forces a fresh probe.
8
+ *
9
+ * Schema is intentionally minimal. `tarballOk` distinguishes "we saw the
10
+ * version and confirmed the tarball is downloadable" from "we saw the
11
+ * version but the CDN HEAD failed" — only the former gates the notice. A
12
+ * `false` entry forces a retry on the next launch rather than waiting out
13
+ * the TTL.
14
+ */
15
+
16
+ import { dirname, join } from "node:path"
17
+ import { homedir } from "node:os"
18
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"
19
+
20
+ export interface UpdateCacheRecord {
21
+ readonly checkedAt: number
22
+ readonly latestVersion: string
23
+ readonly tarballOk: boolean
24
+ }
25
+
26
+ export const cacheDir = (): string => {
27
+ const xdg = process.env.XDG_CACHE_HOME
28
+ if (xdg && xdg.length > 0) return join(xdg, "house")
29
+ return join(homedir(), ".cache", "house")
30
+ }
31
+
32
+ export const cachePath = (): string => join(cacheDir(), "update-check.json")
33
+
34
+ export const readCache = async (path = cachePath()): Promise<UpdateCacheRecord | null> => {
35
+ try {
36
+ const raw = await readFile(path, "utf8")
37
+ const parsed = JSON.parse(raw) as unknown
38
+ if (typeof parsed !== "object" || parsed === null) return null
39
+ const r = parsed as Record<string, unknown>
40
+ if (
41
+ typeof r.checkedAt !== "number" ||
42
+ typeof r.latestVersion !== "string" ||
43
+ typeof r.tarballOk !== "boolean"
44
+ ) {
45
+ return null
46
+ }
47
+ return {
48
+ checkedAt: r.checkedAt,
49
+ latestVersion: r.latestVersion,
50
+ tarballOk: r.tarballOk,
51
+ }
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ export const writeCache = async (record: UpdateCacheRecord, path = cachePath()): Promise<void> => {
58
+ // Atomic write: writeFile to a sibling tmp path, then rename. Rename is
59
+ // atomic on POSIX, so a process.exit() racing the writer either leaves
60
+ // the prior file intact or installs the new one fully — never a partial
61
+ // JSON blob that readCache would have to ignore.
62
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
63
+ try {
64
+ await mkdir(dirname(path), { recursive: true })
65
+ await writeFile(tmp, JSON.stringify(record), "utf8")
66
+ await rename(tmp, path)
67
+ } catch {
68
+ // Cache writes are best-effort. A read-only HOME or full disk should
69
+ // not break the app; we'll just re-probe on the next launch. Clean up
70
+ // the tmp file if writeFile partially succeeded but rename did not.
71
+ try {
72
+ await unlink(tmp)
73
+ } catch {
74
+ // best-effort
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Update probe. Resolves to an upgrade record iff a strictly-newer version
3
+ * is published on npm AND its tarball is actually downloadable.
4
+ *
5
+ * The HEAD on `dist.tarball` is the load-bearing step: the npm registry can
6
+ * publish version metadata moments before the CDN serves the tarball, and
7
+ * we promised not to nag the user toward a version they cannot install
8
+ * yet. A non-200 HEAD invalidates the cache entry (tarballOk=false) so the
9
+ * next launch retries instead of waiting out the TTL.
10
+ *
11
+ * All failures are silent. The notice surface is opportunistic — the user
12
+ * should never see an error from a feature whose job is to whisper.
13
+ */
14
+
15
+ import { isNewer } from "./compare.ts"
16
+ import { readCache, writeCache, type UpdateCacheRecord } from "./cache.ts"
17
+
18
+ export const TTL_MS = 24 * 60 * 60 * 1000
19
+ const FETCH_TIMEOUT_MS = 3000
20
+ const REGISTRY_URL = (pkgName: string) => `https://registry.npmjs.org/${pkgName}/latest`
21
+
22
+ export interface UpdateInfo {
23
+ readonly pkgName: string
24
+ readonly currentVersion: string
25
+ readonly latestVersion: string
26
+ }
27
+
28
+ export interface CheckOptions {
29
+ readonly pkgName: string
30
+ readonly currentVersion: string
31
+ /** Override for tests. Defaults to the npm registry + global fetch. */
32
+ readonly now?: () => number
33
+ readonly env?: Record<string, string | undefined>
34
+ readonly fetchImpl?: typeof fetch
35
+ readonly cacheRead?: () => Promise<UpdateCacheRecord | null>
36
+ readonly cacheWrite?: (r: UpdateCacheRecord) => Promise<void>
37
+ }
38
+
39
+ /** Treat any non-empty, non-`0`, non-`false` value as opt-out. Mirrors the
40
+ * permissiveness of the de-facto npm-ecosystem convention; a user copy-
41
+ * pasting `NO_UPDATE_NOTIFIER=true` from another tool's docs should work. */
42
+ const isTruthyEnv = (value: string | undefined): boolean => {
43
+ if (!value) return false
44
+ const v = value.toLowerCase()
45
+ return v !== "0" && v !== "false" && v !== "no"
46
+ }
47
+
48
+ /** Most major CIs (GitHub Actions, GitLab, Travis, CircleCI, Jenkins) set
49
+ * `CI=true`. A few set it to other truthy values; treat any non-empty
50
+ * value as "we're in CI, skip the nag." */
51
+ const isCi = (env: Record<string, string | undefined>): boolean =>
52
+ typeof env.CI === "string" &&
53
+ env.CI.length > 0 &&
54
+ env.CI !== "0" &&
55
+ env.CI.toLowerCase() !== "false"
56
+
57
+ /** Run a fetch with a single timeout that covers BOTH the response headers
58
+ * and the caller's body read. Returning the bare Response and then reading
59
+ * `res.json()` outside the timer leaves the body stream unbounded — a
60
+ * stalled connection after headers would hang forever. The consumer
61
+ * callback runs while the AbortController is still live, so an abort
62
+ * cancels an in-flight body read too. */
63
+ const fetchWithTimeout = async <T>(
64
+ url: string,
65
+ init: RequestInit,
66
+ consume: (res: Response) => Promise<T>,
67
+ fetchImpl: typeof fetch = fetch,
68
+ ): Promise<T> => {
69
+ const ctrl = new AbortController()
70
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
71
+ try {
72
+ const res = await fetchImpl(url, { ...init, signal: ctrl.signal })
73
+ return await consume(res)
74
+ } finally {
75
+ clearTimeout(timer)
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Run the probe and return upgrade info if one applies. `null` covers
81
+ * every "no nag" case: opt-out, recent cache hit on the same version, no
82
+ * newer version, registry/CDN failure, malformed response.
83
+ */
84
+ export const checkForUpdate = async (opts: CheckOptions): Promise<UpdateInfo | null> => {
85
+ const env = opts.env ?? process.env
86
+ if (isTruthyEnv(env.NO_UPDATE_NOTIFIER) || isCi(env)) {
87
+ return null
88
+ }
89
+
90
+ const now = opts.now ?? Date.now
91
+ const cacheRead = opts.cacheRead ?? (() => readCache())
92
+ const cacheWrite = opts.cacheWrite ?? ((r: UpdateCacheRecord) => writeCache(r))
93
+
94
+ const cached = await cacheRead()
95
+ const fresh = cached !== null && cached.tarballOk && now() - cached.checkedAt < TTL_MS
96
+ if (fresh) {
97
+ return isNewer(cached.latestVersion, opts.currentVersion)
98
+ ? {
99
+ pkgName: opts.pkgName,
100
+ currentVersion: opts.currentVersion,
101
+ latestVersion: cached.latestVersion,
102
+ }
103
+ : null
104
+ }
105
+
106
+ // Probe the registry.
107
+ let latestVersion: string
108
+ let tarballUrl: string
109
+ try {
110
+ const parsed = await fetchWithTimeout(
111
+ REGISTRY_URL(opts.pkgName),
112
+ { headers: { accept: "application/json" } },
113
+ async (res) => {
114
+ if (!res.ok) return null
115
+ const body = (await res.json()) as unknown
116
+ if (typeof body !== "object" || body === null) return null
117
+ const obj = body as Record<string, unknown>
118
+ const version = obj.version
119
+ const dist = obj.dist as Record<string, unknown> | undefined
120
+ const tarball = dist?.tarball
121
+ if (typeof version !== "string" || typeof tarball !== "string") return null
122
+ // The registry returns a CDN URL we're about to HEAD without
123
+ // further validation. Pin to https so a compromised or proxied
124
+ // registry can't redirect us to file://, http://, or another
125
+ // scheme we'd issue a request against.
126
+ try {
127
+ if (new URL(tarball).protocol !== "https:") return null
128
+ } catch {
129
+ return null
130
+ }
131
+ return { version, tarball }
132
+ },
133
+ opts.fetchImpl,
134
+ )
135
+ if (!parsed) return null
136
+ latestVersion = parsed.version
137
+ tarballUrl = parsed.tarball
138
+ } catch {
139
+ return null
140
+ }
141
+
142
+ // Verify the artifact is actually downloadable. This is the step that
143
+ // makes "only announced when the artifact is available" hold.
144
+ let tarballOk = false
145
+ try {
146
+ tarballOk = await fetchWithTimeout(
147
+ tarballUrl,
148
+ { method: "HEAD" },
149
+ async (res) => res.ok,
150
+ opts.fetchImpl,
151
+ )
152
+ } catch {
153
+ tarballOk = false
154
+ }
155
+
156
+ await cacheWrite({ checkedAt: now(), latestVersion, tarballOk })
157
+
158
+ if (!tarballOk) return null
159
+ if (!isNewer(latestVersion, opts.currentVersion)) return null
160
+ return {
161
+ pkgName: opts.pkgName,
162
+ currentVersion: opts.currentVersion,
163
+ latestVersion,
164
+ }
165
+ }