@krapwoo/ds-viewer 0.2.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.
Files changed (52) hide show
  1. package/README.md +151 -0
  2. package/config/index.ts +4 -2
  3. package/dist/cli/agentsFile.js +31 -0
  4. package/dist/cli/dev.js +312 -15
  5. package/dist/cli/doctor.js +502 -0
  6. package/dist/cli/endpoint.js +142 -0
  7. package/dist/cli/explain.js +151 -0
  8. package/dist/cli/githubAction.js +65 -0
  9. package/dist/cli/init.js +9 -5
  10. package/dist/cli/kitDiff.js +132 -0
  11. package/dist/cli/kitRoot.js +20 -0
  12. package/dist/cli/main.js +123 -15
  13. package/dist/cli/migrate.js +30 -0
  14. package/dist/cli/migrateEntry.js +19 -0
  15. package/dist/cli/migrations/index.js +14 -0
  16. package/dist/cli/migrations/types.js +9 -0
  17. package/dist/cli/packageManager.js +29 -0
  18. package/dist/cli/packageVersion.js +24 -0
  19. package/dist/cli/pageIndex.js +27 -1
  20. package/dist/cli/props.js +4 -3
  21. package/dist/cli/semver.js +26 -0
  22. package/dist/cli/staticPage.js +356 -0
  23. package/dist/cli/sync.js +11 -4
  24. package/dist/cli/update.js +93 -0
  25. package/dist/cli/updateCheck.js +143 -0
  26. package/dist/cli/updatePlan.js +202 -0
  27. package/dist/cli/workspace.js +10 -5
  28. package/dist/native/catalog/catalogNavigation.js +129 -0
  29. package/dist/native/catalog/comparison.js +283 -0
  30. package/dist/native/catalog/tokens.js +134 -0
  31. package/dist/native/catalog/types.js +1 -0
  32. package/native/catalog/CatalogShell.tsx +105 -11
  33. package/native/catalog/CatalogSidebar.tsx +58 -2
  34. package/native/catalog/ComparisonGrid.tsx +9 -7
  35. package/native/catalog/SectionBlock.tsx +5 -0
  36. package/native/catalog/SpacingScaleGallery.tsx +1 -1
  37. package/native/catalog/UpdatePanel.tsx +556 -0
  38. package/native/catalog/catalogNavigation.ts +70 -1
  39. package/native/catalog/comparison.ts +63 -5
  40. package/native/catalog/index.ts +5 -1
  41. package/native/catalog/pageApi.ts +11 -2
  42. package/native/catalog/tokens.ts +15 -0
  43. package/native/catalog/types.ts +19 -5
  44. package/native/catalog/updatePanelState.ts +74 -0
  45. package/package.json +5 -1
  46. package/starter-kit/components/Avatar/Avatar.catalog.tsx +4 -22
  47. package/starter-kit/components/Badge/Badge.catalog.tsx +15 -23
  48. package/starter-kit/components/Banner/Banner.tsx +1 -1
  49. package/starter-kit/components/BottomSheet/BottomSheet.tsx +2 -2
  50. package/starter-kit/components/Button/Button.catalog.tsx +15 -29
  51. package/starter-kit/components/Dialog/Dialog.tsx +2 -2
  52. package/starter-kit/components/Pill/Pill.catalog.tsx +12 -26
package/README.md CHANGED
@@ -61,6 +61,8 @@ Either way, then:
61
61
  ```bash
62
62
  npm run ds-viewer dev # opens the catalog in a browser, re-syncing on every file change
63
63
  npm run ds-viewer sync # regenerates component/token/page data without starting a server
64
+ npm run ds-viewer doctor # checks every page for drift and coverage gaps (--json, --ci)
65
+ npm run ds-viewer explain <Page> # prints why a page's specimens are laid out the way they are
64
66
  ```
65
67
 
