@carlesandres/house 0.4.14 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +25 -2
  2. package/README.md +10 -8
  3. package/dist/bin.js +92 -0
  4. package/dist/index.js +10005 -0
  5. package/package.json +15 -3
  6. package/src/Browser.tsx +0 -1210
  7. package/src/CommandPalette.tsx +0 -214
  8. package/src/Footer.tsx +0 -258
  9. package/src/Header.tsx +0 -160
  10. package/src/PromptRow.tsx +0 -51
  11. package/src/Spinner.tsx +0 -39
  12. package/src/StatusIndicator.tsx +0 -55
  13. package/src/StatusPopover.tsx +0 -166
  14. package/src/brand.ts +0 -7
  15. package/src/cli/argv.ts +0 -179
  16. package/src/commands/buildCommands.ts +0 -98
  17. package/src/commands/paletteOnlyCommands.ts +0 -27
  18. package/src/commands/score.ts +0 -78
  19. package/src/commands/types.ts +0 -26
  20. package/src/config/load.ts +0 -381
  21. package/src/config/save.ts +0 -95
  22. package/src/discovery/filter.ts +0 -148
  23. package/src/discovery/rootLabel.ts +0 -18
  24. package/src/discovery/show.ts +0 -48
  25. package/src/discovery/walk.ts +0 -209
  26. package/src/index.tsx +0 -469
  27. package/src/io/clipboard.ts +0 -53
  28. package/src/io/editor.ts +0 -162
  29. package/src/io/readFile.ts +0 -14
  30. package/src/keymap/browser.ts +0 -335
  31. package/src/keymap/displayKey.ts +0 -17
  32. package/src/keymap/keymap.ts +0 -95
  33. package/src/layout/resolve.ts +0 -52
  34. package/src/layout/sidebarEmptyState.ts +0 -7
  35. package/src/layout/sidebarRow.ts +0 -69
  36. package/src/markdown/frontmatter.ts +0 -62
  37. package/src/serve/css.ts +0 -120
  38. package/src/serve/openBrowser.ts +0 -19
  39. package/src/serve/render.ts +0 -56
  40. package/src/serve/server.ts +0 -166
  41. package/src/theme/atom.ts +0 -23
  42. package/src/theme/colors.ts +0 -86
  43. package/src/theme/loader.ts +0 -110
  44. package/src/theme/registry.ts +0 -12
  45. package/src/theme/resolve.ts +0 -168
  46. package/src/theme/themes/aura.json +0 -58
  47. package/src/theme/themes/ayu.json +0 -69
  48. package/src/theme/themes/carbonfox.json +0 -201
  49. package/src/theme/themes/catppuccin-frappe.json +0 -186
  50. package/src/theme/themes/catppuccin-macchiato.json +0 -186
  51. package/src/theme/themes/catppuccin.json +0 -212
  52. package/src/theme/themes/cobalt2.json +0 -181
  53. package/src/theme/themes/cursor.json +0 -202
  54. package/src/theme/themes/dracula.json +0 -172
  55. package/src/theme/themes/everforest.json +0 -194
  56. package/src/theme/themes/flexoki.json +0 -190
  57. package/src/theme/themes/github.json +0 -186
  58. package/src/theme/themes/gruvbox.json +0 -195
  59. package/src/theme/themes/kanagawa.json +0 -180
  60. package/src/theme/themes/lucent-orng.json +0 -186
  61. package/src/theme/themes/material.json +0 -188
  62. package/src/theme/themes/matrix.json +0 -180
  63. package/src/theme/themes/mercury.json +0 -198
  64. package/src/theme/themes/monokai.json +0 -174
  65. package/src/theme/themes/nightowl.json +0 -174
  66. package/src/theme/themes/nord.json +0 -176
  67. package/src/theme/themes/one-dark.json +0 -184
  68. package/src/theme/themes/opencode.json +0 -198
  69. package/src/theme/themes/orng.json +0 -202
  70. package/src/theme/themes/osaka-jade.json +0 -193
  71. package/src/theme/themes/palenight.json +0 -175
  72. package/src/theme/themes/rosepine.json +0 -187
  73. package/src/theme/themes/solarized.json +0 -180
  74. package/src/theme/themes/synthwave84.json +0 -179
  75. package/src/theme/themes/tokyonight.json +0 -196
  76. package/src/theme/themes/vercel.json +0 -198
  77. package/src/theme/themes/vesper.json +0 -171
  78. package/src/theme/themes/zenburn.json +0 -176
  79. package/src/theme/types.ts +0 -115
  80. package/src/tips.ts +0 -88
  81. package/src/ui/middleTruncate.ts +0 -27
  82. package/src/update/cache.ts +0 -77
  83. package/src/update/check.ts +0 -165
  84. package/src/update/compare.ts +0 -41
  85. package/src/update/notice.ts +0 -29
  86. package/src/update/runtime.ts +0 -48
  87. package/src/update/useUpdateNotice.ts +0 -24
