@krapwoo/ds-viewer 0.3.0 → 0.4.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/README.md CHANGED
@@ -286,6 +286,75 @@ either one only if it's missing (an `AGENTS.md` without the start marker counts
286
286
  section, even if the file already has other content) and never touches anything outside its own
287
287
  markers or its own file.
288
288
 
289
+ ## Updating
290
+
291
+ A quiet line appears in the sidebar footer when a new version is published — a banner too, for a
292
+ major version, dismissible per version. Either opens the update page (`#ds-viewer-update`), which
293
+ shows what would change and one **Update now** button:
294
+
295
+ ```
296
+ 0.4.0 → 0.5.0 Minor
297
+ You're on 0.4.0. Released 2 days ago.
298
+
299
+ What's new
300
+ • Faster sync for large kits
301
+ • New Tabs component in the starter kit
302
+
303
+ Files that would change
304
+ package.json version
305
+ package-lock.json version
306
+
307
+ [ Update now ] or run npx ds-viewer update
308
+ ```
309
+
310
+ **Update now** asks the running `dev` process to apply the same steps `update` runs below, over a
311
+ local endpoint that only ever answers the viewer's own page (random port, a per-run secret, exact
312
+ origin match). The secret itself is baked into the generated viewer bundle alongside it — it's the
313
+ endpoint's own Host and exact-Origin checks, not the secret being unguessable, that stop any other
314
+ web page from using it. The viewer restarts itself on the new version and reloads automatically;
315
+ nothing is committed.
316
+
317
+ If any file the update would touch has uncommitted changes, the panel refuses and names them —
318
+ there's no override in the viewer; commit or stash first, or use `update --force` below. Outside a
319
+ git repository, every file is treated as not dirty (there's nothing to check).
320
+
321
+ ### `npx ds-viewer update`
322
+
323
+ ```
324
+ npx ds-viewer update # shows the plan, asks to confirm, then updates
325
+ npx ds-viewer update --dry-run # shows the plan and stops
326
+ npx ds-viewer update --yes # skip the confirmation prompt
327
+ npx ds-viewer update --force # update even with uncommitted changes in a planned file
328
+ ```
329
+
330
+ Downloads the target version, runs its own `migrate --from <your version>`, upgrades with
331
+ whichever package manager your lockfile names, re-runs `migrate` for real, then `doctor`. Changes
332
+ are left uncommitted — review them like any other change.
333
+
334
+ ### `npx ds-viewer migrate --from <version>`
335
+
336
+ Runs every migration introduced after `<version>` — `update` calls this for you; run it directly
337
+ only to catch up a project that upgraded its `package.json` some other way. `--dry-run` reports the
338
+ changes without writing them; `--json` for the machine-readable version. 0.4 ships this framework
339
+ with an empty migration list — it introduces no breaking change of its own.
340
+
341
+ ### `npx ds-viewer kit diff <Component>`
342
+
343
+ Starter-kit files are copied once by `init` and never touched again — not even by `update`, which
344
+ only ever counts how many differ from the version you're installing. `kit diff <Component>` shows
345
+ your copy against the installed kit's, as a plain diff:
346
+
347
+ ```
348
+ npx ds-viewer kit diff Button
349
+ --- components/Button/Button.catalog.tsx (installed kit)
350
+ +++ components/Button/Button.catalog.tsx (yours)
351
+ ...
352
+ ```
353
+
354
+ Set `starterKit.root` in `ds-viewer.config.ts` if you moved the kit after `init --new` wrote it
355
+ (default `src/ds`); a config from before 0.4 with no `root` field still works — it's inferred from
356
+ your first `components` glob.
357
+
289
358
  ## What's included
290
359
 
291
360
  Tokens · Icons (45) · and generic components:
package/config/index.ts CHANGED
@@ -16,8 +16,10 @@ export interface DsViewerConfig {
16
16
  pages?: string[];
17
17
  /** Sidebar group display order; groups not listed here follow alphabetically. */
18
18
  groupOrder?: string[];
19
- /** Starter-kit projects only. */
20
- starterKit?: { version: string };
19
+ /** Starter-kit projects only. `root` is the folder `init --new` copied the kit into (default
20
+ * `src/ds`); absent on a config written before 0.4, in which case `cli/kitRoot.ts` infers it
21
+ * from the first `components` glob instead. */
22
+ starterKit?: { version: string; root?: string };
21
23
  /** @default true */
22
24
  updateCheck?: boolean;
23
25
  /** `strict: true` promotes `doctor` warnings to errors. */
package/dist/cli/dev.js CHANGED
@@ -1,11 +1,20 @@
1
- import { existsSync, readFileSync, watch } from 'node:fs';
1
+ import { existsSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
4
7
  import { resolveConfig } from "./config.js";
5
8
  import { sync } from "./sync.js";
6
9
  import { writeWorkspace } from "./workspace.js";
7
10
  import { findFreePort } from "./port.js";
8
11
  import { globBaseFolder } from "./glob.js";
12
+ import { checkForUpdate, isUpdateCheckEnabled, writeUpdateFile } from "./updateCheck.js";
13
+ import { readOwnVersion } from "./packageVersion.js";
14
+ import { createUpdateEndpoint } from "./endpoint.js";
15
+ import { buildUpdatePlan } from "./updatePlan.js";
16
+ import { lastSummaryLine } from "./doctor.js";
17
+ import { detectPackageManager, installUpgradeCommand } from "./packageManager.js";
9
18
  export class LocalInstallMissingError extends Error {
10
19
  }
11
20
  const IGNORED_WATCH_SEGMENTS = new Set(['.ds-viewer', 'node_modules', '.git']);
@@ -49,26 +58,295 @@ export function watchTargetFolders(config) {
49
58
  * workspace until `dev` was restarted, because `writeWorkspace` was never called again with the
50
59
  * new value (controller end-to-end finding E1). Returns the freshly-resolved config so `dev` can
51
60
  * keep watching with it. */
52
- export function reloadWorkspace(projectRoot) {
61
+ export function reloadWorkspace(projectRoot, updateEndpoint) {
53
62
  const config = resolveConfig(projectRoot);
54
63
  sync(config);
55
- writeWorkspace(config);
64
+ writeWorkspace(config, updateEndpoint);
56
65
  return config;
57
66
  }
67
+ /** Design §5: `dev` writes `.ds-viewer/update.json` every run, from a fresh check (respecting the
68
+ * 24h cache inside `checkForUpdate`) or `null` when checks are disabled — never left unwritten,
69
+ * since the generated entry file (Task 17) always imports it. Exported and test-only-injectable
70
+ * the same way `reloadWorkspace` already is, so this plan never needs to test `dev()` itself
71
+ * (which spawns a real Expo process) to prove this one behavior. */
72
+ export async function refreshUpdateFile(config, ownVersion, options = {}) {
73
+ const result = isUpdateCheckEnabled(config, options.env ?? process.env)
74
+ ? await (options.checkForUpdate ?? checkForUpdate)(ownVersion)
75
+ : undefined;
76
+ writeUpdateFile(config.projectRoot, result);
77
+ }
78
+ // Promisified, not `execFileSync` — install/migrate/doctor each take real seconds, and this now
79
+ // runs from inside the endpoint's own request-handling process, which must keep answering
80
+ // `/update/status` polls throughout (Critical finding, Fable correction pass: the synchronous
81
+ // version blocked the entire event loop until the whole update finished).
82
+ const execFileAsync = promisify(execFile);
83
+ const defaultExecImpl = async (command, args, options) => (await execFileAsync(command, args, options)).stdout;
84
+ function installedMainJs(projectRoot) {
85
+ return path.join(projectRoot, 'node_modules', '@krapwoo', 'ds-viewer', 'dist', 'cli', 'main.js');
86
+ }
87
+ function packageManagerLabel(pm) {
88
+ return pm === 'npm' ? 'npm' : pm === 'pnpm' ? 'pnpm' : 'yarn';
89
+ }
90
+ /** Design §5 "`npx ds-viewer update`" steps 3-5, run from inside `dev` instead of a one-shot CLI
91
+ * invocation — every external effect injected (Global Constraints), exactly like `cli/update.ts`'s
92
+ * `runUpdate`. The plan's own dirty-file refusal already happened before this was ever called
93
+ * (the endpoint, Task 12, refuses `POST /update` itself); this function only ever runs a plan it
94
+ * has already been told is safe to apply. */
95
+ export async function performUpdate(config, currentVersion, plan, deps) {
96
+ const migrationCount = plan.files.filter((f) => f.reason.startsWith('migration:')).length;
97
+ const pm = detectPackageManager(config.projectRoot);
98
+ // Names the detected package manager and the real migration count (Important finding, Fable
99
+ // correction pass: the approved mockup's own `updating` state reads "Installed with npm" and
100
+ // "Applying 1 migration…", never a generic placeholder). The install step carries its own
101
+ // present-tense `progressLabel` (Minor finding, Fable correction pass: the in-flight step
102
+ // previously read "Installed with npm…" — already past tense — while still running) and its own
103
+ // `failLabel` (the mockup's failure state reads "Install with npm failed", not "Installed with
104
+ // npm failed" — a different, plain-present phrasing from either the done or in-flight label).
105
+ const stepDefs = [
106
+ { label: `Downloaded ${plan.latest}` },
107
+ { label: `Installed with ${packageManagerLabel(pm)}`, progressLabel: `Installing with ${packageManagerLabel(pm)}`, failLabel: `Install with ${packageManagerLabel(pm)}` },
108
+ { label: migrationCount > 0 ? `Applying ${migrationCount} migration${migrationCount === 1 ? '' : 's'}` : 'No migrations to apply' },
109
+ { label: 'Checking pages (doctor)' },
110
+ { label: 'Restarting the viewer' },
111
+ ];
112
+ // `completedCount` steps are 'done', the next one is 'now' (in flight), the rest 'todo' — a
113
+ // three-way state, not a boolean (Important finding, Fable correction pass; Task 12/15 share
114
+ // this exact shape). Step 0 (download) is already done — `buildUpdatePlan` already downloaded
115
+ // the tarball while building the plan this function was handed.
116
+ const report = (completedCount) => deps.onStatus({
117
+ phase: 'updating',
118
+ steps: stepDefs.map((def, i) => ({
119
+ label: i === completedCount && def.progressLabel ? def.progressLabel : def.label,
120
+ state: i < completedCount ? 'done' : i === completedCount ? 'now' : 'todo',
121
+ })),
122
+ });
123
+ report(1);
124
+ const { command, args } = installUpgradeCommand(pm, '@krapwoo/ds-viewer', plan.latest);
125
+ try {
126
+ await deps.execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
127
+ }
128
+ catch (error) {
129
+ deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[1].failLabel ?? stepDefs[1].label });
130
+ return;
131
+ }
132
+ report(2);
133
+ try {
134
+ await deps.execImpl('node', [installedMainJs(config.projectRoot), 'migrate', '--from', currentVersion], { cwd: config.projectRoot, encoding: 'utf8' });
135
+ }
136
+ catch (error) {
137
+ deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[2].label });
138
+ return;
139
+ }
140
+ report(3);
141
+ let doctorOutput;
142
+ try {
143
+ doctorOutput = await deps.execImpl('node', [installedMainJs(config.projectRoot), 'doctor'], { cwd: config.projectRoot, encoding: 'utf8' });
144
+ }
145
+ catch (error) {
146
+ deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[3].label });
147
+ return;
148
+ }
149
+ report(4);
150
+ // Fable Minor finding, raised to Important by the controller: the success page showed the whole
151
+ // doctor report (30+ lines for a real project) — the mockup's own success state shows one line.
152
+ const doctorSummary = lastSummaryLine(doctorOutput);
153
+ deps.onStatus({ phase: 'restarting' });
154
+ // Errata 2c: wait before actually restarting, so the browser's next `/update/status` poll can
155
+ // observe the `'restarting'` phase instead of the connection just dropping.
156
+ await new Promise((resolve) => setTimeout(resolve, deps.delayMs ?? 1500));
157
+ // `files` lets the `success` status (Task 12/15/16) show what changed after the reload, once
158
+ // `plan` itself is gone (Important finding, Fable correction pass). `latest` (Errata 1b) lets
159
+ // the panel render the success state once `update.json` has gone back to `null`.
160
+ await deps.restart({ doctorSummary, files: plan.files.map((f) => f.path), latest: plan.latest });
161
+ }
162
+ /** Errata 7c: Metro listens on `[::1]` only, so a plain `127.0.0.1` connect probe always reports
163
+ * the port as free even while Metro is still bound to it (observed with `lsof`) — `findFreePort`
164
+ * itself already tries to bind the port, which fails correctly either way. */
165
+ function defaultProbePortFree(port) {
166
+ return findFreePort(port, port).then(() => true, () => false);
167
+ }
168
+ async function waitForPortFree(port, probe) {
169
+ for (let attempt = 0; attempt < 50; attempt += 1) {
170
+ if (await probe(port))
171
+ return;
172
+ await new Promise((resolve) => setTimeout(resolve, 100));
173
+ }
174
+ }
175
+ /** Kills `pid`'s whole process group with `signal` — a negative pid, except on `win32` (Errata
176
+ * 7e), where there is no process-group concept and `child.kill(signal)` is the only option. */
177
+ function killGroup(child, signal) {
178
+ if (!child.pid)
179
+ return;
180
+ try {
181
+ if (process.platform === 'win32') {
182
+ child.kill(signal);
183
+ }
184
+ else {
185
+ process.kill(-child.pid, signal);
186
+ }
187
+ }
188
+ catch {
189
+ // Already exited.
190
+ }
191
+ }
192
+ /** Design §5 "Restart handoff" steps 3-5, rewritten in full (Critical finding, Fable correction
193
+ * pass — the original version was detached-but-unrefed-and-immediately-exited, which this
194
+ * repository's own `scripts/checkCatalogConsole.mjs` already documents as leaving the Expo
195
+ * grandchild running on the port): closes everything this `dev` process owns; kills the *whole
196
+ * process group* Expo/Metro spawned into (`dev()`, below, now spawns it with `detached: true`,
197
+ * the same convention `checkCatalogConsole.mjs`'s own `killDevGroup` already established — a
198
+ * negative pid targets the group, not just the one pid `child.kill()` alone would reach); waits
199
+ * for the old child to actually exit (or 10s, whichever comes first — Errata 7d) and the port to
200
+ * actually free before re-spawning `dev --port <same port>` — this time *not* detached, with
201
+ * inherited stdio, so this very process becomes a thin supervisor that forwards
202
+ * `SIGINT`/`SIGTERM`/`SIGHUP` to the new child and exits with its code, printing a recovery
203
+ * command (and the result this update already recorded) if it exits non-zero, or if anything in
204
+ * this function itself throws. Has no unit test (see this task's own note) — proven end-to-end
205
+ * in Task 20. */
206
+ export async function performRestart(handoff, deps = {}) {
207
+ // Errata 7a: remove `dev()`'s own SIGINT/SIGTERM/SIGHUP listeners (Step 5, below) first, before
208
+ // anything else — otherwise a signal sent to this supervisor mid-restart exits through that old
209
+ // handler (which tries to kill the already-exiting old child and calls `process.exit(0)`),
210
+ // orphaning the restarted `dev` and its Metro. `targetChild` starts `undefined`; a signal
211
+ // delivered before `newChild` exists below used to be silently swallowed instead of cancelling
212
+ // the restart (Minor finding, Fable correction pass: Ctrl-C in that up-to-15s window brought the
213
+ // viewer back up instead of stopping it) — `cancelledBeforeSpawn` remembers it instead, so the
214
+ // new `dev` is never spawned once the old child is gone.
215
+ let targetChild;
216
+ let cancelledBeforeSpawn = false;
217
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'])
218
+ process.removeAllListeners(signal);
219
+ const forward = (signal) => {
220
+ if (targetChild)
221
+ targetChild.kill(signal);
222
+ else
223
+ cancelledBeforeSpawn = true;
224
+ };
225
+ process.on('SIGINT', forward);
226
+ process.on('SIGTERM', forward);
227
+ process.on('SIGHUP', forward);
228
+ try {
229
+ for (const watcher of handoff.watchers)
230
+ watcher.close();
231
+ handoff.endpointServer.close();
232
+ killGroup(handoff.child, 'SIGTERM');
233
+ // Errata 7d: race the old child's exit against a 10s timer, then SIGKILL the group — a wedged
234
+ // Expo/Metro process must never block the restart forever.
235
+ const exited = await Promise.race([
236
+ new Promise((resolve) => {
237
+ if (handoff.child.exitCode !== null) {
238
+ resolve(true);
239
+ return;
240
+ }
241
+ handoff.child.once('exit', () => resolve(true));
242
+ }),
243
+ new Promise((resolve) => setTimeout(() => resolve(false), 10_000)),
244
+ ]);
245
+ if (!exited)
246
+ killGroup(handoff.child, 'SIGKILL');
247
+ await waitForPortFree(handoff.port, deps.probePortFree ?? defaultProbePortFree);
248
+ // The old child is gone and the port is free — if a signal arrived in the window above (before
249
+ // there was anything to forward it to), this restart is itself cancelled: the user asked to
250
+ // stop, not to come back up on the new version.
251
+ if (cancelledBeforeSpawn) {
252
+ process.exit(0);
253
+ return;
254
+ }
255
+ const newChild = spawn(process.execPath, [...process.execArgv, process.argv[1], 'dev', '--port', String(handoff.port)], { cwd: handoff.projectRoot, stdio: 'inherit' });
256
+ newChild.on('error', (error) => console.error(`Could not restart the viewer: ${error.message}`));
257
+ targetChild = newChild;
258
+ const exitCode = await new Promise((resolve) => newChild.on('exit', (code) => resolve(code ?? 0)));
259
+ if (exitCode !== 0) {
260
+ console.error(`The restarted viewer exited unexpectedly (code ${exitCode}).`);
261
+ console.error('Recover with: npx ds-viewer dev');
262
+ // Important finding, Fable correction pass: design §5 restart step 5 asks for "the recovery
263
+ // command and the result of the update" — previously only the recovery command printed here,
264
+ // losing the install/migrate/doctor result (never shown anywhere else; it ran in-process).
265
+ console.error(`The update itself finished: ${handoff.doctorSummary}; changed: ${handoff.files.join(', ') || '(none)'}.`);
266
+ }
267
+ process.exit(exitCode);
268
+ }
269
+ catch (error) {
270
+ console.error(`Restart failed: ${error.message}`);
271
+ console.error('Recover with: npx ds-viewer dev');
272
+ console.error(`The update itself finished: ${handoff.doctorSummary}; changed: ${handoff.files.join(', ') || '(none)'}.`);
273
+ process.exit(1);
274
+ }
275
+ }
58
276
  /** Design §2 "`dev`": checks the local install, runs `sync`, writes the workspace, starts Expo
59
277
  * web on the first free port from 5181 bound to localhost only, and watches the project for
60
278
  * component/token/page changes to re-run `sync`. Metro's own HMR updates the rendered component
61
279
  * when its source changes, but not the generated props table or page list (Task 17 Step 4 relies
62
280
  * on this: editing `Button.tsx` must update its props table, not just hot-reload the component). */
