@carlesandres/house 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { stat } from "node:fs/promises"
12
12
  import { createCliRenderer, SyntaxStyle } from "@opentui/core"
13
+ import type { BorderSides } from "@opentui/core"
13
14
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
14
15
  import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
15
16
  import { Cause, Duration, Effect, Fiber, Stream } from "effect"
@@ -19,17 +20,21 @@ import { Browser } from "./Browser.tsx"
19
20
  import { parseArgv, usage } from "./cli/argv.ts"
20
21
  import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
21
22
  import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
23
+ import { Header } from "./Header.tsx"
22
24
  import { readFileText } from "./io/readFile.ts"
23
25
  import { openInBrowser } from "./serve/openBrowser.ts"
24
26
  import { startServer } from "./serve/server.ts"
25
27
  import { colors, setActiveTheme } from "./theme/colors.ts"
26
28
  import { themeAtom, type ThemeState } from "./theme/atom.ts"
27
29
  import { getThemeDefinition, themeDefinitions } from "./theme/registry.ts"
30
+ import { formatQuitNotice } from "./update/notice.ts"
31
+ import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
32
+ import { useUpdateNotice } from "./update/useUpdateNotice.ts"
28
33
 
29
34
  export interface AppProps {
30
35
  /** Markdown source to render. */
31
36
  readonly content: string
32
- /** Optional title shown in the frame border. Defaults to a generic label. */
37
+ /** Optional title shown in the header's current-file slot. Defaults to a generic label. */
33
38
  readonly title?: string
34
39
  /** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
35
40
  readonly maxWidth?: number | null
@@ -56,11 +61,13 @@ interface DiscoverShellProps {
56
61
  readonly target: string
57
62
  readonly all: boolean
58
63
  readonly sort: SortOrder
64
+ readonly mdx: boolean
59
65
  readonly maxWidth: number | null
60
66
  readonly sidebarMode: SidebarMode
61
67
  }
62
68
 
63
- const DiscoverShell = ({ target, all, sort, maxWidth, sidebarMode }: DiscoverShellProps) => {
69
+ const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: DiscoverShellProps) => {
70
+ const updateNotice = useUpdateNotice()
64
71
  const [files, setFiles] = useState<readonly FileEntry[]>([])
65
72
  const [scanning, setScanning] = useState<boolean>(true)
66
73
  const [scanError, setScanError] = useState<string | null>(null)
@@ -69,7 +76,7 @@ const DiscoverShell = ({ target, all, sort, maxWidth, sidebarMode }: DiscoverShe
69
76
  const countRef = useRef(0)
70
77
 
71
78
  useEffect(() => {
72
- const program = walk(target, { all, sort }).pipe(
79
+ const program = walk(target, { all, sort, mdx }).pipe(
73
80
  Stream.groupedWithin(64, Duration.millis(60)),
74
81
  Stream.runForEach((chunk) =>
75
82
  Effect.sync(() => {
@@ -94,17 +101,17 @@ const DiscoverShell = ({ target, all, sort, maxWidth, sidebarMode }: DiscoverShe
94
101
  return () => {
95
102
  Effect.runFork(Fiber.interrupt(fiber))
96
103
  }
97
- }, [target, all, sort])
104
+ }, [target, all, sort, mdx])
98
105
 
99
106
  const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
100
107
 
101
108
  return (
102
109
  <Browser
103
110
  files={files}
104
- title={target}
105
111
  maxWidth={maxWidth}
106
112
  discoveryStatus={discoveryStatus}
107
113
  sidebarMode={sidebarMode}
114
+ updateNotice={updateNotice}
108
115
  />
109
116
  )
110
117
  }
@@ -145,13 +152,14 @@ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppPr
145
152
  if (key.name === "l" && key.shift) toggleTone()
146
153
  })
147
154
 
155
+ const paneBorderSides: BorderSides[] = ["top", "bottom"]
156
+
148
157
  return (
149
158
  <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
159
+ <Header width={width} currentFile={title} />
150
160
  <box
151
- title={` ${title} `}
152
- titleAlignment="left"
153
161
  style={{
154
- border: true,
162
+ border: paneBorderSides,
155
163
  borderColor: colors.border,
156
164
  padding: 1,
157
165
  flexGrow: 1,
@@ -183,6 +191,8 @@ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppPr
183
191
  )
184
192
  }
185
193
 
194
+ let updateExitHookRegistered = false
195
+
186
196
  if (import.meta.main) {
187
197
  const args = parseArgv(Bun.argv.slice(2))
188
198
 
@@ -200,12 +210,20 @@ if (import.meta.main) {
200
210
  }
201
211
 
202
212
  const config = await Effect.runPromise(
203
- loadConfig({ cli: { theme: args.theme, tone: args.tone } }),
213
+ loadConfig({
214
+ cli: {
215
+ theme: args.theme,
216
+ tone: args.tone,
217
+ // --no-mdx is a one-way override: present means "off". When
218
+ // absent, fall through to env/file/default.
219
+ mdx: args.noMdx ? false : null,
220
+ },
221
+ }),
204
222
  ).catch((err: unknown) => {
205
223
  console.error(`house: ${formatConfigError(err)}`)
206
224
  process.exit(2)
207
225
  })
208
- const { theme: themeId, tone } = config
226
+ const { theme: themeId, tone, mdx } = config
209
227
  const themeDef = getThemeDefinition(themeId)
210
228
  if (themeDef === undefined) {
211
229
  // Unreachable: Config.schema validated themeId against themeDefinitions.
@@ -275,7 +293,17 @@ if (import.meta.main) {
275
293
  }
276
294
  sidebarMode = args.sidebar
277
295
  }
278
- await runTui({ target, themeId, tone, maxWidth, all: args.all, sort, sidebarMode })
296
+ await runTui({
297
+ target,
298
+ themeId,
299
+ tone,
300
+ maxWidth,
301
+ all: args.all,
302
+ sort,
303
+ mdx,
304
+ sidebarMode,
305
+ updateCheck: !args.noUpdateCheck,
306
+ })
279
307
  }
280
308
  }
281
309
 
@@ -286,7 +314,11 @@ interface TuiBootOptions {
286
314
  readonly maxWidth: number | null
287
315
  readonly all: boolean
288
316
  readonly sort: SortOrder
317
+ readonly mdx: boolean
289
318
  readonly sidebarMode: SidebarMode
319
+ /** Run the npm-registry probe and surface the "update available" notice.
320
+ * False suppresses both the toast and the quit-time print. */
321
+ readonly updateCheck: boolean
290
322
  }
