@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.
- package/README.md +151 -0
- package/config/index.ts +4 -2
- package/dist/cli/agentsFile.js +31 -0
- package/dist/cli/dev.js +312 -15
- package/dist/cli/doctor.js +502 -0
- package/dist/cli/endpoint.js +142 -0
- package/dist/cli/explain.js +151 -0
- package/dist/cli/githubAction.js +65 -0
- package/dist/cli/init.js +9 -5
- package/dist/cli/kitDiff.js +132 -0
- package/dist/cli/kitRoot.js +20 -0
- package/dist/cli/main.js +123 -15
- package/dist/cli/migrate.js +30 -0
- package/dist/cli/migrateEntry.js +19 -0
- package/dist/cli/migrations/index.js +14 -0
- package/dist/cli/migrations/types.js +9 -0
- package/dist/cli/packageManager.js +29 -0
- package/dist/cli/packageVersion.js +24 -0
- package/dist/cli/pageIndex.js +27 -1
- package/dist/cli/props.js +4 -3
- package/dist/cli/semver.js +26 -0
- package/dist/cli/staticPage.js +356 -0
- package/dist/cli/sync.js +11 -4
- package/dist/cli/update.js +93 -0
- package/dist/cli/updateCheck.js +143 -0
- package/dist/cli/updatePlan.js +202 -0
- package/dist/cli/workspace.js +10 -5
- package/dist/native/catalog/catalogNavigation.js +129 -0
- package/dist/native/catalog/comparison.js +283 -0
- package/dist/native/catalog/tokens.js +134 -0
- package/dist/native/catalog/types.js +1 -0
- package/native/catalog/CatalogShell.tsx +105 -11
- package/native/catalog/CatalogSidebar.tsx +58 -2
- package/native/catalog/ComparisonGrid.tsx +9 -7
- package/native/catalog/SectionBlock.tsx +5 -0
- package/native/catalog/SpacingScaleGallery.tsx +1 -1
- package/native/catalog/UpdatePanel.tsx +556 -0
- package/native/catalog/catalogNavigation.ts +70 -1
- package/native/catalog/comparison.ts +63 -5
- package/native/catalog/index.ts +5 -1
- package/native/catalog/pageApi.ts +11 -2
- package/native/catalog/tokens.ts +15 -0
- package/native/catalog/types.ts +19 -5
- package/native/catalog/updatePanelState.ts +74 -0
- package/package.json +5 -1
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +4 -22
- package/starter-kit/components/Badge/Badge.catalog.tsx +15 -23
- package/starter-kit/components/Banner/Banner.tsx +1 -1
- package/starter-kit/components/BottomSheet/BottomSheet.tsx +2 -2
- package/starter-kit/components/Button/Button.catalog.tsx +15 -29
- package/starter-kit/components/Dialog/Dialog.tsx +2 -2
- 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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
119
|
-
// left alone, a signal sent only to `dev`'s own pid (not its process group) would
|
|
120
|
-
// spawned `expo start` running, still bound to its port (Important finding, Fable
|
|
121
|
-
// pass: this is exactly what would hang `check:catalog`'s driving script, Task 11, if
|
|
122
|
-
// didn't clean up on its own).
|
|
123
|
-
|
|
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
|
-
|
|
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
|
}
|