@carlesandres/house 0.4.13 → 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 (86) hide show
  1. package/CHANGELOG.md +32 -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 -71
  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/show.ts +0 -48
  24. package/src/discovery/walk.ts +0 -209
  25. package/src/index.tsx +0 -464
  26. package/src/io/clipboard.ts +0 -53
  27. package/src/io/editor.ts +0 -162
  28. package/src/io/readFile.ts +0 -14
  29. package/src/keymap/browser.ts +0 -335
  30. package/src/keymap/displayKey.ts +0 -17
  31. package/src/keymap/keymap.ts +0 -95
  32. package/src/layout/resolve.ts +0 -52
  33. package/src/layout/sidebarEmptyState.ts +0 -7
  34. package/src/layout/sidebarRow.ts +0 -69
  35. package/src/markdown/frontmatter.ts +0 -62
  36. package/src/serve/css.ts +0 -120
  37. package/src/serve/openBrowser.ts +0 -19
  38. package/src/serve/render.ts +0 -56
  39. package/src/serve/server.ts +0 -166
  40. package/src/theme/atom.ts +0 -23
  41. package/src/theme/colors.ts +0 -86
  42. package/src/theme/loader.ts +0 -110
  43. package/src/theme/registry.ts +0 -12
  44. package/src/theme/resolve.ts +0 -168
  45. package/src/theme/themes/aura.json +0 -58
  46. package/src/theme/themes/ayu.json +0 -69
  47. package/src/theme/themes/carbonfox.json +0 -201
  48. package/src/theme/themes/catppuccin-frappe.json +0 -186
  49. package/src/theme/themes/catppuccin-macchiato.json +0 -186
  50. package/src/theme/themes/catppuccin.json +0 -212
  51. package/src/theme/themes/cobalt2.json +0 -181
  52. package/src/theme/themes/cursor.json +0 -202
  53. package/src/theme/themes/dracula.json +0 -172
  54. package/src/theme/themes/everforest.json +0 -194
  55. package/src/theme/themes/flexoki.json +0 -190
  56. package/src/theme/themes/github.json +0 -186
  57. package/src/theme/themes/gruvbox.json +0 -195
  58. package/src/theme/themes/kanagawa.json +0 -180
  59. package/src/theme/themes/lucent-orng.json +0 -186
  60. package/src/theme/themes/material.json +0 -188
  61. package/src/theme/themes/matrix.json +0 -180
  62. package/src/theme/themes/mercury.json +0 -198
  63. package/src/theme/themes/monokai.json +0 -174
  64. package/src/theme/themes/nightowl.json +0 -174
  65. package/src/theme/themes/nord.json +0 -176
  66. package/src/theme/themes/one-dark.json +0 -184
  67. package/src/theme/themes/opencode.json +0 -198
  68. package/src/theme/themes/orng.json +0 -202
  69. package/src/theme/themes/osaka-jade.json +0 -193
  70. package/src/theme/themes/palenight.json +0 -175
  71. package/src/theme/themes/rosepine.json +0 -187
  72. package/src/theme/themes/solarized.json +0 -180
  73. package/src/theme/themes/synthwave84.json +0 -179
  74. package/src/theme/themes/tokyonight.json +0 -196
  75. package/src/theme/themes/vercel.json +0 -198
  76. package/src/theme/themes/vesper.json +0 -171
  77. package/src/theme/themes/zenburn.json +0 -176
  78. package/src/theme/types.ts +0 -115
  79. package/src/tips.ts +0 -88
  80. package/src/ui/middleTruncate.ts +0 -27
  81. package/src/update/cache.ts +0 -77
  82. package/src/update/check.ts +0 -165
  83. package/src/update/compare.ts +0 -41
  84. package/src/update/notice.ts +0 -29
  85. package/src/update/runtime.ts +0 -48
  86. 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,464 +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 { dirname, isAbsolute, relative, resolve } from "node:path"