291
323
 
292
324
  async function runTui({
@@ -296,7 +328,9 @@ async function runTui({
296
328
  maxWidth,
297
329
  all,
298
330
  sort,
331
+ mdx,
299
332
  sidebarMode,
333
+ updateCheck,
300
334
  }: TuiBootOptions): Promise<void> {
301
335
  let stats: Awaited<ReturnType<typeof stat>>
302
336
  try {
@@ -306,6 +340,24 @@ async function runTui({
306
340
  process.exit(1)
307
341
  }
308
342
 
343
+ if (updateCheck) {
344
+ // Fire the npm-registry probe in the background. Result lands in a
345
+ // module singleton; the React tree picks it up via `useUpdateNotice`
346
+ // for the footer toast, and the 'exit' hook below reads it
347
+ // synchronously for the scrollback print. Failures are silent — this
348
+ // whole feature is opportunistic.
349
+ startUpdateProbe(pkg.name, pkg.version)
350
+ // Register once per process. Multiple 'exit' listeners would print
351
+ // the notice multiple times if runTui were ever re-entered.
352
+ if (!updateExitHookRegistered) {
353
+ updateExitHookRegistered = true
354
+ process.on("exit", () => {
355
+ const info = currentUpdateInfo()
356
+ if (info) process.stderr.write(formatQuitNotice(info))
357
+ })
358
+ }
359
+ }
360
+
309
361
  const renderer = await createCliRenderer({ exitOnCtrlC: false })
310
362
  const initialTheme: ThemeState = { id: themeId, tone }
311
363
 
@@ -316,6 +368,7 @@ async function runTui({
316
368
  target={target}
317
369
  all={all}
318
370
  sort={sort}
371
+ mdx={mdx}
319
372
  maxWidth={maxWidth}
320
373
  sidebarMode={sidebarMode}
321
374
  />
@@ -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
@@ -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
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Strict-greater version compare for the update notice.
3
+ *
4
+ * The notice fires only when the registry's published version is *strictly
5
+ * greater* than the running version on its numeric base. Pre-release
6
+ * suffixes are stripped before compare because:
7
+ *
8
+ * - `dist-tags.latest` is by convention a stable, never a pre-release; the
9
+ * notice does not target users who opted into pre-releases via a custom
10
+ * install command.
11
+ * - A local dev build of `0.5.0-dev.3` should NOT be nagged toward the
12
+ * published `0.5.0` while iterating on the same base — they're "the
13
+ * same version" for nag purposes.
14
+ */
15
+
16
+ const toBaseSegments = (version: string): readonly number[] | null => {
17
+ const base = version.split("-", 1)[0] ?? ""
18
+ const parts = base.split(".")
19
+ const nums: number[] = []
20
+ for (const p of parts) {
21
+ const n = Number(p)
22
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return null
23
+ nums.push(n)
24
+ }
25
+ return nums.length > 0 ? nums : null
26
+ }
27
+
28
+ /** True iff `candidate` > `current` on the numeric base. Malformed input → false. */
29
+ export const isNewer = (candidate: string, current: string): boolean => {
30
+ const a = toBaseSegments(candidate)
31
+ const b = toBaseSegments(current)
32
+ if (!a || !b) return false
33
+ const len = Math.max(a.length, b.length)
34
+ for (let i = 0; i < len; i++) {
35
+ const x = a[i] ?? 0
36
+ const y = b[i] ?? 0
37
+ if (x > y) return true
38
+ if (x < y) return false
39
+ }
40
+ return false
41
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Notice formatting for the update check. Kept separate from the probe so
3
+ * the surfaces (in-app footer, quit-time stderr print) can be reshaped
4
+ * without touching the network or cache code.
5
+ *
6
+ * Install-method note: we don't try to detect npm vs bun vs Homebrew. The
7
+ * runtime probe (`process.versions.bun`) only tells us how house was
8
+ * launched, not how it was installed — a user who ran `npm i -g …` and
9
+ * then happens to invoke via a bun-installed shim would be misled.
10
+ * Showing both commands is unambiguous and lets the user pick the one
11
+ * matching their install.
12
+ */
13
+
14
+ import type { UpdateInfo } from "./check.ts"
15
+
16
+ /** One-liner for the footer toast — must fit a tight viewport. */
17
+ export const formatFooterNotice = (info: UpdateInfo): string =>
18
+ `update available: ${info.latestVersion} (current ${info.currentVersion})`
19
+
20
+ /** Multi-line block printed to stderr after the renderer tears down. The
21
+ * user keeps this in scrollback and can copy the command directly. */
22
+ export const formatQuitNotice = (info: UpdateInfo): string =>
23
+ [
24
+ "",
25
+ `house ${info.latestVersion} is available (you have ${info.currentVersion}).`,
26
+ ` npm i -g ${info.pkgName}`,
27
+ ` bun add -g ${info.pkgName}`,
28
+ "",
29
+ ].join("\n")