66
68
  `ds-viewer.config.ts` fields: `name`, `logo` (optional — a path to an image asset, shown in the
@@ -204,6 +206,155 @@ data, not a component — see `ColorsGallery`/`SpacingGallery`/`TypographyGaller
204
206
  output full width under a "Tokens" label and shows only a Quick reference card with the source
205
207
  path.
206
208
 
209
+ ## Keeping the catalog current
210
+
211
+ ### Binding examples to props
212
+
213
+ A grid's row or column axis can name the real prop it demonstrates:
214
+
215
+ ```tsx
216
+ import { defineCatalogPage, grid } from '@krapwoo/ds-viewer';
217
+
218
+ comparison: grid(
219
+ 'Variant', 'State',
220
+ { prop: 'variant', items: [
221
+ { key: 'primary', label: 'Primary' },
222
+ { key: 'ghost', label: 'Ghost' },
223
+ ] },
224
+ [{ key: 'default', label: 'Default' }, { key: 'disabled', label: 'Disabled' }],
225
+ (row, column) => <Button variant={row} disabled={column === 'disabled'} onPress={() => {}} />,
226
+ ),
227
+ ```
228
+
229
+ Only a prop typed as a string-literal union (2 or more options) can be bound this way; each item's
230
+ `key` must then be a real option of that prop. An axis that doesn't name a single real prop (e.g.
231
+ "which of three optional props is set," or a continuous `number`) stays a plain array of
232
+ `{ key, label }` — just as valid, and never flagged by `doctor`. `variants`/`states` list items use
233
+ the existing `props` tag (`{ key: 'ghost', props: { variant: 'ghost' }, node: ... }`) the same way.
234
+ Binding is what lets `doctor` tell you precisely when a renamed or removed option leaves a stale
235
+ example behind, instead of you finding out by reading the diff.
236
+
237
+ ### `npx ds-viewer doctor`
238
+
239
+ Reads every page with the TypeScript compiler — it never imports a page file or runs app code.
240
+ Human output is grouped by page, each issue with a one-line fix:
241
+
242
+ ```
243
+ Button
244
+ [error] bound-option-removed: The row "outline" is bound to "variant", which no longer has that option.
245
+ Fix: Delete this row, or update its key to a current option of "variant".
246
+
247
+ 0 errors, 1 warning — 38 components (37 with examples), 2 unbound examples.
248
+ ```
249
+
250
+ - `--json` prints the same report as stable, versioned data: `{ version: 1, summary: { errors, warnings, components, withExamples, unboundExamples }, update: null, issues: [{ id, severity, page?, file?, line?, message, fix }] }`. `update` is always `null` in this release (filled in starting 0.4's update check); `file` is always relative to the project root. `line` is populated for a `page-parse-error` found by typechecking a page file (see below), and always `undefined` for every other issue id — threading a real position through every rule is future work.
251
+ - `--ci` exits 1 only when an error was found — a warning alone never fails CI, and `--ci` never makes a network call.
252
+ - `doctor.strict: true` in `ds-viewer.config.ts` promotes every warning to an error, for both the summary counts and `--ci`'s exit code.
253
+ - A page whose layout data (`specimenSize`, `group`, grid `rows`/`columns`, list items' `key`/`name`/`props`/`group`, `propNotes` keys) isn't a literal — written as something other than a literal value, array, object, or a same-file `const` reference — gets one "page not statically checkable" warning. A page file that fails to parse *or typecheck* is `page-parse-error` instead: every page file is also run through the TypeScript type checker (the same way a component file already is), so a genuine syntax or type error in a page is reported with its line.
254
+
255
+ ## Known gaps
256
+
257
+ - The viewer's own dev-mode console warnings only cover `duplicate-page-id` (design §4's "the same rules appear as development warnings naming the page" — scoped to that one rule in 0.3). Bound-axis drift (`bound-option-removed`, `bound-axis-prop-removed`) has no viewer-side warning yet; `npx ds-viewer doctor` (and its `--ci` workflow) is the only place that catches it today.
258
+
259
+ ### `npx ds-viewer explain <Page>`
260
+
261
+ Prints each layout decision with its reason, using the exact same pure functions the catalog itself
262
+ renders with:
263
+
264
+ ```
265
+ Button
266
+ Grid (Variant × State)
267
+ 5 rows × 3 columns (regular); 3 of 3 max regular columns used — fits a 1280px laptop.
268
+ ```
269
+
270
+ Side-by-side placement depends on a real measured height, so by default `explain` reports it as
271
+ "decided in the viewer." Pass `--heights <firstBlockPx>,<secondBlockPx>` to check that decision here
272
+ too, assuming a 1280px laptop (952px content width) the same way `doctor`'s own column-limit check
273
+ does. `--json` is available on `explain` as well.
274
+
275
+ ### The `AGENTS.md` section and GitHub Action
276
+
277
+ `npx @krapwoo/ds-viewer init` (either path) appends a short section to `AGENTS.md` between
278
+ `<!-- ds-viewer:start v1 -->`/`<!-- ds-viewer:end -->` markers — creating the file if it doesn't
279
+ exist — telling an AI working on the project to update a page alongside its component, bind
280
+ examples to props, never invent grid cells or groups, and run `doctor`/`explain` before finishing.
281
+ It also writes `.github/workflows/ds-viewer.yml`, a SHA-pinned Action that installs dependencies
282
+ (picking `npm ci`, `pnpm install --frozen-lockfile`, or `yarn install --immutable` from whichever
283
+ lockfile the project has at that moment) and then runs `npx ds-viewer doctor --ci` on every pull
284
+ request. Both are written once; re-running `init` adds
285
+ either one only if it's missing (an `AGENTS.md` without the start marker counts as missing the
286
+ section, even if the file already has other content) and never touches anything outside its own
287
+ markers or its own file.
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
+
207
358
  ## What's included
208
359
 
209
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. */
@@ -0,0 +1,31 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const START_MARKER = '<!-- ds-viewer:start v1 -->';
4
+ const END_MARKER = '<!-- ds-viewer:end -->';
5
+ // Design §4's four numbered lines, verbatim — no heading added: the design names only these four
6
+ // lines as the section's content.
7
+ const SECTION_BODY = [
8
+ "1. When you add or change a component's props or a token, update its \`*.catalog.tsx\` in the same change.",
9
+ "2. Bind examples to props (\`prop\` on grid axes, \`props\` on list items) so drift is detected.",
10
+ '3. Never invent grid cells or groups; author only combinations that exist.',
11
+ "4. Run \`npx ds-viewer doctor\` before finishing and fix every error. Use \`npx ds-viewer explain <Page>\` to check layout; see the README's page-authoring guide.",
12
+ ].join('\n');
13
+ /** Design §4 "AGENTS.md section", §1 Ownership, §2 step 3. Appends between the exact markers,
14
+ * creating the file when absent — never touches a byte outside them, and never runs at all once
15
+ * the start marker is already present, even if the rest of the file looks nothing like what this
16
+ * function would have written (design: "An AGENTS.md without the markers counts as missing the
17
+ * section" — the converse is also true: a file WITH the marker is never rewritten in 0.3). */
18
+ export function ensureAgentsFileSection(projectRoot) {
19
+ const agentsPath = path.join(projectRoot, 'AGENTS.md');
20
+ const existing = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : '';
21
+ if (existing.includes(START_MARKER))
22
+ return [];
23
+ // Minor finding (Fable's review): a CRLF `AGENTS.md` must not end up with mixed line endings
24
+ // after the append — the separator and the whole section below use whichever newline the
25
+ // existing file already uses.
26
+ const newline = existing.includes('\r\n') ? '\r\n' : '\n';
27
+ const separator = existing === '' ? '' : existing.endsWith(`${newline}${newline}`) ? '' : existing.endsWith(newline) ? newline : `${newline}${newline}`;
28
+ const body = newline === '\n' ? SECTION_BODY : SECTION_BODY.replace(/\n/g, newline);
29
+ writeFileSync(agentsPath, `${existing}${separator}${START_MARKER}${newline}${body}${newline}${END_MARKER}${newline}`);
30
+ return [agentsPath];
31
+ }
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
  }