@erclx/aitk 3.45.0 → 3.46.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.45.0",
4
+ "version": "3.46.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -13,6 +13,8 @@ It also produces one output where two are needed. A page written to be worked th
13
13
 
14
14
  A session that does record something records the wrong thing. It writes what was taught rather than what the learner retrieved, and a tally of errors carries none of the misconception a later session would work against.
15
15
 
16
+ The lesson then reaches nobody. It is a page carrying a stylesheet and a script, and the only thing a session hands over is a file path, which an editor preview opens with neither. The learner reads unstyled markup and takes it for the lesson, or opens nothing at all, and either way the session reports the lesson as delivered. A path is also the wrong unit once a workspace holds several pages, since the learner wants the one they are on rather than the folder it sits in.
17
+
16
18
  The durable half then has nowhere to go. A reference page and a glossary carry no learner and are ordinary reference prose, so they belong wherever the project already keeps prose on that subject, and a workspace holding them is a gitignored folder one person reads. A session moving them by hand picks a destination from the reader's activity rather than from who owns the subject, drops a page into a corpus without the source line that corpus requires, and has nothing stopping it from carrying a lesson across.
17
19
 
18
20
  Two failures land specifically on where the folder sits. A workspace resolved against the current directory forks into a copy per linked worktree, so the learning records split and no session sees the whole history. A body naming only the destination path reports success and loses the write, because the file-editing tools refuse a main-root path from a linked worktree and offer a worktree copy instead.
@@ -28,6 +30,7 @@ Two failures land specifically on where the folder sits. A workspace resolved ag
28
30
  - Split the output by lifetime, sending the worked-through half to lessons and the looked-up half to reference pages in the format the authoring gates read
29
31
  - Record the wrong answer itself rather than the count, since that is what the next session places the learner from
30
32
  - Report progress against the mission's success lines, so a mission can be called finished
33
+ - Hand the learner a link that opens the workspace in a real browser on every run, reading it back from the verb that served it rather than composing one, since the port a preview lands on is not the port it asked for
31
34
  - Propose a destination for each durable page by who owns its subject, and wait for the operator rather than routing on the session's own reading
32
35
  - Hand each confirmed page off through a named file of its own, so one skill owns the durable writes and no other producer's unread work is discarded with it
33
36
  - Name what the destination expects that the page does not carry yet, since the workspace runs none of the gates the destination does
@@ -113,6 +113,18 @@ aitk teach glossary <topic> --json \
113
113
 
114
114
  Follow `${CLAUDE_SKILL_DIR}/references/lesson-craft.md` for what makes a lesson worth returning to. Keep every quiz answer the same length, so formatting leaks no clue about which one is correct.
115
115
 
116
+ ### Hand over a link, never a path
117
+
118
+ A lesson is a page carrying a stylesheet and a script, and an editor preview opens it with neither, so a path alone delivers unstyled markup that reads as the lesson. Serve the teach root and give the learner a link they can click:
119
+
120
+ ```bash
121
+ aitk serve .claude/teach --entry <nn>-<topic>/index.html --json
122
+ ```
123
+
124
+ Start it in the background so the session keeps going, and read `url` off the record rather than composing one. The verb walks past a port already in use, so the port it took is exactly the half a guessed URL gets wrong. Report the refusal and its `reason` when `ok` is false, and report it rather than proceeding silently when the verb does not resolve at all, which is an installed CLI predating it.
125
+
126
+ Do this on every run that opens or resumes a workspace, including one that writes no lesson, since the learner's route into what is already there is the same link.
127
+
116
128
  ## Step 5: record what happened
117
129
 
118
130
  Write `learning-records/<nnnn>-<slug>.md` before the session ends, carrying the lessons covered, what the learner retrieved unaided, what they got wrong with the wrong answer itself, and what to revisit.
@@ -165,9 +177,14 @@ Lesson: .claude/teach/<nn>-<topic>/lessons/<nnnn>-<slug>.html
165
177
  Reference: .claude/teach/<nn>-<topic>/reference/<slug>.md
166
178
  Record: .claude/teach/<nn>-<topic>/learning-records/<nnnn>-<slug>.md
167
179
  Progress: <n> of <m> success lines met