6
- import { createCliRenderer } from "@opentui/core"
7
- import { createRoot } from "@opentui/react"
8
- import { RegistryProvider } from "@effect/atom-react"
9
- import { Cause, Duration, Effect, Fiber, Stream } from "effect"
10
- import { useEffect, useRef, useState } from "react"
11
- import pkg from "../package.json" with { type: "json" }
12
- import { Browser, type StartupFocus } from "./Browser.tsx"
13
- import { parseArgv, usage } from "./cli/argv.ts"
14
- import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
15
- import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
16
- import { walk, type FileEntry } from "./discovery/walk.ts"
17
- import { openInBrowser } from "./serve/openBrowser.ts"
18
- import { startServer } from "./serve/server.ts"
19
- import { setActiveTheme } from "./theme/colors.ts"
20
- import { themeAtom, type ThemeState } from "./theme/atom.ts"
21
- import { getThemeDefinition } from "./theme/registry.ts"
22
- import { formatQuitNotice } from "./update/notice.ts"
23
- import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
24
- import { useUpdateNotice } from "./update/useUpdateNotice.ts"
25
-
26
- /**
27
- * DiscoverShell — owns the streaming walk for the Browser. Mounts Browser
28
- * immediately with `files=[]` and pushes entries as the stream emits.
29
- *
30
- * Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
31
- * trigger one React render per file. Tuned by feel — small enough that
32
- * results still feel live on tiny trees, large enough to keep render
33
- * frequency sane on big ones. Revisit if profiling says otherwise.
34
- *
35
- * Cancellation: the walk runs on a forked fiber; unmount interrupts it.
36
- * `Quit` in Browser tears down the renderer and exits, which propagates
37
- * naturally — the cleanup effect still fires before process.exit completes.
38
- */
39
- const pathIsDirectory = async (path: string): Promise<boolean> => {
40
- try {
41
- return (await stat(path)).isDirectory()
42
- } catch {
43
- return false
44
- }
45
- }
46
-
47
- const findGitRoot = async (cwd: string): Promise<string> => {
48
- const start = resolve(cwd)
49
- let current = start
50
- for (;;) {
51
- if (await pathIsDirectory(resolve(current, ".git"))) return current
52
- const parent = dirname(current)
53
- if (parent === current) return start
54
- current = parent
55
- }
56
- }
57
-
58
- export const resolveDiscoveryRoot = async ({
59
- cliRoot,
60
- defaultRoot,
61
- cwd,
62
- }: {
63
- readonly cliRoot: string | null
64
- readonly defaultRoot: "cwd" | "git"
65
- readonly cwd: string
66
- }): Promise<string> => {
67
- if (cliRoot !== null) return cliRoot
68
- if (defaultRoot === "git") return findGitRoot(cwd)
69
- return cwd
70
- }
71
-
72
- export const validateDiscoveryRoot = async (root: string): Promise<void> => {
73
- let stats: Awaited<ReturnType<typeof stat>>
74
- try {
75
- stats = await stat(root)
76
- } catch (err) {
77
- throw new Error(`cannot access discovery root ${root}: ${String(err)}`)
78
- }
79
- if (!stats.isDirectory()) {
80
- throw new Error(`discovery root must be a directory, got file ${root}`)
81
- }
82
- }
83
-
84
- export const formatPartialDiscoveryStatus = ({
85
- skippedCount,
86
- lastSkippedPath,
87
- }: {
88
- readonly skippedCount: number
89
- readonly lastSkippedPath: string | null
90
- }): string | null => {
91
- if (skippedCount <= 0) return null
92
- const noun = skippedCount === 1 ? "directory" : "directories"
93
- const suffix = lastSkippedPath && skippedCount === 1 ? `: ${lastSkippedPath}` : ""
94
- return `scan incomplete: skipped ${skippedCount} ${noun}${suffix}`
95
- }
96
-
97
- export const formatFatalDiscoveryStatus = (): string => "scan failed: unable to read discovery root"
98
-
99
- interface DiscoverShellProps {
100
- readonly target: string
101
- readonly initialQuery: string
102
- /** Resolved discovery vocabulary from the config layer. The shift+a
103
- * toggle (#145) is session-only sugar that flips between this set
104
- * and the full vocabulary; the underlying categories remain
105
- * independent everywhere else. */
106
- readonly initialShow: readonly ShowCategory[]
107
- readonly extensions: readonly string[]
108
- readonly wrapWidth: number
109
- readonly initialWrap: boolean
110
- readonly startupFocus: StartupFocus
111
- }
112
-
113
- export const DiscoverShell = ({
114
- target,
115
- initialQuery,
116
- initialShow,
117
- extensions,
118
- wrapWidth,
119
- initialWrap,
120
- startupFocus,
121
- }: DiscoverShellProps) => {
122
- const updateNotice = useUpdateNotice()
123
- const [show, setShow] = useState<readonly ShowCategory[]>(initialShow)
124
- const [files, setFiles] = useState<readonly FileEntry[]>([])
125
- const [scanning, setScanning] = useState<boolean>(true)
126
- const [scanError, setScanError] = useState<string | null>(null)
127
- const [skippedDirCount, setSkippedDirCount] = useState<number>(0)
128
- const [lastSkippedDir, setLastSkippedDir] = useState<string | null>(null)
129
- // Files arrive in a ref-tracked count so the status string can show
130
- // "indexing… N" even when React hasn't yet flushed the latest setFiles.
131
- const countRef = useRef(0)
132
-
133
- useEffect(() => {
134
- // Restart from a clean slate every time the discovery set changes.
135
- // Required for the `all` toggle (#145): without this, a flip would
136
- // concatenate the new walk onto stale entries and leave `scanning`
137
- // stuck on whatever the previous walk last set it to.
138
- setFiles([])
139
- setScanning(true)
140
- setScanError(null)
141
- setSkippedDirCount(0)
142
- setLastSkippedDir(null)
143
- countRef.current = 0
144
- const warnedProgram = walk(target, {
145
- show,
146
- extensions,
147
- onWarning: ({ path }) => {
148
- const relativePath = relative(resolve(target), path)
149
- setSkippedDirCount((prev) => prev + 1)
150
- setLastSkippedDir(
151
- relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath)
152
- ? relativePath
153
- : path,
154
- )
155
- },
156
- }).pipe(
157
- Stream.groupedWithin(64, Duration.millis(60)),
158
- Stream.runForEach((chunk) =>
159
- Effect.sync(() => {
160
- const arr = Array.from(chunk)
161
- if (arr.length === 0) return
162
- countRef.current += arr.length
163
- setFiles((prev) => [...prev, ...arr])
164
- }),
165
- ),
166
- Effect.matchCauseEffect({
167
- onSuccess: () => Effect.sync(() => setScanning(false)),
168
- onFailure: (cause) =>
169
- Effect.sync(() => {
170
- if (Cause.hasInterrupts(cause)) return
171
- setScanError(formatFatalDiscoveryStatus())
172
- setScanning(false)
173
- }),
174
- }),
175
- )
176
- const fiber = Effect.runFork(warnedProgram)
177
- return () => {
178
- Effect.runFork(Fiber.interrupt(fiber))
179
- }
180
- }, [target, show, extensions])
181
-
182
- const discoveryStatus =
183
- scanError ??
184
- (scanning
185
- ? `indexing… ${countRef.current}`
186
- : formatPartialDiscoveryStatus({
187
- skippedCount: skippedDirCount,
188
- lastSkippedPath: lastSkippedDir,
189
- }))
190
-
191
- return (
192
- <Browser
193
- files={files}
194
- initialQuery={initialQuery}
195
- wrapWidth={wrapWidth}
196
- initialWrap={initialWrap}
197
- emptyRootLabel={target}
198
- discoveryStatus={discoveryStatus}
199
- startupFocus={startupFocus}
200
- updateNotice={updateNotice}
201
- onToggleAll={() => {
202
- // shift+a is the only place the categories are treated as a
203
- // single thing. If every category is already on, fall back
204
- // to "show none"; otherwise opt into the full vocabulary.
205
- // Each press is a stable round-trip between [] and full.
206
- const next: readonly ShowCategory[] =
207
- show.length === SHOW_CATEGORIES.length ? [] : [...SHOW_CATEGORIES]
208
- setShow(next)
209
- }}
210
- />
211
- )
212
- }
213
-
214
- export const resolveInitialQuery = ({
215
- pathArg,
216
- discoveryRoot,
217
- cwd,
218
- }: {
219
- readonly pathArg: string | null
220
- readonly discoveryRoot: string
221
- readonly cwd: string
222
- }): string => {
223
- if (pathArg === null) return ""
224
- const resolvedPath = resolve(cwd, pathArg)
225
- const rel = relative(discoveryRoot, resolvedPath)
226
- if (rel.length === 0) return ""
227
- if (!rel.startsWith("..") && !isAbsolute(rel)) return rel
228
- if (pathArg.startsWith("./")) return pathArg.slice(2)
229
- return pathArg
230
- }
231
-
232
- let updateExitHookRegistered = false
233
-
234
- if (import.meta.main) {
235
- const args = parseArgv(Bun.argv.slice(2))
236
-
237
- if (args.help) {
238
- console.log(usage)
239
- process.exit(0)
240
- }
241
- if (args.wrapConflict) {
242
- console.error("house: --wrap and --no-wrap cannot be used together")
243
- process.exit(2)
244
- }
245
- if (Bun.argv.slice(2).includes("--width") && args.width === null) {
246
- console.error("house: --width requires a positive integer")
247
- process.exit(2)
248
- }
249
- if (args.version) {
250
- console.log(pkg.version)
251
- process.exit(0)
252
- }
253
- if (args.configPath) {
254
- console.log(defaultConfigPath())
255
- process.exit(0)
256
- }
257
-
258
- // Parse --show eagerly so an invalid token fails fast with the CLI-style
259
- // "house: ..." message, before any I/O work in loadConfig kicks off.
260
- let cliShow: readonly ShowCategory[] | null = null
261
- if (args.show !== null) {
262
- const parsed = parseShowList(args.show)
263
- if (!parsed.ok) {
264
- console.error(
265
- `house: --show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
266
- )
267
- process.exit(2)
268
- }
269
- cliShow = parsed.value
270
- }
271
-
272
- const config = await Effect.runPromise(
273
- loadConfig({
274
- cli: {
275
- theme: args.theme,
276
- tone: args.tone,
277
- extensions:
278
- args.extensions === null
279
- ? null
280
- : args.extensions
281
- .split(",")
282
- .map((s) => s.trim())
283
- .filter(Boolean),
284
- // `--show` replaces env/file when present (set semantics —
285
- // no per-category merge across sources). `null` falls through.
286
- show: cliShow,
287
- focus:
288
- args.focus === "sidebar" || args.focus === "reader" || args.focus === "filter"
289
- ? args.focus
290
- : null,
291
- width:
292
- args.width === null
293
- ? null
294
- : (() => {
295
- const n = Number.parseInt(args.width, 10)
296
- if (!/^\d+$/.test(args.width) || !Number.isSafeInteger(n) || n <= 0) {
297
- console.error(`house: --width must be a positive integer, got "${args.width}"`)
298
- process.exit(2)
299
- }
300
- return n
301
- })(),
302
- wrap: args.wrap,
303
- },
304
- }),
305
- ).catch((err: unknown) => {
306
- console.error(`house: ${formatConfigError(err)}`)
307
- process.exit(2)
308
- })
309
- const {
310
- theme: themeId,
311
- tone,
312
- extensions,
313
- show,
314
- focus: startupFocus,
315
- defaultRoot,
316
- width: wrapWidth,
317
- wrap: initialWrap,
318
- } = config
319
- const themeDef = getThemeDefinition(themeId)
320
- if (themeDef === undefined) {
321
- // Unreachable: Config.schema validated themeId against themeDefinitions.
322
- console.error(`house: unknown theme "${themeId}"`)
323
- process.exit(2)
324
- }
325
- setActiveTheme(themeDef, tone)
326
-
327
- const cwd = process.cwd()
328
- const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
329
- const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
330
-
331
- if (args.serve) {
332
- const target = args.path
333
- if (target === null) {
334
- console.error("house: --serve requires a file path")
335
- process.exit(2)
336
- }
337
- let stats: Awaited<ReturnType<typeof stat>>
338
- try {
339
- stats = await stat(target)
340
- } catch (err) {
341
- console.error(`house: cannot access ${target}: ${String(err)}`)
342
- process.exit(1)
343
- }
344
- if (stats.isDirectory()) {
345
- console.error(`house: --serve requires a file, got directory ${target}`)
346
- process.exit(2)
347
- }
348
- let port = 0
349
- if (args.port !== null) {
350
- const n = Number.parseInt(args.port, 10)
351
- if (!Number.isFinite(n) || n < 0 || n > 65535) {
352
- console.error(`house: --port must be 0-65535, got "${args.port}"`)
353
- process.exit(2)
354
- }
355
- port = n
356
- }
357
- const handle = startServer({ path: target, port })
358
- console.log(`house serving ${target} at ${handle.url}`)
359
- console.log("press ctrl+c to stop")
360
- openInBrowser(handle.url)
361
- const shutdown = async () => {
362
- await handle.stop()
363
- process.exit(0)
364
- }
365
- process.on("SIGINT", shutdown)
366
- process.on("SIGTERM", shutdown)
367
- // Bun.serve keeps the event loop alive until stop().
368
- } else {
369
- if (args.focus !== null) {
370
- if (args.focus !== "sidebar" && args.focus !== "reader" && args.focus !== "filter") {
371
- console.error(
372
- `house: --focus must be "sidebar", "reader", or "filter", got "${args.focus}"`,
373
- )
374
- process.exit(2)
375
- }
376
- }
377
- await runTui({
378
- discoveryRoot,
379
- initialQuery,
380
- themeId,
381
- tone,
382
- wrapWidth,
383
- initialWrap,
384
- show,
385
- extensions,
386
- startupFocus,
387
- updateCheck: !args.noUpdateCheck,
388
- })
389
- }
390
- }
391
-
392
- interface TuiBootOptions {
393
- readonly discoveryRoot: string
394
- readonly initialQuery: string
395
- readonly themeId: string
396
- readonly tone: "dark" | "light"
397
- readonly wrapWidth: number
398
- readonly initialWrap: boolean
399
- readonly show: readonly ShowCategory[]
400
- readonly extensions: readonly string[]
401
- readonly startupFocus: StartupFocus
402
- /** Run the npm-registry probe and surface the "update available" notice.
403
- * False suppresses both the toast and the quit-time print. */
404
- readonly updateCheck: boolean
405
- }
406
-
407
- async function runTui({
408
- discoveryRoot,
409
- initialQuery,
410
- themeId,
411
- tone,
412
- wrapWidth,
413
- initialWrap,
414
- show,
415
- extensions,
416
- startupFocus,
417
- updateCheck,
418
- }: TuiBootOptions): Promise<void> {
419
- if (updateCheck) {
420
- // Fire the npm-registry probe in the background. Result lands in a
421
- // module singleton; the React tree picks it up via `useUpdateNotice`
422
- // for the footer toast, and the 'exit' hook below reads it
423
- // synchronously for the scrollback print. Failures are silent — this
424
- // whole feature is opportunistic.
425
- startUpdateProbe(pkg.name, pkg.version)
426
- // Register once per process. Multiple 'exit' listeners would print
427
- // the notice multiple times if runTui were ever re-entered.
428
- if (!updateExitHookRegistered) {
429
- updateExitHookRegistered = true
430
- process.on("exit", () => {
431
- const info = currentUpdateInfo()
432
- if (info) process.stderr.write(formatQuitNotice(info))
433
- })
434
- }
435
- }
436
-
437
- const renderer = await createCliRenderer({
438
- exitOnCtrlC: false,
439
- useMouse: true,
440
- enableMouseMovement: true,
441
- screenMode: "alternate-screen",
442
- })
443
- renderer.useMouse = true
444
- const initialTheme: ThemeState = { id: themeId, tone }
445
- try {
446
- await validateDiscoveryRoot(discoveryRoot)
447
- } catch (err) {
448
- console.error(`house: ${err instanceof Error ? err.message : String(err)}`)
449
- process.exit(1)
450
- }
451
- createRoot(renderer).render(
452
- <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
453
- <DiscoverShell
454
- target={discoveryRoot}
455
- initialQuery={initialQuery}
456
- initialShow={show}
457
- extensions={extensions}
458
- wrapWidth={wrapWidth}
459
- initialWrap={initialWrap}
460
- startupFocus={startupFocus}
461
- />
462
- </RegistryProvider>,
463
- )
464
- }