@carlesandres/house 0.3.1 → 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,25 +10,31 @@
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
- import { Effect } from "effect"
16
- import { useMemo } from "react"
16
+ import { Cause, Duration, Effect, Fiber, Stream } from "effect"
17
+ import { useEffect, useMemo, useRef, useState } from "react"
17
18
  import pkg from "../package.json" with { type: "json" }
18
19
  import { Browser } from "./Browser.tsx"
19
20
  import { parseArgv, usage } from "./cli/argv.ts"
20
- import { walk, type SortOrder } from "./discovery/walk.ts"
21
+ import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
22
+ import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
23
+ import { Header } from "./Header.tsx"
21
24
  import { readFileText } from "./io/readFile.ts"
22
25
  import { openInBrowser } from "./serve/openBrowser.ts"
23
26
  import { startServer } from "./serve/server.ts"
24
27
  import { colors, setActiveTheme } from "./theme/colors.ts"
25
28
  import { themeAtom, type ThemeState } from "./theme/atom.ts"
26
- import { getThemeDefinition, isThemeId, themeDefinitions } from "./theme/registry.ts"
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"
27
33
 
28
34
  export interface AppProps {
29
35
  /** Markdown source to render. */
30
36
  readonly content: string
31
- /** 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. */
32
38
  readonly title?: string
33
39
  /** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
34
40
  readonly maxWidth?: number | null
@@ -36,6 +42,80 @@ export interface AppProps {
36
42
  readonly onQuit?: () => void
37
43
  }
38
44
 
45
+ /**
46
+ * DiscoverShell — owns the streaming walk for directory mode. Mounts Browser
47
+ * immediately with `files=[]` and pushes entries as the stream emits.
48
+ *
49
+ * Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
50
+ * trigger one React render per file. Tuned by feel — small enough that
51
+ * results still feel live on tiny trees, large enough to keep render
52
+ * frequency sane on big ones. Revisit if profiling says otherwise.
53
+ *
54
+ * Cancellation: the walk runs on a forked fiber; unmount interrupts it.
55
+ * `Quit` in Browser tears down the renderer and exits, which propagates
56
+ * naturally — the cleanup effect still fires before process.exit completes.
57
+ */
58
+ export type SidebarMode = "auto" | "on" | "off"
59
+
60
+ interface DiscoverShellProps {
61
+ readonly target: string
62
+ readonly all: boolean
63
+ readonly sort: SortOrder
64
+ readonly mdx: boolean
65
+ readonly maxWidth: number | null
66
+ readonly sidebarMode: SidebarMode
67
+ }
68
+
69
+ const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: DiscoverShellProps) => {
70
+ const updateNotice = useUpdateNotice()
71
+ const [files, setFiles] = useState<readonly FileEntry[]>([])
72
+ const [scanning, setScanning] = useState<boolean>(true)
73
+ const [scanError, setScanError] = useState<string | null>(null)
74
+ // Files arrive in a ref-tracked count so the status string can show
75
+ // "indexing… N" even when React hasn't yet flushed the latest setFiles.
76
+ const countRef = useRef(0)
77
+
78
+ useEffect(() => {
79
+ const program = walk(target, { all, sort, mdx }).pipe(
80
+ Stream.groupedWithin(64, Duration.millis(60)),
81
+ Stream.runForEach((chunk) =>
82
+ Effect.sync(() => {
83
+ const arr = Array.from(chunk)
84
+ if (arr.length === 0) return
85
+ countRef.current += arr.length
86
+ setFiles((prev) => [...prev, ...arr])
87
+ }),
88
+ ),
89
+ Effect.matchCauseEffect({
90
+ onSuccess: () => Effect.sync(() => setScanning(false)),
91
+ onFailure: (cause) =>
92
+ Effect.sync(() => {
93
+ // Interruption is the normal teardown path — don't surface it.
94
+ if (Cause.hasInterrupts(cause)) return
95
+ setScanError(`scan failed: ${Cause.pretty(cause)}`)
96
+ setScanning(false)
97
+ }),
98
+ }),
99
+ )
100
+ const fiber = Effect.runFork(program)
101
+ return () => {
102
+ Effect.runFork(Fiber.interrupt(fiber))
103
+ }
104
+ }, [target, all, sort, mdx])
105
+
106
+ const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
107
+
108
+ return (
109
+ <Browser
110
+ files={files}
111
+ maxWidth={maxWidth}
112
+ discoveryStatus={discoveryStatus}
113
+ sidebarMode={sidebarMode}
114
+ updateNotice={updateNotice}
115
+ />
116
+ )
117
+ }
118
+
39
119
  export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppProps) => {
40
120
  const renderer = useRenderer()
41
121
  const { width, height } = useTerminalDimensions()
@@ -72,13 +152,14 @@ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppPr
72
152
  if (key.name === "l" && key.shift) toggleTone()
73
153
  })