180
+ Open: [<the url the serve verb reported>](<the same url>)
168
181
  ```
169
182
 
170
- Omit the reference line where the lesson produced no durable page. Emit every path from the project root, in the form the project's instruction file sets.
183
+ Omit the reference line where the lesson produced no durable page. The open line is the one line that is never omitted, since it is the only route the learner has into the page, and it carries what `aitk serve` reported rather than a URL composed here. Where the verb refused, that line names the refusal instead of a link.
184
+
185
+ Write that line as a markdown link carrying the URL as both its text and its target, rather than as a bare URL and never inside backticks. A code span renders as text the reader has to select and copy, which is the one thing the line exists to save them, and the path rule the project states governs a file path rather than a URL.
186
+
187
+ Emit every path from the project root, in the form the project's instruction file sets.
171
188
 
172
189
  A promotion pass reports its own shape instead, one line per page the operator confirmed and one naming the handoff:
173
190
 
@@ -63,8 +63,17 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
63
63
  | `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
64
64
  | `aitk inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
65
65
  | `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
66
+ | `aitk serve [dir]` | Serve a directory on the loopback interface and print the link that opens it, running until interrupted (`--port`, `--entry`, `--json`) |
66
67
  | `aitk upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
67
68
 
69
+ `aitk serve` ships and drives no browser, which is what separates it from the two that do. A generated page loses its stylesheet and its script to an editor preview and to a `file://` open, so the link is the delivery rather than a convenience, and every generated surface here reaches a reader through one. It binds `127.0.0.1` and never a wildcard, because what it is pointed at is routinely a gitignored record tree. It sends `cache-control: no-store`, since a preview exists to be edited and reloaded and a cached stylesheet reads as a fix that did not work.
70
+
71
+ A port already in use is the ordinary case rather than a refusal, so it walks forward to the next free one and reports which it took. That is why a caller reads `url` off the `--json` record instead of composing one from the port it asked for. Only contention is walked past. Any other bind failure refuses as `bind-failed` carrying the error's code, rather than being retried twenty times and reported as a range being full, which names a cause nothing checked.
72
+
73
+ A request naming a directory is redirected to its trailing-slash form rather than answered in place. A browser resolves a relative asset against the last slash of the URL it is on, so answering `/lesson` directly leaves the page asking for `/course.css` instead of `/lesson/course.css`, and it renders unstyled through the server that exists to prevent exactly that.
74
+
75
+ Containment is tested after symlinks are followed rather than on the path as written, and the test sits immediately before the read rather than beside the request that produced it. Resolving a request lexically clears a link pointing outside the served root, and this repository is a live instance of that shape, since `claude/standards` and `claude/snippets` are links out of `claude/`. Position is what makes the property hold: a directory request appends its index after the request path has been checked, so a check placed earlier leaves that index untested. An `--entry` that escapes the root refuses with `no-entry` before a port is taken, because `url` is the field a caller hands to a reader.
76
+
68
77
  `aitk demo` is the second browser command and the one that ships, since its purpose is running in a target rather than regenerating what this repository commits. It needs a browser binary the package does not carry, installed once with `bunx playwright install chromium`.
69
78
 
70
79
  `aitk inventory` is the third and takes the same answer for the same reason. It reads `inventory.toml` at the project root for its base URL, its routes, and the element query each subject runs over, so what it walks comes from the project rather than from the toolkit. It reports how many different answers a site gives for one property and never gates, because whether five focus rings across four routes is a defect is a judgment. A missing server and an unmatched query are both refusals rather than empty listings, since a listing with no rows reads as one consistent answer.
@@ -59,7 +59,7 @@ The article gate is looser than the verb list and carries a real cost. Re-runnin
59
59
 
60
60
  A delta phrased as a transition (`from fourteen to fifteen`), a fraction (`thirteen of sixteen`), and a total reached through an indirect noun (`denominator of sixty-one shipped skills`) are all catalog-size claims this corpus carries, and none matches the trigger shape this reads. Each stays a known gap. The false-positive rate that gated closing them has a measurement behind it now, and what it showed is that the widening these three need is the one that costs a false positive rather than the one that does not.
61
61
 
62
- A second figure in a sentence whose first figure already matched is a fourth gap and a structural one, since one match is taken per catalog per sentence. `authors 70 rules under governance/rules/ and consumes 55 into .claude/rules/` is read for its 70 alone, which is correct here because the two figures name different populations, and a sentence stating one catalog twice would go unread the same way.
62
+ A second figure in a sentence whose first figure already matched is a fourth gap and a structural one, since one match is taken per catalog per sentence. `authors 70 rules under governance/rules/ and consumes 62 into .claude/rules/` is read for its 70 alone, which is correct here because the two figures name different populations, and a sentence stating one catalog twice would go unread the same way.
63
63
 