@@ -1,209 +0,0 @@
1
- import { readdir, readFile } from "node:fs/promises"
2
- import { extname, join, relative, resolve } from "node:path"
3
- import { Data, Effect, Stream } from "effect"
4
- import ignore, { type Ignore } from "ignore"
5
- import type { ShowCategory } from "./show.ts"
6
-
7
- export interface FileEntry {
8
- /** Absolute path on disk. */
9
- readonly path: string
10
- /** Path relative to the discovery root, with forward slashes. */
11
- readonly relativePath: string
12
- /** File basename. */
13
- readonly name: string
14
- }
15
-
16
- export interface DiscoveryWarning {
17
- readonly path: string
18
- readonly cause: unknown
19
- }
20
-
21
- export interface WalkOptions {
22
- /** Categories of normally-skipped entries to opt into. Empty (the
23
- * default) yields the conservative set: no dotfiles, no gitignored
24
- * entries. Order is irrelevant — semantics are set membership. Hard
25
- * skips (`node_modules`, `.git`, `.venv`) always apply. */
26
- readonly show?: Iterable<ShowCategory>
27
- /** Additional file extensions to include alongside `.md` / `.markdown`.
28
- * Values may include or omit the leading dot. */
29
- readonly extensions?: Iterable<string>
30
- /** Non-fatal subtree read errors. Root-level failures still error the walk. */
31
- readonly onWarning?: ((warning: DiscoveryWarning) => void) | null
32
- }
33
-
34
- export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
35
- readonly root: string
36
- readonly cause: unknown
37
- }> {}
38
-
39
- const BASE_EXTENSIONS = new Set([".md", ".markdown"])
40
- const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
41
-
42
- const normalizeExtension = (ext: string): string => {
43
- const trimmed = ext.trim().toLowerCase()
44
- return trimmed.startsWith(".") ? trimmed : `.${trimmed}`
45
- }
46
-
47
- interface IgnoreLevel {
48
- readonly dir: string
49
- readonly ig: Ignore
50
- }
51
-
52
- const isIgnored = (
53
- entryPath: string,
54
- isDirectory: boolean,
55
- levels: readonly IgnoreLevel[],
56
- ): boolean => {
57
- for (const { dir, ig } of levels) {
58
- const rel = relative(dir, entryPath)
59
- if (!rel || rel.startsWith("..")) continue
60
- const candidate = isDirectory ? `${rel}/` : rel
61
- if (ig.ignores(candidate)) return true
62
- }
63
- return false
64
- }
65
-
66
- const tryLoadGitignore = async (dir: string): Promise<Ignore | null> => {
67
- try {
68
- const content = await readFile(join(dir, ".gitignore"), "utf8")
69
- return ignore().add(content)
70
- } catch {
71
- return null
72
- }
73
- }
74
-
75
- const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
76
- entries: readonly T[],
77
- ): T[] =>
78
- [...entries].sort((a, b) => {
79
- const aDir = a.isDirectory()
80
- const bDir = b.isDirectory()
81
- if (aDir !== bDir) {
82
- return aDir ? 1 : -1
83
- }
84
- return a.name.localeCompare(b.name)
85
- })
86
-
87
- /**
88
- * DFS generator. Yields each markdown FileEntry as it is discovered, before
89
- * descending further. Per-directory sort still happens before yielding so
90
- * arrival order is files-first and alphabetical within each group.
91
- *
92
- * Cancellation: `signal.aborted` is checked between syscalls. Node's
93
- * `readdir` doesn't accept an AbortSignal, so a single in-flight `readdir`
94
- * on a slow filesystem still runs to completion before we notice — the
95
- * generator only exits at the next checkpoint.
96
- */
97
- async function* walkDirGen(
98
- dirPath: string,
99
- rootPath: string,
100
- parentLevels: readonly IgnoreLevel[],
101
- opts: { showHidden: boolean; showGitignored: boolean; extensions: ReadonlySet<string> },
102
- onWarning: ((warning: DiscoveryWarning) => void) | null,
103
- signal: AbortSignal,
104
- ): AsyncGenerator<FileEntry, void, void> {
105
- if (signal.aborted) return
106
- const isRoot = dirPath === rootPath
107
-
108
- let levels = parentLevels
109
- if (!opts.showGitignored) {
110
- const ig = await tryLoadGitignore(dirPath)
111
- if (signal.aborted) return
112
- if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
113
- }
114
-
115
- let raw
116
- try {
117
- raw = await readdir(dirPath, { withFileTypes: true })
118
- } catch (error) {
119
- if (isRoot) throw error
120
- onWarning?.({ path: dirPath, cause: error })
121
- return
122
- }
123
- if (signal.aborted) return
124
-
125
- for (const entry of sortEntries(raw)) {
126
- if (signal.aborted) return
127
-
128
- // Never follow symlinks — cycle hazard, and a markdown reader doesn't
129
- // need them. May be relaxed (files only) in a later iteration.
130
- if (entry.isSymbolicLink()) continue
131
-
132
- const entryPath = join(dirPath, entry.name)
133
-
134
- if (entry.isDirectory()) {
135
- if (HARD_SKIP_DIRS.has(entry.name)) continue
136
- if (!opts.showHidden && entry.name.startsWith(".")) continue
137
- if (!opts.showGitignored && isIgnored(entryPath, true, levels)) continue
138
- yield* walkDirGen(entryPath, rootPath, levels, opts, onWarning, signal)
139
- continue
140
- }
141
-
142
- if (!entry.isFile()) continue
143
- if (!opts.showHidden && entry.name.startsWith(".")) continue
144
- const allowed = opts.extensions
145
- if (!allowed.has(extname(entry.name).toLowerCase())) continue
146
- if (!opts.showGitignored && isIgnored(entryPath, false, levels)) continue
147
-
148
- yield {
149
- path: entryPath,
150
- relativePath: relative(rootPath, entryPath),
151
- name: entry.name,
152
- }
153
- }
154
- }
155
-
156
- /**
157
- * Stream markdown files under `root`. Entries arrive in DFS order respecting
158
- * the per-directory sort. The stream is interruptible at syscall boundaries:
159
- * the consumer's teardown trips an AbortController, and the generator exits
160
- * at its next `signal.aborted` check.
161
- *
162
- * Rules (see DESIGN.md §6):
163
- * - Extensions: `.md`, `.markdown`, plus configured extras.
164
- * - Hard skips (always): `node_modules`, `.git`, `.venv`.
165
- * - Hidden files/dirs (leading `.`) skipped unless `show` contains `"hidden"`.
166
- * - `.gitignore` honored, including nested `.gitignore` files.
167
- * - Symlinks not followed.
168
- * - Sort: files before directories, alphabetical within each group.
169
- */
170
- export const walk = (
171
- root: string,
172
- options: WalkOptions = {},
173
- ): Stream.Stream<FileEntry, DiscoveryError> => {
174
- const absRoot = resolve(root)
175
- const show = new Set<ShowCategory>(options.show ?? [])
176
- const extensions = new Set(BASE_EXTENSIONS)
177
- for (const ext of options.extensions ?? []) extensions.add(normalizeExtension(ext))
178
- const opts = {
179
- showHidden: show.has("hidden"),
180
- showGitignored: show.has("gitignored"),
181
- extensions,
182
- }
183
- const onWarning = options.onWarning ?? null
184
- const controller = new AbortController()
185
- const iterable: AsyncIterable<FileEntry> = {
186
- [Symbol.asyncIterator]() {
187
- const gen = walkDirGen(absRoot, absRoot, [], opts, onWarning, controller.signal)
188
- return {
189
- next: () => gen.next(),
190
- return: async (value?: void) => {
191
- controller.abort()
192
- return gen.return(value as void)
193
- },
194
- }
195
- },
196
- }
197
- return Stream.fromAsyncIterable(iterable, (cause) => new DiscoveryError({ root, cause }))
198
- }
199
-
200
- /**
201
- * Test/convenience helper: collect the full walk into an array. Mirrors the
202
- * pre-streaming `walk()` signature so call sites that don't need streaming
203
- * (notably tests) stay terse.
204
- */
205
- export const walkToArray = (
206
- root: string,
207
- options: WalkOptions = {},
208
- ): Effect.Effect<readonly FileEntry[], DiscoveryError> =>
209
- Stream.runCollect(walk(root, options)).pipe(Effect.map((chunk) => Array.from(chunk)))
package/src/index.tsx DELETED
@@ -1,469 +0,0 @@
1
- #!/usr/bin/env bun
2
- /** house — entry point. Boots the browser TUI or `--serve` preview. */
3
-
4
- import { stat } from "node:fs/promises"
5
- import { homedir } from "node:os"
6
- import { dirname, isAbsolute, relative, resolve } from "node:path"
7
- import { createCliRenderer } from "@opentui/core"
8
- import { createRoot } from "@opentui/react"
9
- import { RegistryProvider } from "@effect/atom-react"
10
- import { Cause, Duration, Effect, Fiber, Stream } from "effect"
11
- import { useEffect, useRef, useState } from "react"
12
- import pkg from "../package.json" with { type: "json" }
13
- import { Browser, type StartupFocus } from "./Browser.tsx"
14
- import { parseArgv, usage } from "./cli/argv.ts"
15
- import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
16
- import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
17
- import { formatDiscoveryRootLabel } from "./discovery/rootLabel.ts"
18
- import { walk, type FileEntry } from "./discovery/walk.ts"
19
- import { openInBrowser } from "./serve/openBrowser.ts"
20
- import { startServer } from "./serve/server.ts"
21
- import { setActiveTheme } from "./theme/colors.ts"
22
- import { themeAtom, type ThemeState } from "./theme/atom.ts"
23
- import { getThemeDefinition } from "./theme/registry.ts"
24
- import { formatQuitNotice } from "./update/notice.ts"
25
- import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
26
- import { useUpdateNotice } from "./update/useUpdateNotice.ts"
27
-
28
- /**
29
- * DiscoverShell — owns the streaming walk for the Browser. Mounts Browser
30
- * immediately with `files=[]` and pushes entries as the stream emits.
31
- *
32
- * Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
33
- * trigger one React render per file. Tuned by feel — small enough that
34
- * results still feel live on tiny trees, large enough to keep render
35
- * frequency sane on big ones. Revisit if profiling says otherwise.
36
- *
37
- * Cancellation: the walk runs on a forked fiber; unmount interrupts it.
38
- * `Quit` in Browser tears down the renderer and exits, which propagates
39
- * naturally — the cleanup effect still fires before process.exit completes.
40
- */
41
- const pathIsDirectory = async (path: string): Promise<boolean> => {
42
- try {
43
- return (await stat(path)).isDirectory()
44
- } catch {
45
- return false
46
- }
47
- }
48
-
49
- const findGitRoot = async (cwd: string): Promise<string> => {
50
- const start = resolve(cwd)
51
- let current = start
52
- for (;;) {
53
- if (await pathIsDirectory(resolve(current, ".git"))) return current
54
- const parent = dirname(current)
55
- if (parent === current) return start
56
- current = parent
57
- }
58
- }
59
-
60
- export const resolveDiscoveryRoot = async ({
61
- cliRoot,
62
- defaultRoot,
63
- cwd,
64
- }: {
65
- readonly cliRoot: string | null
66
- readonly defaultRoot: "cwd" | "git"
67
- readonly cwd: string
68
- }): Promise<string> => {
69
- if (cliRoot !== null) return resolve(cwd, cliRoot)
70
- if (defaultRoot === "git") return findGitRoot(cwd)
71
- return cwd
72
- }
73
-
74
- export const validateDiscoveryRoot = async (root: string): Promise<void> => {
75
- let stats: Awaited<ReturnType<typeof stat>>
76
- try {
77
- stats = await stat(root)
78
- } catch (err) {
79
- throw new Error(`cannot access discovery root ${root}: ${String(err)}`)
80
- }
81
- if (!stats.isDirectory()) {
82
- throw new Error(`discovery root must be a directory, got file ${root}`)
83
- }
84
- }
85
-
86
- export const formatPartialDiscoveryStatus = ({
87
- skippedCount,
88
- lastSkippedPath,
89
- }: {
90
- readonly skippedCount: number
91
- readonly lastSkippedPath: string | null
92
- }): string | null => {
93
- if (skippedCount <= 0) return null
94
- const noun = skippedCount === 1 ? "directory" : "directories"
95
- const suffix = lastSkippedPath && skippedCount === 1 ? `: ${lastSkippedPath}` : ""
96
- return `scan incomplete: skipped ${skippedCount} ${noun}${suffix}`
97
- }
98
-
99
- export const formatFatalDiscoveryStatus = (): string => "scan failed: unable to read discovery root"
100
-
101
- interface DiscoverShellProps {
102
- readonly target: string
103
- readonly initialQuery: string
104
- /** Resolved discovery vocabulary from the config layer. The shift+a
105
- * toggle (#145) is session-only sugar that flips between this set
106
- * and the full vocabulary; the underlying categories remain
107
- * independent everywhere else. */
108
- readonly initialShow: readonly ShowCategory[]
109
- readonly rootLabel?: string
110
- readonly extensions: readonly string[]
111
- readonly wrapWidth: number
112
- readonly initialWrap: boolean
113
- readonly startupFocus: StartupFocus
114
- }
115
-
116
- export const DiscoverShell = ({
117
- target,
118
- initialQuery,
119
- initialShow,
120
- rootLabel = target,
121
- extensions,
122
- wrapWidth,
123
- initialWrap,
124
- startupFocus,
125
- }: DiscoverShellProps) => {
126
- const updateNotice = useUpdateNotice()
127
- const [show, setShow] = useState<readonly ShowCategory[]>(initialShow)
128
- const [files, setFiles] = useState<readonly FileEntry[]>([])
129
- const [scanning, setScanning] = useState<boolean>(true)
130
- const [scanError, setScanError] = useState<string | null>(null)
131
- const [skippedDirCount, setSkippedDirCount] = useState<number>(0)
132
- const [lastSkippedDir, setLastSkippedDir] = useState<string | null>(null)
133
- // Files arrive in a ref-tracked count so the status string can show
134
- // "indexing… N" even when React hasn't yet flushed the latest setFiles.
135
- const countRef = useRef(0)
136
-
137
- useEffect(() => {
138
- // Restart from a clean slate every time the discovery set changes.
139
- // Required for the `all` toggle (#145): without this, a flip would
140
- // concatenate the new walk onto stale entries and leave `scanning`
141
- // stuck on whatever the previous walk last set it to.
142
- setFiles([])
143
- setScanning(true)
144
- setScanError(null)
145
- setSkippedDirCount(0)
146
- setLastSkippedDir(null)
147
- countRef.current = 0
148
- const warnedProgram = walk(target, {
149
- show,
150
- extensions,
151
- onWarning: ({ path }) => {
152
- const relativePath = relative(resolve(target), path)
153
- setSkippedDirCount((prev) => prev + 1)
154
- setLastSkippedDir(
155
- relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath)
156
- ? relativePath
157
- : path,
158
- )
159
- },
160
- }).pipe(
161
- Stream.groupedWithin(64, Duration.millis(60)),
162
- Stream.runForEach((chunk) =>
163
- Effect.sync(() => {
164
- const arr = Array.from(chunk)
165
- if (arr.length === 0) return
166
- countRef.current += arr.length
167
- setFiles((prev) => [...prev, ...arr])
168
- }),
169
- ),
170
- Effect.matchCauseEffect({
171
- onSuccess: () => Effect.sync(() => setScanning(false)),
172
- onFailure: (cause) =>
173
- Effect.sync(() => {
174
- if (Cause.hasInterrupts(cause)) return
175
- setScanError(formatFatalDiscoveryStatus())
176
- setScanning(false)
177
- }),
178
- }),
179
- )
180
- const fiber = Effect.runFork(warnedProgram)
181
- return () => {
182
- Effect.runFork(Fiber.interrupt(fiber))
183
- }
184
- }, [target, show, extensions])
185
-
186
- const discoveryStatus =
187
- scanError ??
188
- (scanning
189
- ? `indexing… ${countRef.current}`
190
- : formatPartialDiscoveryStatus({
191
- skippedCount: skippedDirCount,
192
- lastSkippedPath: lastSkippedDir,
193
- }))
194
-
195
- return (
196
- <Browser
197
- files={files}
198
- initialQuery={initialQuery}
199
- wrapWidth={wrapWidth}
200
- initialWrap={initialWrap}
201
- rootLabel={rootLabel}
202
- discoveryStatus={discoveryStatus}
203
- startupFocus={startupFocus}
204
- updateNotice={updateNotice}
205
- onToggleAll={() => {
206
- // shift+a is the only place the categories are treated as a
207
- // single thing. If every category is already on, fall back
208
- // to "show none"; otherwise opt into the full vocabulary.
209
- // Each press is a stable round-trip between [] and full.
210
- const next: readonly ShowCategory[] =
211
- show.length === SHOW_CATEGORIES.length ? [] : [...SHOW_CATEGORIES]
212
- setShow(next)
213
- }}
214
- />
215
- )
216
- }
217
-
218
- export const resolveInitialQuery = ({
219
- pathArg,
220
- discoveryRoot,
221
- cwd,
222
- }: {
223
- readonly pathArg: string | null
224
- readonly discoveryRoot: string
225
- readonly cwd: string
226
- }): string => {
227
- if (pathArg === null) return ""
228
- const resolvedPath = resolve(cwd, pathArg)
229
- const rel = relative(discoveryRoot, resolvedPath)
230
- if (rel.length === 0) return ""
231
- if (!rel.startsWith("..") && !isAbsolute(rel)) return rel
232
- if (pathArg.startsWith("./")) return pathArg.slice(2)
233
- return pathArg
234
- }
235
-
236
- let updateExitHookRegistered = false
237
-
238
- if (import.meta.main) {
239
- const args = parseArgv(Bun.argv.slice(2))
240
-
241
- if (args.help) {
242
- console.log(usage)
243
- process.exit(0)
244
- }
245
- if (args.wrapConflict) {
246
- console.error("house: --wrap and --no-wrap cannot be used together")
247
- process.exit(2)
248
- }
249
- if (Bun.argv.slice(2).includes("--width") && args.width === null) {
250
- console.error("house: --width requires a positive integer")
251
- process.exit(2)
252
- }
253
- if (args.version) {
254
- console.log(pkg.version)
255
- process.exit(0)
256
- }
257
- if (args.configPath) {
258
- console.log(defaultConfigPath())
259
- process.exit(0)
260
- }
261
-
262
- // Parse --show eagerly so an invalid token fails fast with the CLI-style
263
- // "house: ..." message, before any I/O work in loadConfig kicks off.
264
- let cliShow: readonly ShowCategory[] | null = null
265
- if (args.show !== null) {
266
- const parsed = parseShowList(args.show)
267
- if (!parsed.ok) {
268
- console.error(
269
- `house: --show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
270
- )
271
- process.exit(2)
272
- }
273
- cliShow = parsed.value
274
- }
275
-
276
- const config = await Effect.runPromise(
277
- loadConfig({
278
- cli: {
279
- theme: args.theme,
280
- tone: args.tone,
281
- extensions:
282
- args.extensions === null
283
- ? null
284
- : args.extensions
285
- .split(",")
286
- .map((s) => s.trim())
287
- .filter(Boolean),
288
- // `--show` replaces env/file when present (set semantics —
289
- // no per-category merge across sources). `null` falls through.
290
- show: cliShow,
291
- focus:
292
- args.focus === "sidebar" || args.focus === "reader" || args.focus === "filter"
293
- ? args.focus
294
- : null,
295
- width:
296
- args.width === null
297
- ? null
298
- : (() => {
299
- const n = Number.parseInt(args.width, 10)
300
- if (!/^\d+$/.test(args.width) || !Number.isSafeInteger(n) || n <= 0) {
301
- console.error(`house: --width must be a positive integer, got "${args.width}"`)
302
- process.exit(2)
303
- }
304
- return n
305
- })(),
306
- wrap: args.wrap,
307
- },
308
- }),
309
- ).catch((err: unknown) => {
310
- console.error(`house: ${formatConfigError(err)}`)
311
- process.exit(2)
312
- })
313
- const {
314
- theme: themeId,
315
- tone,
316
- extensions,
317
- show,
318
- focus: startupFocus,
319
- defaultRoot,
320
- width: wrapWidth,
321
- wrap: initialWrap,
322
- } = config
323
- const themeDef = getThemeDefinition(themeId)
324
- if (themeDef === undefined) {
325
- // Unreachable: Config.schema validated themeId against themeDefinitions.
326
- console.error(`house: unknown theme "${themeId}"`)
327
- process.exit(2)
328
- }
329
- setActiveTheme(themeDef, tone)
330
-
331
- const cwd = process.cwd()
332
- const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
333
- const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
334
-
335
- if (args.serve) {
336
- const target = args.path
337
- if (target === null) {
338
- console.error("house: --serve requires a file path")
339
- process.exit(2)
340
- }
341
- let stats: Awaited<ReturnType<typeof stat>>
342
- try {
343
- stats = await stat(target)
344
- } catch (err) {
345
- console.error(`house: cannot access ${target}: ${String(err)}`)
346
- process.exit(1)
347
- }
348
- if (stats.isDirectory()) {
349
- console.error(`house: --serve requires a file, got directory ${target}`)
350
- process.exit(2)
351
- }
352
- let port = 0
353
- if (args.port !== null) {
354
- const n = Number.parseInt(args.port, 10)
355
- if (!Number.isFinite(n) || n < 0 || n > 65535) {
356
- console.error(`house: --port must be 0-65535, got "${args.port}"`)
357
- process.exit(2)
358
- }
359
- port = n
360
- }
361
- const handle = startServer({ path: target, port })
362
- console.log(`house serving ${target} at ${handle.url}`)
363
- console.log("press ctrl+c to stop")
364
- openInBrowser(handle.url)
365
- const shutdown = async () => {
366
- await handle.stop()
367
- process.exit(0)
368
- }
369
- process.on("SIGINT", shutdown)
370
- process.on("SIGTERM", shutdown)
371
- // Bun.serve keeps the event loop alive until stop().
372
- } else {
373
- if (args.focus !== null) {
374
- if (args.focus !== "sidebar" && args.focus !== "reader" && args.focus !== "filter") {
375
- console.error(
376
- `house: --focus must be "sidebar", "reader", or "filter", got "${args.focus}"`,
377
- )
378
- process.exit(2)
379
- }
380
- }
381
- await runTui({
382
- discoveryRoot,
383
- initialQuery,
384
- themeId,
385
- tone,
386
- wrapWidth,
387
- initialWrap,
388
- show,
389
- extensions,
390
- startupFocus,
391
- updateCheck: !args.noUpdateCheck,
392
- })
393
- }
394
- }
395
-
396
- interface TuiBootOptions {
397
- readonly discoveryRoot: string
398
- readonly initialQuery: string
399
- readonly themeId: string
400
- readonly tone: "dark" | "light"
401
- readonly wrapWidth: number
402
- readonly initialWrap: boolean
403
- readonly show: readonly ShowCategory[]
404
- readonly extensions: readonly string[]
405
- readonly startupFocus: StartupFocus
406
- /** Run the npm-registry probe and surface the "update available" notice.
407
- * False suppresses both the toast and the quit-time print. */
408
- readonly updateCheck: boolean
409
- }
410
-
411
- async function runTui({
412
- discoveryRoot,
413
- initialQuery,
414
- themeId,
415
- tone,
416
- wrapWidth,
417
- initialWrap,
418
- show,
419
- extensions,
420
- startupFocus,
421
- updateCheck,
422
- }: TuiBootOptions): Promise<void> {
423
- if (updateCheck) {
424
- // Fire the npm-registry probe in the background. Result lands in a
425
- // module singleton; the React tree picks it up via `useUpdateNotice`
426
- // for the footer toast, and the 'exit' hook below reads it
427
- // synchronously for the scrollback print. Failures are silent — this
428
- // whole feature is opportunistic.
429
- startUpdateProbe(pkg.name, pkg.version)
430
- // Register once per process. Multiple 'exit' listeners would print
431
- // the notice multiple times if runTui were ever re-entered.
432
- if (!updateExitHookRegistered) {
433
- updateExitHookRegistered = true
434
- process.on("exit", () => {
435
- const info = currentUpdateInfo()
436
- if (info) process.stderr.write(formatQuitNotice(info))
437
- })
438
- }
439
- }
440
-
441
- const renderer = await createCliRenderer({
442
- exitOnCtrlC: false,
443
- useMouse: true,
444
- enableMouseMovement: true,
445
- screenMode: "alternate-screen",
446
- })
447
- renderer.useMouse = true
448
- const initialTheme: ThemeState = { id: themeId, tone }
449
- try {
450
- await validateDiscoveryRoot(discoveryRoot)
451
- } catch (err) {
452
- console.error(`house: ${err instanceof Error ? err.message : String(err)}`)
453
- process.exit(1)
454
- }
455
- createRoot(renderer).render(
456
- <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
457
- <DiscoverShell
458
- target={discoveryRoot}
459
- initialQuery={initialQuery}
460
- initialShow={show}
461
- rootLabel={formatDiscoveryRootLabel({ discoveryRoot, home: homedir() })}
462
- extensions={extensions}
463
- wrapWidth={wrapWidth}
464
- initialWrap={initialWrap}
465
- startupFocus={startupFocus}
466
- />
467
- </RegistryProvider>,
468
- )
469
- }