@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-teach/REQUIREMENT.md +3 -0
- package/claude/skills/claude-teach/SKILL.md +18 -1
- package/docs/agents/commands.md +9 -0
- package/docs/agents/counts.md +1 -1
- package/docs/agents/teach.md +12 -0
- package/governance/rules/claude/561-teach.md +1 -1
- package/governance/rules/ui/400-ui.md +1 -0
- package/governance/rules/ui/410-a11y.md +2 -0
- package/governance/rules/ui/420-forms.md +1 -0
- package/governance/rules/ui/430-ux-completeness.md +2 -0
- package/governance/rules/ui/440-surface-capture.md +13 -5
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/serve.ts +159 -0
- package/src/serve/static.ts +322 -0
|
@@ -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.
|
|
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
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -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.
|
package/docs/agents/counts.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/agents/teach.md
CHANGED
|
@@ -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
|
-
-
|
|
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.
|
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Require a
|
|
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
|
|
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
|
|
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
|
-
-
|
|
21
|
-
-
|
|
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
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
|
+
}
|