64
64
  A calendar date (`2026-08-21`) or a backticked commit reference in the same sentence reads the whole sentence past, since that is how this corpus already marks a figure as a historical record rather than a live claim. `.claude/ARCHITECTURE.md` and the context entries carry a figure this way deliberately, and every one of them stays correct forever.
65
65
 
@@ -129,6 +129,18 @@ Each `quiz` entry carries `order`, the authored option indices in presentation o
129
129
 
130
130
  The order is drawn here rather than instructed, and that is the point of the verb. An author told to vary the position still varies it by judgment, and the judgment settles on the first slot, which is the defect this design departs from. The draw is uniform over the options, so the position carries no information about which answer is correct.
131
131
 
132
+ ## Opening a workspace
133
+
134
+ No `aitk teach` verb serves the workspace. `aitk serve` does, taking the teach root as its directory and the workspace contents page as its entry:
135
+
136
+ ```bash
137
+ aitk serve .claude/teach --entry 03-fde-system-design/index.html --json
138
+ ```
139
+
140
+ It stays general rather than becoming `aitk teach serve`, because nothing about serving a directory is specific to a learning workspace, and the same verb carries a slide render and a design preview.
141
+
142
+ Read `url` off the record rather than building one from the port that was asked for. The verb walks forward past a port already in use, which is routine when a second workspace is already open, and the port it took is the one thing a composed URL gets wrong.
143
+
132
144
  ## Refusal reasons
133
145
 
134
146
  | Reason | Raised when |
@@ -9,5 +9,5 @@ paths:
9
9
  ## Authority
10
10
 
11
11
  - Follow the teach standard for the folder layout, ordinal naming, frontmatter, and the mission and learning-record formats. It is the single source. Read it with `aitk standards teach`.
12
- - Read `references/glossary.md` inside the `aitk:claude-teach` skill for the glossary every workspace carries at its root. It is the single source for the entry shape, the ordering, and which terms the file carries. Read it rather than invoking the skill, which resumes a workspace and runs lessons.
12
+ - Follow the glossary standard for the glossary every workspace carries at its root. It is the single source for the entry shape, the ordering, and which terms the file carries. Read it with `aitk standards glossary`.
13
13
  - Report it rather than proceeding silently when that file does not resolve. It ships with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have it.
@@ -4,6 +4,7 @@ paths:
4
4
  - '**/*.tsx'
5
5
  - '**/*.jsx'
6
6
  - '**/*.astro'
7
+ - '**/*.html'
7
8
  ---
8
9
 
9
10
  # UI copy standards
@@ -4,6 +4,8 @@ paths:
4
4
  - '**/*.tsx'
5
5
  - '**/*.jsx'
6
6
  - '**/*.astro'
7
+ - '**/*.html'
8
+ - '**/*.css'
7
9
  ---
8
10
 
9
11
  # Accessibility and keyboard standards
@@ -4,6 +4,7 @@ paths:
4
4
  - '**/*.tsx'
5
5
  - '**/*.jsx'
6
6
  - '**/*.astro'
7
+ - '**/*.html'
7
8
  ---
8
9
 
9
10
  # Form standards
@@ -4,6 +4,8 @@ paths:
4
4
  - '**/*.tsx'
5
5
  - '**/*.jsx'
6
6
  - '**/*.astro'
7
+ - '**/*.html'
8
+ - '**/*.css'
7
9
  ---
8
10
 
9
11
  # UX completeness standards
@@ -1,24 +1,32 @@
1
1
  ---
2
- description: Require a route capture against a running preview after a page or route surface changes
2
+ description: Require a capture against a running preview after a rendered surface changes
3
3
  paths:
4
4
  - '**/routes/**/*.{tsx,jsx,vue,svelte,astro}'
5
5
  - '**/pages/**/*.{tsx,jsx,vue,svelte,astro}'
6
6
  - '**/app/**/page.{tsx,jsx}'
7
+ - '**/*.html'
7
8
  ---
8
9
 
9
10
  # Surface capture standards
10
11
 
12
+ ## What a surface is
13
+
14
+ - A surface is anything the project renders for a person to look at. A framework route is one. A page the project generates is another, whoever generates it.
15
+ - Judge a generated page by the same rule as a route. Reading its markup reports nothing about how it composes.
16
+
11
17
  ## When to capture
