@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/CHANGELOG.md +47 -137
- package/README.md +15 -15
- package/package.json +1 -1
- package/src/Browser.tsx +212 -134
- package/src/CommandPalette.tsx +70 -46
- package/src/Footer.tsx +66 -26
- package/src/Header.tsx +68 -0
- package/src/HelpOverlay.tsx +59 -41
- package/src/brand.ts +7 -0
- package/src/cli/argv.ts +34 -3
- package/src/config/load.ts +84 -19
- package/src/discovery/walk.ts +8 -3
- package/src/index.tsx +64 -11
- package/src/layout/resolve.ts +9 -9
- package/src/theme/colors.ts +51 -15
- package/src/theme/types.ts +7 -0
- package/src/update/cache.ts +77 -0
- package/src/update/check.ts +165 -0
- package/src/update/compare.ts +41 -0
- package/src/update/notice.ts +29 -0
- package/src/update/runtime.ts +48 -0
- package/src/update/useUpdateNotice.ts +24 -0
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
|
|
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:
|
|
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({
|
|
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({
|
|
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
|
/>
|
package/src/layout/resolve.ts
CHANGED
|
@@ -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.
|
|
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
|
|
50
|
-
*
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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 = (
|
|
60
|
-
viewport >= TIGHT_VIEWPORT_THRESHOLD
|
|
60
|
+
export const initialShownForAuto = (_viewport: number): boolean => true
|
package/src/theme/colors.ts
CHANGED
|
@@ -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`
|
|
13
|
-
* text
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
package/src/theme/types.ts
CHANGED
|
@@ -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")
|