@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 +69 -0
- package/config/index.ts +4 -2
- package/dist/cli/dev.js +312 -15
- package/dist/cli/doctor.js +10 -0
- package/dist/cli/endpoint.js +142 -0
- package/dist/cli/githubAction.js +8 -11
- package/dist/cli/init.js +3 -3
- package/dist/cli/kitDiff.js +132 -0
- package/dist/cli/kitRoot.js +20 -0
- package/dist/cli/main.js +77 -18
- 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/semver.js +26 -0
- 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/tokens.js +15 -0
- package/native/catalog/CatalogShell.tsx +105 -11
- package/native/catalog/CatalogSidebar.tsx +58 -2
- package/native/catalog/SectionBlock.tsx +5 -0
- package/native/catalog/UpdatePanel.tsx +556 -0
- package/native/catalog/catalogNavigation.ts +70 -1
- package/native/catalog/index.ts +3 -0
- package/native/catalog/tokens.ts +15 -0
- package/native/catalog/types.ts +11 -0
- package/native/catalog/updatePanelState.ts +74 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
+
}
|