@erclx/aitk 3.45.0 → 3.47.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.
@@ -2,10 +2,13 @@ import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { checkClaim, type ClaimReport } from '@/sessions/claim'
4
4
  import {
5
+ callerIdentity,
5
6
  repositoryOf,
6
7
  type ResolvedSession,
7
8
  resolveSessions,
9
+ type SelfReport,
8
10
  type SessionReport,
11
+ selfOf,
9
12
  } from '@/sessions/resolve'
10
13
  import {
11
14
  intro,
@@ -21,6 +24,7 @@ interface ListCommandOptions {
21
24
  readonly json?: boolean
22
25
  readonly branch?: string
23
26
  readonly repository?: string
27
+ readonly self?: boolean
24
28
  }
25
29
 
26
30
  const REASONS: Record<string, string> = {
@@ -52,6 +56,10 @@ export function register(program: Command): void {
52
56
  '--repository <path>',
53
57
  'Answer about this project rather than the working one',
54
58
  )
59
+ .option(
60
+ '--self',
61
+ "Report the caller's own row, and refuse where the roster holds none",
62
+ )
55
63
  .addHelpText(
56
64
  'after',
57
65
  [
@@ -93,6 +101,19 @@ export function register(program: Command): void {
93
101
  'The match can return more than one session. Read the count rather than',
94
102
  'the first row, since two sessions can hold one branch.',
95
103
  '',
104
+ "--self narrows the report to the caller's own row, which is what a",
105
+ 'dispatcher reads to learn the sessionId it carries into a launch. It',
106
+ 'joins on CLAUDE_CODE_SESSION_ID first, falls back to CLAUDE_PID, and',
107
+ 'falls back again to the pid the messaging socket path spells. It never',
108
+ 'reads CLAUDE_CODE_HOST_SESSION_ID, which holds a value from another',
109
+ 'namespace that matches no row.',
110
+ '',
111
+ 'It refuses with reason "no-self-identity" when the environment states',
112
+ 'none of the three, and "no-self-row" when it states one and no live',
113
+ 'row carries it. The second is the ordinary answer for a session',
114
+ 'driving from Remote Control, which is addressable on the message',
115
+ 'channel and holds no local process record for the roster to report.',
116
+ '',
96
117
  'Each session writes its own working directory beside its own name, so a',
97
118
  'name from a session listing joins to a branch by an exact match rather',
98
119
  'than by ordering the roster on start time.',
@@ -108,6 +129,7 @@ export function register(program: Command): void {
108
129
  ' aitk sessions list --json',
109
130
  ' aitk sessions list --branch feat/parser --json',
110
131
  ' aitk sessions list --branch chore/agents --repository ../caret --json',
132
+ ' aitk sessions list --self --json',
111
133
  '',
112
134
  ].join('\n'),
113
135
  )
@@ -136,6 +158,34 @@ async function runList(opts: ListCommandOptions): Promise<number> {
136
158
  return 1
137
159
  }
138
160
 
161
+ // The roster read returns every row and marks none of them as the caller, so
162
+ // the join runs here, ahead of any scope. Resolving it after the branch
163
+ // filter would answer "no row" for a caller whose row was merely filtered
164
+ // out, which is a different failure wearing the same reason.
165
+ const own = opts.self ? selfOf(report.sessions, callerIdentity()) : null
166
+
167
+ if (own?.kind === 'unresolved') {
168
+ intro('aitk sessions list')
169
+ logStep('Refused')
170
+ logWarn(selfRefusal(own))
171
+ outro()
172
+
173
+ if (opts.json) {
174
+ process.stdout.write(
175
+ `${JSON.stringify({
176
+ dir: report.dir,
177
+ reason:
178
+ own.reason === 'no-identity' ? 'no-self-identity' : 'no-self-row',
179
+ sessions: [],
180
+ })}\n`,
181
+ )
182
+ }
183
+
184
+ return 1
185
+ }
186
+
187
+ const pool = own === null ? report.sessions : [own.session]
188
+
139
189
  // A branch name identifies a branch inside one repository and nothing across
140
190
  // a machine, so an unscoped match reaches a session working in a different
141
191
  // project. `main` is the name that collides on every machine running two.
@@ -164,11 +214,11 @@ async function runList(opts: ListCommandOptions): Promise<number> {
164
214
  }
165
215
 
166
216
  const shown = opts.branch
167
- ? report.sessions.filter(
217
+ ? pool.filter(
168
218
  (session) =>
169
219
  session.branch === opts.branch && session.repository === repository,
170
220
  )
171
- : report.sessions
221
+ : pool
172
222
 
173
223
  const claim = opts.branch
174
224
  ? await checkClaim(opts.branch, { cwd: at, resolve: async () => report })
@@ -176,7 +226,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
176
226
 
177
227
  intro('aitk sessions list')
178
228
  reportConfidence(report)
179
- reportSessions(shown, opts.branch, repository)
229
+ reportSessions(shown, opts.branch, repository, own !== null)
180
230
  if (claim) reportClaim(claim)
181
231
  outro()
182
232
 
@@ -200,6 +250,22 @@ async function runList(opts: ListCommandOptions): Promise<number> {
200
250
  return 0
201
251
  }
202
252
 
253
+ /**
254
+ * Separates a client that states no identity from a roster holding no row for
255
+ * one it does state, since the two send a reader to different places.
256
+ */
257
+ function selfRefusal(own: Extract<SelfReport, { kind: 'unresolved' }>): string {
258
+ if (own.reason === 'no-identity') {
259
+ return 'Nothing in the environment identifies this session, so --self has nothing to match against. A client setting none of CLAUDE_CODE_SESSION_ID, CLAUDE_PID, or CLAUDE_CODE_MESSAGING_SOCKET cannot be located on the roster at all.'
260
+ }
261
+
262
+ const held =
263
+ own.identity.sessionId ??
264
+ (own.identity.pid === null ? 'nothing' : `pid ${own.identity.pid}`)
265
+
266
+ return `The environment identifies this session as ${held}, and no live row carries it. The roster holds local process records alone, so a session driving through Remote Control never appears here, and a record whose session has ended is dropped ahead of the match.`
267
+ }
268
+
203
269
  /**
204
270
  * States how liveness was decided on every run, including the run that decided
205
271
  * it the strong way.
@@ -239,6 +305,7 @@ function reportSessions(
239
305
  sessions: readonly ResolvedSession[],
240
306
  branch: string | undefined,
241
307
  repository: string | null,
308
+ scoped: boolean,
242
309
  ): void {
243
310
  logStep('Sessions')
244
311
 
@@ -248,11 +315,21 @@ function reportSessions(
248
315
  )
249
316
  }
250
317
 
318
+ // An empty result under --self says nothing about the roster, since the pool
319
+ // was narrowed to one row before the branch filter ran. Reporting the wider
320
+ // answer there would claim a reading this run never took.
251
321
  if (sessions.length === 0) {
322
+ if (branch) {
323
+ logInfo(
324
+ scoped
325
+ ? `This session does not hold ${branch}.`
326
+ : `No live session in this repository holds ${branch}.`,
327
+ )
328
+ return
329
+ }
330
+
252
331
  logInfo(
253
- branch
254
- ? `No live session in this repository holds ${branch}.`
255
- : 'No live session. Every record in the registry belongs to a session that has ended.',
332
+ 'No live session. Every record in the registry belongs to a session that has ended.',
256
333
  )
257
334
  return
258
335
  }
@@ -0,0 +1,167 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { readdirSync, statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { PROJECT_ROOT } from '@/project-root'
5
+ import { stateDir } from '@/targets/registry'
6
+
7
+ const CLI = join(PROJECT_ROOT, 'src/cli.ts')
8
+
9
+ /** No case has ever needed longer, and a blocked verb should fail fast. */
10
+ const DEFAULT_TIMEOUT_MS = 10_000
11
+
12
+ export interface ProcessRun {
13
+ readonly status: number | null
14
+ readonly stdout: string
15
+ readonly stderr: string
16
+ readonly json: unknown
17
+ }
18
+
19
+ export interface RunCliOptions {
20
+ readonly cwd: string
21
+ readonly env?: NodeJS.ProcessEnv
22
+ readonly timeoutMs?: number
23
+ }
24
+
25
+ /**
26
+ * Thrown when a case reaches past its declared temporary directory into this
27
+ * machine's real toolkit state. `stateDir()` in `src/targets/registry.ts`
28
+ * holds both the target registry `gov install` and `gov sync` record into and
29
+ * the sandbox tree `aitk sandbox` provisions into, so a case that inherits the
30
+ * real `HOME` unmodified writes into whichever of the two a verb touches, and
31
+ * nothing but this check would ever say so.
32
+ */
33
+ export class ContainmentViolation extends Error {}
34
+
35
+ /**
36
+ * Compares two snapshots of this machine's real toolkit state directory and
37
+ * reports whether a spawn changed it. A pure comparison over the two reads
38
+ * rather than the read itself, so the detection logic is testable without
39
+ * touching the filesystem or spawning anything.
40
+ */
41
+ export function detectStateLeak(
42
+ before: string | undefined,
43
+ after: string | undefined,
44
+ ): boolean {
45
+ return before !== after
46
+ }
47
+
48
+ /**
49
+ * A sorted `path:size` listing of every file under this machine's real
50
+ * `stateDir()`, walked recursively rather than read one level deep, so a
51
+ * write nested inside an existing folder, such as a file the sandbox tree
52
+ * already holds, shows up the same as a new top-level entry. Reading a single
53
+ * known file, such as the target registry alone, would miss every sibling
54
+ * `stateDir()` grows, which is what left the sandbox tree unwatched.
55
+ */
56
+ export function snapshotStateDir(): string {
57
+ const root = stateDir()
58
+ const rows: string[] = []
59
+
60
+ function walk(dir: string): void {
61
+ let names: string[]
62
+ try {
63
+ names = readdirSync(dir)
64
+ } catch {
65
+ return
66
+ }
67
+
68
+ for (const name of names.sort()) {
69
+ const full = join(dir, name)
70
+ let info: ReturnType<typeof statSync>
71
+ try {
72
+ info = statSync(full)
73
+ } catch {
74
+ continue
75
+ }
76
+ if (info.isDirectory()) walk(full)
77
+ else rows.push(`${full}:${info.size}`)
78
+ }
79
+ }
80
+
81
+ walk(root)
82
+ return rows.join('\n')
83
+ }
84
+
85
+ /**
86
+ * Spawns the real entry point rather than calling a command's action function
87
+ * in-process, so a case answers whether a verb is registered, whether it
88
+ * exits the way its own contract states, and whether its `--json` record
89
+ * parses off stdout alone, none of which an in-process call can misreport.
90
+ *
91
+ * A git hook exports `GIT_DIR`, which would resolve a fixture's git-aware
92
+ * reads against this checkout instead of the temporary directory a case
93
+ * builds, so every spawn drops the `GIT_` prefix before adding the headless
94
+ * flag every case needs to avoid a picker blocking on stdin.
95
+ *
96
+ * `AITK_STATE_DIR` and `AITK_SANDBOX_DIR` get the same treatment as `GIT_DIR`,
97
+ * each pointed at a folder under the case's own `cwd` rather than dropped,
98
+ * since dropping either alone would still resolve through the inherited
99
+ * `HOME` to this machine's real `~/.local/state/aitk`. `stateDir()` and
100
+ * `sandboxTree()` resolve the same three ways and share that parent, so both
101
+ * overrides move together. A case explicitly passing its own value through
102
+ * `options.env` still wins, matching `AITK_NON_INTERACTIVE` below.
103
+ *
104
+ * The `stateDir()` snapshot before and after the spawn is what actually
105
+ * catches an escape past that redirection, since a default can be wrong in a
106
+ * way a case never asserts on its own, and it is what `AITK_SANDBOX_DIR`
107
+ * rides for free: the sandbox tree already sits under `stateDir()`, so
108
+ * walking the whole directory catches a leak there with no override of its
109
+ * own to add. `ContainmentViolation` fails loud rather than leaving a dead
110
+ * row for a reviewer to find on a real machine.
111
+ */
112
+ export function runCli(
113
+ args: readonly string[],
114
+ options: RunCliOptions,
115
+ ): ProcessRun {
116
+ const inherited = Object.fromEntries(
117
+ Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')),
118
+ )
119
+
120
+ const before = snapshotStateDir()
121
+
122
+ const result = spawnSync('bun', [CLI, ...args], {
123
+ cwd: options.cwd,
124
+ encoding: 'utf8',
125
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
126
+ env: {
127
+ ...inherited,
128
+ AITK_NON_INTERACTIVE: '1',
129
+ AITK_STATE_DIR: join(options.cwd, '.aitk-state'),
130
+ AITK_SANDBOX_DIR: join(options.cwd, '.aitk-state', 'sandbox'),
131
+ ...options.env,
132
+ },
133
+ })
134
+
135
+ const after = snapshotStateDir()
136
+ if (detectStateLeak(before, after)) {
137
+ throw new ContainmentViolation(
138
+ `A case wrote into this machine's real toolkit state at ${stateDir()}. ` +
139
+ 'Every process-tier case must stay inside the directory it declared.',
140
+ )
141
+ }
142
+
143
+ return {
144
+ status: result.status,
145
+ stdout: result.stdout,
146
+ stderr: result.stderr,
147
+ json: parseJson(result.stdout),
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Data goes to stdout and framing to stderr, so a harness reading `--json`
153
+ * off the merged output would assert against a record no verb ever wrote. A
154
+ * command that emits no JSON, or fails before it gets there, leaves the
155
+ * field `undefined` rather than throwing, so a case asserting the exit code
156
+ * of a refusal is not also forced to guard a parse.
157
+ */
158
+ function parseJson(stdout: string): unknown {
159
+ const trimmed = stdout.trim()
160
+ if (trimmed === '') return undefined
161
+
162
+ try {
163
+ return JSON.parse(trimmed)
164
+ } catch {
165
+ return undefined
166
+ }
167
+ }
@@ -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
+ }