63
- export async function dev(initialConfig) {
281
+ export async function dev(initialConfig, devOptions = {}) {
64
282
  assertLocalInstall(initialConfig.projectRoot);
283
+ const ownVersion = readOwnVersion(import.meta.dirname);
284
+ await refreshUpdateFile(initialConfig, ownVersion);
65
285
  let config = initialConfig;
66
286
  sync(config);
67
- const workspace = writeWorkspace(config);
68
- const port = await findFreePort(5181, 5199);
287
+ // Restart handoff step 2/4: a status file left by the *previous* `dev` process (right before it
288
+ // exited to restart) is read once, then deleted — a plain refresh afterward shows "idle"/"no
289
+ // update known" instead of replaying a stale success/failure forever.
290
+ const statusFile = path.join(config.projectRoot, '.ds-viewer', 'update-status.json');
291
+ let updateStatus = { phase: 'idle' };
292
+ if (existsSync(statusFile)) {
293
+ try {
294
+ updateStatus = JSON.parse(readFileSync(statusFile, 'utf8'));
295
+ }
296
+ catch {
297
+ // A partial/corrupt write from a process that crashed mid-write — stay idle.
298
+ }
299
+ rmSync(statusFile, { force: true });
300
+ }
301
+ const port = await findFreePort(devOptions.pinnedPort ?? 5181, devOptions.pinnedPort ?? 5199);
69
302
  console.log(`Starting the catalog at http://localhost:${port}`);
303
+ let watchersPaused = false;
304
+ // Metro's own child process doesn't exist yet — assigned below, well before this is ever read
305
+ // (performUpdate's `restart` callback, which is the only reader, can only run after a real
306
+ // browser has already loaded the page and clicked "Update now").
307
+ let child;
308
+ const secret = randomBytes(24).toString('hex');
309
+ const allowedOrigin = `http://localhost:${port}`;
310
+ const endpointDeps = {
311
+ secret,
312
+ allowedOrigin,
313
+ buildPlan: () => buildUpdatePlan(config, ownVersion),
314
+ startUpdate: (plan) => {
315
+ watchersPaused = true;
316
+ void performUpdate(config, ownVersion, plan, {
317
+ execImpl: defaultExecImpl,
318
+ onStatus: (status) => {
319
+ updateStatus = status;
320
+ if (status.phase === 'failure')
321
+ watchersPaused = false; // the update stopped — keep developing on the old version.
322
+ },
323
+ restart: async (result) => {
324
+ writeFileSync(statusFile, JSON.stringify({ phase: 'success', ...result }));
325
+ await performRestart({ projectRoot: config.projectRoot, port, child, watchers, endpointServer, doctorSummary: result.doctorSummary, files: result.files });
326
+ },
327
+ }).catch((error) => {
328
+ // Minor finding, Fable correction pass: `performUpdate` only ever reports `'failure'` from
329
+ // its own guarded steps — a throw outside them (e.g. `writeFileSync(statusFile)` itself
330
+ // failing) previously became an unhandled rejection with nothing watching for it.
331
+ updateStatus = { phase: 'failure', log: error.message, failedStep: 'Updating' };
332
+ watchersPaused = false;
333
+ });
334
+ },
335
+ getStatus: () => updateStatus,
336
+ };
337
+ const endpointServer = createUpdateEndpoint(endpointDeps);
338
+ await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
339
+ const endpointPort = endpointServer.address().port;
340
+ // Printed for the same reason the Metro port above is printed: a developer (or Task 20's own
341
+ // verification) checking the endpoint directly (e.g. with curl) needs its port — the secret
342
+ // itself is never printed; the endpoint rejects anything that doesn't already have it.
343
+ console.log(`Update endpoint listening on 127.0.0.1:${endpointPort}`);
344
+ const updateEndpoint = { baseUrl: `http://127.0.0.1:${endpointPort}`, secret };
345
+ const workspace = writeWorkspace(config, updateEndpoint);
70
346
  let debounceTimer;
71
347
  const onSourceChange = (_eventType, relativePath) => {
348
+ if (watchersPaused)
349
+ return;
72
350
  if (relativePath && !isWatchedPath(relativePath))
73
351
  return;
74
352
  clearTimeout(debounceTimer);
@@ -87,10 +365,12 @@ export async function dev(initialConfig) {
87
365
  // `entry.tsx`/`metro.config.js` until `dev` is restarted (controller end-to-end finding E1).
88
366
  let configDebounceTimer;
89
367
  const onConfigChange = () => {
368
+ if (watchersPaused)
369
+ return;
90
370
  clearTimeout(configDebounceTimer);
91
371
  configDebounceTimer = setTimeout(() => {
92
372
  try {
93
- config = reloadWorkspace(config.projectRoot);
373
+ config = reloadWorkspace(config.projectRoot, updateEndpoint);
94
374
  }
95
375
  catch (error) {
96
376
  console.warn(`Could not reload ds-viewer.config.ts after a change: ${error.message}`);
@@ -104,28 +384,45 @@ export async function dev(initialConfig) {
104
384
  for (const watcher of watchers) {
105
385
  watcher.on('error', (error) => console.warn(`File watcher error: ${error.message}`));
106
386
  }
107
- const child = spawn('npx', ['expo', 'start', '--web', '--host', 'localhost', '--port', String(port)], {
387
+ child = spawn('npx', ['expo', 'start', '--web', '--host', 'localhost', '--port', String(port)], {
108
388
  cwd: workspace,
109
389
  stdio: 'inherit',
110
390
  shell: process.platform === 'win32',
391
+ detached: true,
111
392
  });
112
393
  child.on('error', (error) => console.warn(`Could not start Expo: ${error.message}`));
113
394
  child.on('exit', (code) => {
114
395
  for (const watcher of watchers)
115
396
  watcher.close();
397
+ endpointServer.close();
116
398
  process.exitCode = code ?? 0;
117
399
  });
118
- // Node's default SIGTERM/SIGINT behavior exits this process without touching its children —
119
- // left alone, a signal sent only to `dev`'s own pid (not its process group) would leave the
120
- // spawned `expo start` running, still bound to its port (Important finding, Fable correction
121
- // pass: this is exactly what would hang `check:catalog`'s driving script, Task 11, if `dev`
122
- // didn't clean up on its own).
123
- for (const signal of ['SIGTERM', 'SIGINT']) {
400
+ // Node's default SIGTERM/SIGINT/SIGHUP behavior exits this process without touching its
401
+ // children — left alone, a signal sent only to `dev`'s own pid (not its process group) would
402
+ // leave the spawned `expo start` running, still bound to its port (Important finding, Fable
403
+ // correction pass: this is exactly what would hang `check:catalog`'s driving script, Task 11, if
404
+ // `dev` didn't clean up on its own). SIGHUP (Errata 7b) matters here too: detached Expo is in
405
+ // its own session, so closing the terminal otherwise leaves Metro bound to the port.
406
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
124
407
  process.on(signal, () => {
125
408
  for (const watcher of watchers)
126
409
  watcher.close();
127
- child.kill(signal);
410
+ endpointServer.close();
411
+ killGroup(child, signal);
128
412
  process.exit(0);
129
413
  });
130
414
  }
415
+ // Minor finding, Fable correction pass: with Expo now spawned `detached: true`, any uncaught
416
+ // throw or unhandled rejection in this process (outside the guarded steps above) would otherwise
417
+ // crash `dev` with no terminal left to send Metro a signal, leaving it bound to the port.
418
+ const killExpoAndExit = (error) => {
419
+ console.error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
420
+ for (const watcher of watchers)
421
+ watcher.close();
422
+ endpointServer.close();
423
+ killGroup(child, 'SIGTERM');
424
+ process.exit(1);
425
+ };
426
+ process.on('uncaughtException', killExpoAndExit);
427
+ process.on('unhandledRejection', killExpoAndExit);
131
428
  }
@@ -490,3 +490,13 @@ export function formatHuman(result) {
490
490
  `${components} components (${withExamples} with examples), ${unboundExamples} unbound examples.`);
491
491
  return lines.join('\n');
492
492
  }
493
+ /** `formatHuman`'s own output always ends with its one summary line (design §4's own example: "0
494
+ * errors, 15 warnings — 37 components (37 with examples), 130 unbound examples.") — every issue
495
+ * and its fix come before it. Used by `dev.ts`/`update.ts` to shrink the `doctor` step's captured
496
+ * stdout down to that one line for the update panel's success state (Minor finding, raised to
497
+ * Important by the controller: the mockup's own success page shows one line, not the whole
498
+ * report). Never throws on empty input — falls back to the trimmed input itself. */
499
+ export function lastSummaryLine(output) {
500
+ const lines = output.split('\n').map((line) => line.trimEnd()).filter((line) => line.length > 0);
501
+ return lines.length > 0 ? lines[lines.length - 1] : output.trim();
502
+ }
@@ -0,0 +1,142 @@
1
+ // cli/endpoint.ts
2
+ import http from 'node:http';
3
+ import { timingSafeEqual } from 'node:crypto';
4
+ import { DS_VIEWER_SECRET_HEADER } from "../native/catalog/catalogNavigation.js";
5
+ /** A constant-time secret compare (Minor finding, Fable correction pass; controller decision 1
6
+ * makes this required, not optional) — `!==` on two strings short-circuits at the first
7
+ * differing byte, which leaks the secret's length and prefix through response-timing one byte at
8
+ * a time. Buffers of different lengths are rejected before ever reaching `timingSafeEqual`
9
+ * (which throws, rather than returning `false`, when its two inputs aren't the same length). */
10
+ function secretMatches(received, expected) {
11
+ if (typeof received !== 'string')
12
+ return false;
13
+ const receivedBuf = Buffer.from(received);
14
+ const expectedBuf = Buffer.from(expected);
15
+ return receivedBuf.length === expectedBuf.length && timingSafeEqual(receivedBuf, expectedBuf);
16
+ }
17
+ function sendJson(res, status, body, allowedOrigin) {
18
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': allowedOrigin });
19
+ res.end(JSON.stringify(body));
20
+ }
21
+ /** Design §5 "Local endpoint safeguards", every one of them, in the exact order this plan's own
22
+ * `spikes/cors-endpoint/` spike proved: Origin (including `OPTIONS`) → secret → route. Does not
23
+ * call `.listen()` itself — Task 18's `dev.ts` owns the real port (`0` → OS-assigned, `127.0.0.1`
24
+ * only) and this task's own tests each pick their own ephemeral port the same way. */
25
+ export function createUpdateEndpoint(deps) {
26
+ // Critical finding, Fable correction pass: the "one update at a time" refusal only ever checked
27
+ // `getStatus()`, which doesn't become `'updating'` until `startUpdate` runs — but `buildPlan`
28
+ // (re-downloading the tarball) can take seconds, and the panel leaves **Update now** enabled
29
+ // throughout, so a second click during that window passed the same check twice. This flag closes
30
+ // exactly that window: set synchronously before the plan is even (re-)built, reset on every path
31
+ // that does *not* hand the plan to `startUpdate` — the 502/409 refusals below. Once `startUpdate`
32
+ // is actually called, `getStatus()` already reports `'updating'` (set synchronously inside it, by
33
+ // every real caller), so resetting this flag right after is safe and lets a later retry (after a
34
+ // `'failure'`, in the same process) through again.
35
+ let starting = false;
36
+ return http.createServer(async (req, res) => {
37
+ // Controller decision 1 (required, not optional): reject a request whose `Host` header is not
38
+ // this very socket's own `127.0.0.1:<port>` — a DNS-rebinding defence, since `req.socket`'s own
39
+ // `localPort` is the port the OS actually accepted this connection on, never spoofable the way
40
+ // `Origin` (an ordinary request header) technically is from a non-browser client. Checked before
41
+ // everything else, including `OPTIONS`.
42
+ if (req.headers.host !== `127.0.0.1:${req.socket.localPort}`) {
43
+ res.writeHead(400).end('Invalid Host header.');
44
+ return;
45
+ }
46
+ const origin = req.headers.origin;
47
+ if (req.method === 'OPTIONS') {
48
+ // The browser's own preflight never attaches the app's custom header to itself — checking
49
+ // the secret here would make every state-changing request fail cross-origin before it ever
50
+ // got the chance to send one.
51
+ if (origin !== deps.allowedOrigin) {
52
+ res.writeHead(403).end();
53
+ return;
54
+ }
55
+ res.writeHead(204, {
56
+ 'Access-Control-Allow-Origin': deps.allowedOrigin,
57
+ 'Access-Control-Allow-Methods': 'POST, GET',
58
+ 'Access-Control-Allow-Headers': DS_VIEWER_SECRET_HEADER,
59
+ });
60
+ res.end();
61
+ return;
62
+ }
63
+ if (origin === undefined) {
64
+ res.writeHead(400).end('Missing Origin header.');
65
+ return;
66
+ }
67
+ if (origin !== deps.allowedOrigin) {
68
+ res.writeHead(403).end('Origin rejected.');
69
+ return;
70
+ }
71
+ if (!secretMatches(req.headers[DS_VIEWER_SECRET_HEADER], deps.secret)) {
72
+ res.writeHead(401).end('Missing or wrong secret.');
73
+ return;
74
+ }
75
+ if (req.method === 'POST' && req.url === '/plan') {
76
+ const plan = await deps.buildPlan();
77
+ sendJson(res, 200, plan, deps.allowedOrigin);
78
+ return;
79
+ }
80
+ if (req.method === 'POST' && req.url === '/update') {
81
+ // "Runs one update at a time; further start requests are refused while one runs" (design
82
+ // §5) — checked before even building a fresh plan, so a second click never re-downloads
83
+ // anything while the first update is still in flight.
84
+ const status = deps.getStatus();
85
+ if (starting || status.phase === 'updating' || status.phase === 'restarting') {
86
+ sendJson(res, 409, { error: 'An update is already running.' }, deps.allowedOrigin);
87
+ return;
88
+ }
89
+ starting = true;
90
+ let plan;
91
+ try {
92
+ plan = await deps.buildPlan();
93
+ }
94
+ catch (error) {
95
+ // A rejected plan must not leave `starting` stuck, or every later Update now is refused.
96
+ starting = false;
97
+ sendJson(res, 502, { error: error.message }, deps.allowedOrigin);
98
+ return;
99
+ }
100
+ if ('error' in plan) {
101
+ starting = false;
102
+ sendJson(res, 502, { error: plan.error }, deps.allowedOrigin);
103
+ return;
104
+ }
105
+ // "Refuses with the plan's dirty-file list ... the panel offers no override" (design §5) —
106
+ // this is the one, authoritative refusal: even if the panel's own UI somehow let a dirty
107
+ // plan through, the endpoint itself still never starts.
108
+ if (plan.dirtyFiles.length > 0) {
109
+ starting = false;
110
+ sendJson(res, 409, { error: 'dirty-files', dirtyFiles: plan.dirtyFiles }, deps.allowedOrigin);
111
+ return;
112
+ }
113
+ // Sends 202 *before* ever calling `startUpdate` — install/migrate/doctor run as real child
114
+ // processes that can take seconds (Critical finding, Fable correction pass: calling
115
+ // `startUpdate` first, synchronously, blocked this response until the whole update finished,
116
+ // so the browser's `POST /update` saw a connection reset instead of 202, and every
117
+ // `/update/status` poll during the install went unanswered). `setImmediate` defers the actual
118
+ // work to the next turn of the event loop, after this response has already been written.
119
+ sendJson(res, 202, { started: true }, deps.allowedOrigin);
120
+ // Errata 3: `startUpdate` is wrapped here too — a synchronous throw inside `setImmediate`'s
121
+ // callback would otherwise become an unhandled exception on the whole process, since there is
122
+ // no request/response left at that point to carry the error back to.
123
+ setImmediate(() => {
124
+ try {
125
+ deps.startUpdate(plan);
126
+ }
127
+ catch (error) {
128
+ console.warn('Update could not start: ' + error.message);
129
+ }
130
+ finally {
131
+ starting = false;
132
+ }
133
+ });
134
+ return;
135
+ }
136
+ if (req.method === 'GET' && req.url === '/update/status') {
137
+ sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
138
+ return;
139
+ }
140
+ res.writeHead(404).end('Not found.');
141
+ });
142
+ }