74
154
 
155
+ const paneBorderSides: BorderSides[] = ["top", "bottom"]
156
+
75
157
  return (
76
158
  <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
159
+ <Header width={width} currentFile={title} />
77
160
  <box
78
- title={` ${title} `}
79
- titleAlignment="left"
80
161
  style={{
81
- border: true,
162
+ border: paneBorderSides,
82
163
  borderColor: colors.border,
83
164
  padding: 1,
84
165
  flexGrow: 1,
@@ -110,6 +191,8 @@ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppPr
110
191
  )
111
192
  }
112
193
 
194
+ let updateExitHookRegistered = false
195
+
113
196
  if (import.meta.main) {
114
197
  const args = parseArgv(Bun.argv.slice(2))
115
198
 
@@ -121,20 +204,29 @@ if (import.meta.main) {
121
204
  console.log(pkg.version)
122
205
  process.exit(0)
123
206
  }
124
-
125
- const themeId = args.theme ?? "opencode"
126
- if (!isThemeId(themeId)) {
127
- const known = themeDefinitions.map((t) => t.id).join(", ")
128
- console.error(`house: unknown theme "${themeId}". Known: ${known}`)
129
- process.exit(2)
207
+ if (args.configPath) {
208
+ console.log(defaultConfigPath())
209
+ process.exit(0)
130
210
  }
131
- const tone = args.tone ?? "dark"
132
- if (tone !== "dark" && tone !== "light") {
133
- console.error(`house: --tone must be "dark" or "light", got "${tone}"`)
211
+
212
+ const config = await Effect.runPromise(
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
+ }),
222
+ ).catch((err: unknown) => {
223
+ console.error(`house: ${formatConfigError(err)}`)
134
224
  process.exit(2)
135
- }
225
+ })
226
+ const { theme: themeId, tone, mdx } = config
136
227
  const themeDef = getThemeDefinition(themeId)
137
228
  if (themeDef === undefined) {
229
+ // Unreachable: Config.schema validated themeId against themeDefinitions.
138
230
  console.error(`house: unknown theme "${themeId}"`)
139
231
  process.exit(2)
140
232
  }
@@ -193,7 +285,25 @@ if (import.meta.main) {
193
285
  }
194
286
  sort = args.sort
195
287
  }
196
- await runTui({ target, themeId, tone, maxWidth, all: args.all, sort })
288
+ let sidebarMode: SidebarMode = "auto"
289
+ if (args.sidebar !== null) {
290
+ if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
291
+ console.error(`house: --sidebar must be "auto", "on", or "off", got "${args.sidebar}"`)
292
+ process.exit(2)
293
+ }
294
+ sidebarMode = args.sidebar
295
+ }
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
+ })
197
307
  }
198
308
  }
199
309
 
@@ -204,6 +314,11 @@ interface TuiBootOptions {
204
314
  readonly maxWidth: number | null
205
315
  readonly all: boolean
206
316
  readonly sort: SortOrder
317
+ readonly mdx: boolean
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
207
322
  }