12
18
 
13
- - Run `bun run screenshot` after changing what a route renders.
19
+ - Run the project's capture command after changing what a surface renders. Report it rather than proceeding silently when the project has none.
14
20
  - Capture against a running preview server. Do not capture against a dev server.
15
- - Capture every theme the route ships. Do not capture the default theme alone.
21
+ - Capture every theme the surface ships. Do not capture the default theme alone.
22
+ - Capture again after fixing a defect a capture found. A repair inside a shared stylesheet can cancel a rule written earlier in the same file.
16
23
 
17
24
  ## What a capture covers
18
25
 
19
26
  - Capture the full page at the viewport its case declares. Do not capture a component in isolation.
20
- - Add a case to the capture record when adding a route.
21
- - Remove a route's case in the change that removes the route.
27
+ - Drive a state a screenshot cannot reach. An initial render reports nothing about a menu that opens, an answer that is chosen, or a rail that tracks scrolling.
28
+ - Add a case to the capture record when adding a surface.
29
+ - Remove a surface's case in the change that removes the surface.
22
30
 
23
31
  ## Sharing a capture
24
32
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.45.0",
4
+ "version": "3.46.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ import { register as docs } from '@/commands/docs'
15
15
  import { register as design } from '@/commands/design'
16
16
  import { register as slides } from '@/commands/slides'
17
17
  import { register as capture } from '@/commands/capture'
18
+ import { register as serve } from '@/commands/serve'
18
19
  import { register as demo } from '@/commands/demo'
19
20
  import { register as inventory } from '@/commands/inventory'
20
21
  import { register as feedback } from '@/commands/feedback'
@@ -61,6 +62,7 @@ function showHelp(): void {
61
62
  `${GREY}│${NC} design [cmd] ${GREY}# Design system commands (render)${NC}`,
62
63
  `${GREY}│${NC} slides [cmd] ${GREY}# Slide deck commands (render, list)${NC}`,
63
64
  `${GREY}│${NC} capture [source] ${GREY}# Render HTML capture sources to PNG${NC}`,
65
+ `${GREY}│${NC} serve [dir] ${GREY}# Serve a directory over localhost and print the preview link${NC}`,
64
66
  `${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
65
67
  `${GREY}│${NC} inventory [subj] ${GREY}# Report one computed property across every route${NC}`,
66
68
  `${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/feedback/${NC}`,
@@ -108,6 +110,7 @@ function showHelp(): void {
108
110
  `${GREY}│${NC} aitk slides render`,
109
111
  `${GREY}│${NC} aitk slides list --json`,
110
112
  `${GREY}│${NC} aitk capture assets/install.html`,
113
+ `${GREY}│${NC} aitk serve .claude/teach`,
111
114
  `${GREY}│${NC} aitk inventory focus --json`,
112
115
  `${GREY}│${NC} pbpaste | aitk feedback`,
113
116
  `${GREY}│${NC} aitk transcripts https://youtu.be/VIDEO_ID`,
@@ -160,6 +163,7 @@ docs(program)
160
163
  design(program)
161
164
  slides(program)
162
165
  capture(program)
166
+ serve(program)
163
167
  demo(program)
164
168
  inventory(program)
165
169
  feedback(program)
@@ -0,0 +1,159 @@
1
+ import type { Command } from 'commander'
2
+ import { relative } from 'node:path'
3
+ import { DEFAULT_PORT, type ServeOutcome, startServer } from '@/serve/static'
4
+ import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
5
+
6
+ const DEFAULT_DIR = '.'
7
+
8
+ interface ServeCommandOptions {
9
+ readonly entry?: string
10
+ readonly json?: boolean
11
+ readonly port?: string
12
+ }
13
+
14
+ export function register(program: Command): void {
15
+ program
16
+ .command('serve')
17
+ .description('Serve a directory over localhost and print the preview link')
18
+ .argument('[dir]', 'Directory to serve', DEFAULT_DIR)
19
+ .helpOption('-h, --help', 'Show this help message')
20
+ .option('--port <number>', `Port to try first, default ${DEFAULT_PORT}`)
21
+ .option('--entry <path>', 'Page the printed link opens, default index.html')
22
+ .option('--json', 'Emit a machine-readable record on stdout')
23
+ .addHelpText(
24
+ 'after',
25
+ [
26
+ '',
27
+ 'Exit codes:',
28
+ ' 0 the server stopped after running',
29
+ ' 1 refused, with the reason on stderr or in the JSON record',
30
+ '',
31
+ 'The server binds 127.0.0.1 and nothing else, and it sends no cache',
32
+ 'headers, so an edited stylesheet is never served stale. A port in use',
33
+ 'is not a failure: the next free one is taken and the link says which.',
34
+ '',
35
+ 'It runs until interrupted. A session wanting the link without waiting',
36
+ 'starts it in the background and reads the record off stdout.',
37
+ '',
38
+ 'Examples:',
39
+ ' aitk serve .claude/teach',
40
+ ' aitk serve .claude/teach --entry 03-fde-system-design/index.html',
41
+ ' aitk serve dist --port 4000 --json',
42
+ '',
43
+ ].join('\n'),
44
+ )
45
+ .action(async (dir: string, opts: ServeCommandOptions) => {
46
+ process.exitCode = await runServe(dir, opts)
47
+ })
48
+ }
49
+
50
+ async function runServe(
51
+ dir: string,
52
+ opts: ServeCommandOptions,
53
+ ): Promise<number> {
54
+ const emitJson = opts.json ?? false
55
+
56
+ const port = parsePort(opts.port)
57
+ if (port === undefined) {
58
+ return report(
59
+ { ok: false, reason: 'no-port', detail: `${opts.port} is not a port` },
60
+ emitJson,
61
+ )
62
+ }
63
+
64
+ const outcome = startServer(dir, { port, entry: opts.entry })
65
+ const code = report(outcome, emitJson)
66
+ if (!outcome.ok) return code
67
+
68
+ await waitForInterrupt(outcome.stop)
69
+ return 0
70
+ }
71
+
72
+ /**
73
+ * The relative path where it stays inside the working directory, and the
74
+ * absolute one where it climbs out. A run from a linked worktree serving the
75
+ * main root reports `../../teach`, which names the directory without giving a
76
+ * reader anything they can open.
77
+ */
78
+ function displayPath(root: string): string {
79
+ const near = relative(process.cwd(), root)
80
+ if (near === '') return '.'
81
+ return near.startsWith('..') ? root : near
82
+ }
83
+
84
+ /**
85
+ * Undefined for a value that is not a port, distinct from an absent flag,
86
+ * which takes the default. A `Number` of a typo is `NaN`, and passing that on
87
+ * asks the runtime to bind a port nobody named.
88
+ */
89
+ function parsePort(raw: string | undefined): number | undefined {
90
+ if (raw === undefined) return DEFAULT_PORT
91
+ const value = Number(raw)
92
+ if (!Number.isInteger(value) || value < 0 || value > 65535) return undefined
93
+ return value
94
+ }
95
+
96
+ function report(outcome: ServeOutcome, emitJson: boolean): number {
97
+ if (!outcome.ok) {
98
+ if (emitJson) {
99
+ process.stdout.write(
100
+ `${JSON.stringify({ ok: false, reason: outcome.reason, detail: outcome.detail })}\n`,
101
+ )
102
+ return 1
103
+ }
104
+ intro('aitk serve')
105
+ logError(outcome.detail)
106
+ outro()
107
+ return 1
108
+ }
109
+
110
+ if (emitJson) {
111
+ process.stdout.write(
112
+ `${JSON.stringify({
113
+ ok: true,
114
+ root: outcome.root,
115
+ host: outcome.host,
116
+ port: outcome.port,
117
+ entry: outcome.entry,
118
+ url: outcome.url,
119
+ entryExists: outcome.entryExists,
120
+ })}\n`,
121
+ )
122
+ return 0
123
+ }
124
+
125
+ intro('aitk serve')
126
+ logStep('Serving')
127
+ logInfo(displayPath(outcome.root))
128
+ logStep('Open')
129
+ logInfo(outcome.url)
130
+
131
+ if (!outcome.entryExists) {
132
+ logStep('No entry page')
133
+ logWarn(
134
+ `${outcome.entry} is not in that directory, so the link opens a 404`,
135
+ )
136
+ }
137
+
138
+ logStep('Stop')
139
+ logInfo('Ctrl-C')
140
+ return 0
141
+ }
142
+
143
+ /**
144
+ * Resolves when the process is asked to stop. The frame is closed here rather
145
+ * than in the reporter, because the run is the serving rather than the start,
146
+ * and closing at start would print the frame's end while the server ran on.
147
+ */
148
+ function waitForInterrupt(stop: () => Promise<void>): Promise<void> {
149
+ return new Promise((settle) => {
150
+ const finish = () => {
151
+ void stop().then(() => {
152
+ outro()
153
+ settle()
154
+ })
155
+ }
156
+ process.once('SIGINT', finish)
157
+ process.once('SIGTERM', finish)
158
+ })
159
+ }
@@ -0,0 +1,322 @@
1
+ import { existsSync, realpathSync, statSync } from 'node:fs'
2
+ import { join, resolve, sep } from 'node:path'
3
+
4
+ /**
5
+ * The loopback interface, never a wildcard bind. A preview serves whatever
6
+ * directory it is pointed at, and the folders this exists for are the
7
+ * gitignored record trees, so reaching the network is the one thing it must
8
+ * not do.
9
+ */
10
+ export const SERVE_HOST = '127.0.0.1'
11
+
12
+ /** Tried first, then the next ports in order, so a second preview still opens. */
13
+ export const DEFAULT_PORT = 8787
14
+
15
+ /** How far past the requested port to look before refusing. */
16
+ const PORT_ATTEMPTS = 20
17
+
18
+ const DEFAULT_ENTRY = 'index.html'
19
+
20
+ /**
21
+ * Extensions a browser has to be told about. Anything absent is served as an
22
+ * octet stream, which downloads rather than renders, and that is the safe
23
+ * direction for a type this map does not claim to know.
24
+ */
25
+ const CONTENT_TYPES: Readonly<Record<string, string>> = {
26
+ css: 'text/css; charset=utf-8',
27
+ gif: 'image/gif',
28
+ htm: 'text/html; charset=utf-8',
29
+ html: 'text/html; charset=utf-8',
30
+ ico: 'image/x-icon',
31
+ jpeg: 'image/jpeg',
32
+ jpg: 'image/jpeg',
33
+ js: 'text/javascript; charset=utf-8',
34
+ json: 'application/json; charset=utf-8',
35
+ /**
36
+ * Plain text rather than `text/markdown`, which a browser offers to save
37
+ * instead of showing. A reader following a link to a source page wants to
38
+ * read it, and the rendered sibling is a separate file.
39
+ */
40
+ md: 'text/plain; charset=utf-8',
41
+ mjs: 'text/javascript; charset=utf-8',
42
+ pdf: 'application/pdf',
43
+ png: 'image/png',
44
+ svg: 'image/svg+xml',
45
+ txt: 'text/plain; charset=utf-8',
46
+ webp: 'image/webp',
47
+ woff: 'font/woff',
48
+ woff2: 'font/woff2',
49
+ }
50
+
51
+ export type ServeRefusal =
52
+ | 'no-root'
53
+ | 'not-a-directory'
54
+ | 'no-port'
55
+ | 'no-entry'
56
+ | 'bind-failed'
57
+
58
+ export interface ServeRefused {
59
+ readonly ok: false
60
+ readonly reason: ServeRefusal
61
+ readonly detail: string
62
+ }
63
+
64
+ export interface ServeStarted {
65
+ readonly ok: true
66
+ /** Absolute, so a report names the directory rather than the caller's cwd. */
67
+ readonly root: string
68
+ readonly host: string
69
+ readonly port: number
70
+ /** The entry page relative to the root, as the URL spells it. */
71
+ readonly entry: string
72
+ /** What a reader clicks. Complete, including the entry page. */
73
+ readonly url: string
74
+ /** Whether the entry page exists. A missing one is reported, never fatal. */
75
+ readonly entryExists: boolean
76
+ readonly stop: () => Promise<void>
77
+ }
78
+
79
+ export type ServeOutcome = ServeStarted | ServeRefused
80
+
81
+ export interface ServeOptions {
82
+ readonly port?: number
83
+ readonly entry?: string
84
+ }
85
+
86
+ function refuse(reason: ServeRefusal, detail: string): ServeRefused {
87
+ return { ok: false, reason, detail }
88
+ }
89
+
90
+ function contentType(path: string): string {
91
+ const dot = path.lastIndexOf('.')
92
+ if (dot === -1) return 'application/octet-stream'
93
+ const ext = path.slice(dot + 1).toLowerCase()
94
+ return CONTENT_TYPES[ext] ?? 'application/octet-stream'
95
+ }
96
+
97
+ /**
98
+ * Resolves a request path inside the root, or returns undefined when it escapes.
99
+ * The containment test compares resolved absolute paths rather than inspecting
100
+ * the request for `..`, because an encoded traversal survives a textual scan and
101
+ * does not survive resolution.
102
+ */
103
+ export function resolveWithin(
104
+ root: string,
105
+ requestPath: string,
106
+ ): string | undefined {
107
+ let decoded: string
108
+ try {
109
+ decoded = decodeURIComponent(requestPath)
110
+ } catch {
111
+ return undefined
112
+ }
113
+ /**
114
+ * A NUL truncates the path at the filesystem layer, so a request carrying one
115
+ * asks for a different file than the one the containment test read.
116
+ */
117
+ if (decoded.includes('\0')) return undefined
118
+
119
+ const relativePath = decoded.replace(/^\/+/, '')
120
+ const target = resolve(root, relativePath)
121
+ if (target !== root && !target.startsWith(root + sep)) return undefined
122
+ return target
123
+ }
124
+
125
+ /**
126
+ * Re-tests containment after following symlinks. `resolveWithin` is lexical
127
+ * and `resolve` does not follow a link, so a link inside the root clears that
128
+ * test while the file it points at sits outside. This repository is a live
129
+ * instance, since `claude/standards` and `claude/snippets` are links out of
130
+ * `claude/`.
131
+ *
132
+ * Only a path that exists is checked, because a link can be followed only once
133
+ * there is something on the other end, and a path that resolves to nothing is
134
+ * a 404 rather than an escape.
135
+ */
136
+ function escapesThroughLink(root: string, target: string): boolean {
137
+ if (!existsSync(target)) return false
138
+ try {
139
+ const realRoot = realpathSync(root)
140
+ const realTarget = realpathSync(target)
141
+ return realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)
142
+ } catch {
143
+ /* Unreadable resolves to no answer, and no answer is refused. */
144
+ return true
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Whether a bind failure is contention worth trying the next port for.
150
+ *
151
+ * Lifted out of the loop so the decision is testable. Manufacturing a real
152
+ * non-contention bind failure needs a privileged port or an unavailable
153
+ * interface and neither travels between machines, where an error value does,
154
+ * so this is unit-tested and the bind itself is not.
155
+ */
156
+ export function shouldWalkPast(error: unknown): boolean {
157
+ return (error as NodeJS.ErrnoException | null)?.code === 'EADDRINUSE'
158
+ }
159
+
160
+ /**
161
+ * Picks a listening port, starting at the requested one and walking forward.
162
+ * A busy port is the ordinary case rather than a failure, since a preview of
163
+ * one workspace is routinely open while another is started.
164
+ */
165
+ function listen(
166
+ root: string,
167
+ first: number,
168
+ ): { server: ReturnType<typeof Bun.serve>; port: number } | undefined {
169
+ for (let port = first; port < first + PORT_ATTEMPTS; port++) {
170
+ try {
171
+ const server = Bun.serve({
172
+ hostname: SERVE_HOST,
173
+ port,
174
+ fetch: (request) => respond(root, request),
175
+ })
176
+ /**
177
+ * The bound port rather than the requested one. Port 0 asks the OS to
178
+ * choose, so reporting the request builds a URL pointing at nothing.
179
+ * The type admits undefined for a unix socket, which a bind carrying a
180
+ * hostname and a port never is, and the request is the honest fallback.
181
+ */
182
+ return { server, port: server.port ?? port }
183
+ } catch (error) {
184
+ /**
185
+ * Contention is the one cause worth walking past. A permission failure
186
+ * or an unavailable interface swallowed here would be retried twenty
187
+ * times and then reported as a port range being full, which names a
188
+ * cause nothing checked. `startServer` turns the rethrow into a refusal.
189
+ */
190
+ if (!shouldWalkPast(error)) throw error
191
+ }
192
+ }
193
+ return undefined
194
+ }
195
+
196
+ export async function respond(
197
+ root: string,
198
+ request: Request,
199
+ ): Promise<Response> {
200
+ const { pathname, search } = new URL(request.url)
201
+ const forbidden = () =>
202
+ new Response('Forbidden\n', {
203
+ status: 403,
204
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
205
+ })
206
+
207
+ const target = resolveWithin(root, pathname)
208
+ if (!target) return forbidden()
209
+
210
+ /**
211
+ * Tested here as well as before the read, because the redirect below answers
212
+ * ahead of that one. A redirect firing on an out-of-root directory reports
213
+ * that it exists, where one that does not answers 404, and the pair is a
214
+ * fact about the filesystem outside the root.
215
+ */
216
+ if (escapesThroughLink(root, target)) return forbidden()
217
+
218
+ let path = target
219
+ if (existsSync(path) && statSync(path).isDirectory()) {
220
+ /**
221
+ * A browser resolves a relative asset against the last slash of the URL it
222
+ * is on, so answering a directory in place leaves `/lesson` asking for
223
+ * `/course.css` rather than `/lesson/course.css` and the page renders
224
+ * unstyled. The redirect moves the base before the index is served.
225
+ */
226
+ if (!pathname.endsWith('/')) {
227
+ return new Response(null, {
228
+ status: 301,
229
+ headers: { location: `${pathname}/${search}` },
230
+ })
231
+ }
232
+ path = join(path, DEFAULT_ENTRY)
233
+ }
234
+
235
+ /**
236
+ * Sits immediately before the read rather than beside the path that produced
237
+ * it, so every path reaching `Bun.file` has been tested whatever produced
238
+ * it. Checking the request path alone left the appended index untested, and
239
+ * a real directory holding a linked index was served.
240
+ */
241
+ if (escapesThroughLink(root, path)) return forbidden()
242
+
243
+ const file = Bun.file(path)
244
+ if (!(await file.exists())) {
245
+ return new Response(`Not found: ${pathname}\n`, {
246
+ status: 404,
247
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
248
+ })
249
+ }
250
+
251
+ return new Response(file, {
252
+ headers: {
253
+ 'content-type': contentType(path),
254
+ /**
255
+ * A preview is edited and reloaded continuously, and a cached stylesheet
256
+ * reads as a fix that did not work. Revalidation is the whole point of
257
+ * the surface, so it is not negotiable per response.
258
+ */
259
+ 'cache-control': 'no-store',
260
+ },
261
+ })
262
+ }
263
+
264
+ export function startServer(
265
+ dir: string,
266
+ options: ServeOptions = {},
267
+ ): ServeOutcome {
268
+ const root = resolve(process.cwd(), dir)
269
+ if (!existsSync(root)) return refuse('no-root', `${dir} does not exist`)
270
+ if (!statSync(root).isDirectory())
271
+ return refuse('not-a-directory', `${dir} is not a directory`)
272
+
273
+ const entry = (options.entry ?? DEFAULT_ENTRY).replace(/^\/+/, '')
274
+
275
+ /**
276
+ * Checked before a port is taken, since `url` is the field a caller is told
277
+ * to read and hand to a reader. An entry the containment test rejects would
278
+ * otherwise be reported as a link the server then refuses. An entry that is
279
+ * merely absent is not this case and does not refuse, which `entryExists`
280
+ * reports instead.
281
+ */
282
+ const entryPath = resolveWithin(root, entry)
283
+ if (!entryPath) return refuse('no-entry', `${entry} escapes ${dir}`)
284
+
285
+ const first = options.port ?? DEFAULT_PORT
286
+
287
+ /**
288
+ * A bind failure that is not contention reaches here as a throw, and the
289
+ * command's own help promises a reason on stderr or in the record. A stack
290
+ * trace is neither, so it is caught and named.
291
+ */
292
+ let bound: ReturnType<typeof listen>
293
+ try {
294
+ bound = listen(root, first)
295
+ } catch (error) {
296
+ const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
297
+ return refuse(
298
+ 'bind-failed',
299
+ `could not bind ${SERVE_HOST}:${first} (${code})`,
300
+ )
301
+ }
302
+
303
+ if (!bound) {
304
+ return refuse(
305
+ 'no-port',
306
+ `no free port between ${first} and ${first + PORT_ATTEMPTS - 1}`,
307
+ )
308
+ }
309
+
310
+ return {
311
+ ok: true,
312
+ root,
313
+ host: SERVE_HOST,
314
+ port: bound.port,
315
+ entry,
316
+ url: `http://${SERVE_HOST}:${bound.port}/${entry}`,
317
+ entryExists: existsSync(entryPath),
318
+ stop: async () => {
319
+ await bound.server.stop(true)
320
+ },
321
+ }
322
+ }