208
323
 
209
324
  async function runTui({
@@ -213,6 +328,9 @@ async function runTui({
213
328
  maxWidth,
214
329
  all,
215
330
  sort,
331
+ mdx,
332
+ sidebarMode,
333
+ updateCheck,
216
334
  }: TuiBootOptions): Promise<void> {
217
335
  let stats: Awaited<ReturnType<typeof stat>>
218
336
  try {
@@ -222,25 +340,38 @@ async function runTui({
222
340
  process.exit(1)
223
341
  }
224
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
+
225
361
  const renderer = await createCliRenderer({ exitOnCtrlC: false })
226
362
  const initialTheme: ThemeState = { id: themeId, tone }
227
363
 
228
364
  if (stats.isDirectory()) {
229
- const files = await Effect.runPromise(
230
- walk(target, { all, sort }).pipe(
231
- Effect.tapError((err) =>
232
- Effect.sync(() => {
233
- console.error(`house: cannot walk ${target}: ${String(err.cause)}`)
234
- }),
235
- ),
236
- ),
237
- ).catch(() => {
238
- process.exit(1)
239
- })
240
- if (!Array.isArray(files)) process.exit(1)
241
365
  createRoot(renderer).render(
242
366
  <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
243
- <Browser files={files} title={target} maxWidth={maxWidth} />
367
+ <DiscoverShell
368
+ target={target}
369
+ all={all}
370
+ sort={sort}
371
+ mdx={mdx}
372
+ maxWidth={maxWidth}
373
+ sidebarMode={sidebarMode}
374
+ />
244
375
  </RegistryProvider>,
245
376
  )
246
377
  } else {
@@ -11,14 +11,18 @@ export type BrowserFocus = "sidebar" | "reader"
11
11
  export interface BrowserCtx {
12
12
  readonly files: readonly FileEntry[]
13
13
  readonly focus: BrowserFocus
14
- readonly sidebarVisible: boolean
14
+ /** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
15
+ readonly sidebarShown: boolean
15
16
  readonly helpVisible: boolean
16
17
  readonly filterOpen: boolean
18
+ readonly paletteOpen: boolean
17
19
  readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
18
20
  readonly setSelectedIndex: (updater: (prev: number) => number) => void
19
- readonly setSidebarVisible: (updater: (prev: boolean) => boolean) => void
21
+ /** Toggle `shown` and adjust focus per DESIGN.md §7.1 (see s-behavior table). */
22
+ readonly toggleShown: () => void
20
23
  readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
21
24
  readonly openFilter: () => void
25
+ readonly openPalette: () => void
22
26
  readonly cycleTheme: (delta: 1 | -1) => void
23
27
  readonly toggleTone: () => void
24
28
  readonly quit: () => void
@@ -37,7 +41,8 @@ const stepBy = (c: BrowserCtx, delta: number) =>
37
41
  c.setSelectedIndex((i) => clamp(i + delta, 0, lastIndex(c)))
38
42
 
39
43
  const inSidebar = (c: BrowserCtx) => c.focus === "sidebar"
40
- const inSidebarFilterClosed = (c: BrowserCtx) => inSidebar(c) && !c.filterOpen
44
+ const filterClosed = (c: BrowserCtx) => !c.filterOpen
45
+ const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
41
46
  const inReader = (c: BrowserCtx) => c.focus === "reader"
42
47
  const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
43
48
  const inReaderWithFiles = (c: BrowserCtx) => inReader(c) && haveFiles(c)
@@ -66,13 +71,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
66
71
  description: "Toggle sidebar visibility",
67
72
  hint: "sidebar",
68
73
  keys: ["s"],
69
- run: (c) => {
70
- const willHide = c.sidebarVisible
71
- c.setSidebarVisible((v) => !v)
72
- // When hiding the sidebar, move focus to the reader so input has a
73
- // target. When revealing it, move focus back to the sidebar.
74
- c.setFocus(willHide ? "reader" : "sidebar")
75
- },
74
+ run: (c) => c.toggleShown(),
76
75
  },
77
76
  {
78
77
  id: "help.toggle",
@@ -88,9 +87,25 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
88
87
  description: "Filter files (fuzzy match on path)",
89
88
  hint: "filter",
90
89
  keys: ["/"],
91
- when: inSidebarFilterClosed,
90
+ // Fires from anywhere except inside an already-open filter. `openFilter`
91
+ // itself force-opens the sidebar and moves focus there, so the binding
92
+ // no longer needs to gate on focus or sidebar visibility.
93
+ when: filterClosed,
92
94
  run: (c) => c.openFilter(),
93
95
  },
96
+ {
97
+ id: "palette.open",
98
+ group: "Global",
99
+ description: "Command palette",
100
+ hint: "palette",
101
+ keys: ["ctrl+p"],
102
+ // Filter swallows ctrl+p as a typed character in its own branch, so this
103
+ // `when` only matters when the palette is already open (which it
104
+ // shouldn't re-open). #70 Q2 — fires from everywhere except the filter,
105
+ // closes help on its way in (handled in Browser.tsx).
106
+ when: paletteClosed,
107
+ run: (c) => c.openPalette(),
108
+ },
94
109
  {
95
110
  id: "serve.current",
96
111
  group: "Global",
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Layout primitives for the two-pane shape (sidebar + reader).
3
+ *
4
+ * Visibility and width are decoupled (see DESIGN.md §7.1):
5
+ * visible = shown || focus === "sidebar"
6
+ * Width is a pure function of viewport, independent of visibility:
7
+ * resolveSidebarWidth(viewport, preferred)
8
+ *
9
+ * Both render code and key handlers consume these primitives — there is no
10
+ * parallel implementation living in JSX.
11
+ */
12
+
13
+ /** Minimum useful sidebar width; below this, file rows truncate too aggressively. */
14
+ export const SIDEBAR_MIN_WIDTH = 28
15
+ /** Maximum sidebar width; beyond this, wasted whitespace on wide terminals. */
16
+ export const SIDEBAR_MAX_WIDTH = 60
17
+ /** Reader pane minimum; below this, prose wraps unpleasantly. */
18
+ export const READER_MIN_WIDTH = 40
19
+ /** Column gap painted between the two panes when both are inline. */
20
+ export const DIVIDER_WIDTH = 1
21
+
22
+ /**
23
+ * Continuous clamp. `preferred` is the user's desired width (until #13 lands,
24
+ * derived from viewport). The reader-min ceiling means the sidebar yields
25
+ * space rather than squeezing the reader below readability.
26
+ *
27
+ * Result is not clamped *up* to SIDEBAR_MIN when the viewport itself is too
28
+ * narrow to hold both panes — the caller decides whether to render at all
29
+ * (e.g. single-pane stack instead of inline). See `canFitInline`.
30
+ */
31
+ export const resolveSidebarWidth = (viewport: number, preferred: number): number => {
32
+ const ceiling = viewport - DIVIDER_WIDTH - READER_MIN_WIDTH
33
+ const lower = Math.min(SIDEBAR_MIN_WIDTH, Math.max(0, ceiling))
34
+ const upper = Math.max(lower, ceiling)
35
+ return Math.max(lower, Math.min(upper, preferred))
36
+ }
37
+
38
+ /**
39
+ * Default preferred sidebar width, derived from viewport until persistent
40
+ * config (#13) provides a user-set value. Matches the previous inline math.
41
+ */
42
+ export const defaultPreferredWidth = (viewport: number): number =>
43
+ Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, Math.floor(viewport * 0.25)))
44
+
45
+ /**
46
+ * True when an inline (side-by-side) layout still gives the reader at least
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.
50
+ */
51
+ export const canFitInline = (viewport: number): boolean =>
52
+ viewport >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + READER_MIN_WIDTH
53
+
54
+ /**
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.
59
+ */
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
+ }