@krapwoo/ds-viewer 0.6.2 → 0.6.3
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 +5 -0
- package/dist/cli/dev.js +40 -22
- package/dist/cli/endpoint.js +13 -2
- package/dist/cli/init.js +8 -3
- package/dist/cli/main.js +21 -1
- package/dist/cli/render.js +43 -3
- package/dist/cli/sync.js +2 -1
- package/dist/cli/types.js +8 -1
- package/dist/cli/updateCheck.js +7 -5
- package/dist/cli/workspace.js +15 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -526,6 +526,11 @@ and adds what only a real render shows:
|
|
|
526
526
|
It needs `puppeteer` in your project (`npm install --save-dev puppeteer`); ds-viewer doesn't
|
|
527
527
|
install a browser. It takes about two minutes for 50 pages and works with `--json` (progress
|
|
528
528
|
goes to stderr), `--ci` and `doctor.strict`, so CI can run `npx ds-viewer doctor --render --ci`.
|
|
529
|
+
|
|
530
|
+
It starts its own `dev` server, with `--no-update-endpoint` and its own generated directory
|
|
531
|
+
(`.ds-viewer-render/`, gitignored by `init` alongside `.ds-viewer/`). Nothing it generates touches
|
|
532
|
+
the workspace a running `dev` owns, so you can run it beside a catalog you already have open and
|
|
533
|
+
that catalog is unaffected — including its update page's actions.
|
|
529
534
|
It stops the viewer it started, including on Ctrl-C (exit codes 130, 143 or 129 for SIGINT,
|
|
530
535
|
SIGTERM or SIGHUP). On Windows it stops the viewer with `taskkill`; that path is unit-tested, not
|
|
531
536
|
run on a real Windows machine.
|
package/dist/cli/dev.js
CHANGED
|
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
|
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { resolveConfig } from "./config.js";
|
|
8
8
|
import { sync } from "./sync.js";
|
|
9
|
-
import { writeWorkspace } from "./workspace.js";
|
|
9
|
+
import { workspacePath, writeWorkspace } from "./workspace.js";
|
|
10
10
|
import { findFreePort } from "./port.js";
|
|
11
11
|
import { globBaseFolder } from "./glob.js";
|
|
12
12
|
import { buildVersionStatus, createVersionController, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck, runUpdateCheck, statusFromCheck, writeUpdateFile, writeVersionFile, } from "./updateCheck.js";
|
|
@@ -15,9 +15,10 @@ import { createUpdateEndpoint } from "./endpoint.js";
|
|
|
15
15
|
import { buildUpdatePlan } from "./updatePlan.js";
|
|
16
16
|
import { lastSummaryLine } from "./doctor.js";
|
|
17
17
|
import { detectPackageManager, installUpgradeCommand, platformCommand } from "./packageManager.js";
|
|
18
|
+
import { DEFAULT_WORKSPACE_DIR, RENDER_WORKSPACE_DIR } from "./types.js";
|
|
18
19
|
export class LocalInstallMissingError extends Error {
|
|
19
20
|
}
|
|
20
|
-
const IGNORED_WATCH_SEGMENTS = new Set([
|
|
21
|
+
const IGNORED_WATCH_SEGMENTS = new Set([DEFAULT_WORKSPACE_DIR, RENDER_WORKSPACE_DIR, 'node_modules', '.git']);
|
|
21
22
|
/** False for a changed path under `.ds-viewer/` (package-owned output — reacting to it would make
|
|
22
23
|
* `dev`'s own `sync` retrigger itself), `node_modules/`, or `.git/`; true for everything else, so
|
|
23
24
|
* `dev`'s watcher (below) only re-syncs on a project source change. */
|
|
@@ -58,8 +59,13 @@ export function watchTargetFolders(config) {
|
|
|
58
59
|
* workspace until `dev` was restarted, because `writeWorkspace` was never called again with the
|
|
59
60
|
* new value (controller end-to-end finding E1). Returns the freshly-resolved config so `dev` can
|
|
60
61
|
* keep watching with it. */
|
|
61
|
-
export function reloadWorkspace(projectRoot, updateEndpoint
|
|
62
|
-
|
|
62
|
+
export function reloadWorkspace(projectRoot, updateEndpoint,
|
|
63
|
+
// `resolveConfig` reads the user's file, which never names a workspace directory, so a run that
|
|
64
|
+
// owns a non-default one has to put it back — otherwise a config change moves every generated
|
|
65
|
+
// file into `.ds-viewer`, which is the collision this parameter exists to prevent.
|
|
66
|
+
workspaceDir) {
|
|
67
|
+
const resolved = resolveConfig(projectRoot);
|
|
68
|
+
const config = workspaceDir ? { ...resolved, workspaceDir } : resolved;
|
|
63
69
|
sync(config);
|
|
64
70
|
writeWorkspace(config, updateEndpoint);
|
|
65
71
|
return config;
|
|
@@ -81,8 +87,8 @@ export async function refreshUpdateFile(config, ownVersion, options = {}) {
|
|
|
81
87
|
? statusFromCheck(ownVersion, autoCheck, { result: await injected(ownVersion), reached: true })
|
|
82
88
|
: { current: ownVersion, autoCheck, lastOutcome: 'not-run', update: null }
|
|
83
89
|
: await buildVersionStatus(ownVersion, autoCheck);
|
|
84
|
-
writeUpdateFile(config.projectRoot, status.update ? { ...status.update, checkedAt: status.lastCheckedAt ?? new Date().toISOString() } : undefined);
|
|
85
|
-
writeVersionFile(config.projectRoot, status);
|
|
90
|
+
writeUpdateFile(config.projectRoot, status.update ? { ...status.update, checkedAt: status.lastCheckedAt ?? new Date().toISOString() } : undefined, config.workspaceDir);
|
|
91
|
+
writeVersionFile(config.projectRoot, status, config.workspaceDir);
|
|
86
92
|
return status;
|
|
87
93
|
}
|
|
88
94
|
// Promisified, not `execFileSync` — install/migrate/doctor each take real seconds, and this now
|
|
@@ -246,7 +252,7 @@ export async function performRestart(handoff, deps = {}) {
|
|
|
246
252
|
try {
|
|
247
253
|
for (const watcher of handoff.watchers)
|
|
248
254
|
watcher.close();
|
|
249
|
-
handoff.endpointServer
|
|
255
|
+
handoff.endpointServer?.close();
|
|
250
256
|
killGroup(handoff.child, 'SIGTERM');
|
|
251
257
|
// Errata 7d: race the old child's exit against a 10s timer, then SIGKILL the group — a wedged
|
|
252
258
|
// Expo/Metro process must never block the restart forever.
|
|
@@ -299,13 +305,15 @@ export async function performRestart(handoff, deps = {}) {
|
|
|
299
305
|
export async function dev(initialConfig, devOptions = {}) {
|
|
300
306
|
assertLocalInstall(initialConfig.projectRoot);
|
|
301
307
|
const ownVersion = readOwnVersion(import.meta.dirname);
|
|
302
|
-
|
|
308
|
+
// Carried on the config so every writer (sync, writeWorkspace, the update files) lands in this
|
|
309
|
+
// run's own directory, and so a config reload cannot silently drop it.
|
|
310
|
+
let config = devOptions.workspaceDir ? { ...initialConfig, workspaceDir: devOptions.workspaceDir } : initialConfig;
|
|
303
311
|
const version = createVersionController({
|
|
304
312
|
ownVersion,
|
|
305
313
|
projectRoot: initialConfig.projectRoot,
|
|
306
314
|
prefsDir: defaultPreferencesDir(),
|
|
307
315
|
env: process.env,
|
|
308
|
-
initial: await refreshUpdateFile(
|
|
316
|
+
initial: await refreshUpdateFile(config, ownVersion),
|
|
309
317
|
getConfig: () => config,
|
|
310
318
|
// **Check now** always asks npm, even with automatic checks off: you asked for this one check.
|
|
311
319
|
// The result lives only in this process; `update.json` and `version.json` are rewritten at
|
|
@@ -316,7 +324,7 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
316
324
|
// Restart handoff step 2/4: a status file left by the *previous* `dev` process (right before it
|
|
317
325
|
// exited to restart) is read once, then deleted — a plain refresh afterward shows "idle"/"no
|
|
318
326
|
// update known" instead of replaying a stale success/failure forever.
|
|
319
|
-
const statusFile =
|
|
327
|
+
const statusFile = workspacePath(config, 'update-status.json');
|
|
320
328
|
let updateStatus = { phase: 'idle' };
|
|
321
329
|
if (existsSync(statusFile)) {
|
|
322
330
|
try {
|
|
@@ -390,14 +398,24 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
390
398
|
checkNow: version.checkNow,
|
|
391
399
|
setAutoCheck: version.setAutoCheck,
|
|
392
400
|
};
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
+
// A throwaway server (a render check's own `dev`) takes no update actions, and creating an
|
|
402
|
+
// endpoint would bake its short-lived port into the shared workspace — pointing an already-open
|
|
403
|
+
// catalog at a port that dies with this process. See `writeWorkspace`.
|
|
404
|
+
let updateEndpoint;
|
|
405
|
+
let endpointServer;
|
|
406
|
+
if (!devOptions.noUpdateEndpoint) {
|
|
407
|
+
endpointServer = createUpdateEndpoint(endpointDeps);
|
|
408
|
+
await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
|
|
409
|
+
const endpointPort = endpointServer.address().port;
|
|
410
|
+
// Printed for the same reason the Metro port above is printed: a developer (or Task 20's own
|
|
411
|
+
// verification) checking the endpoint directly (e.g. with curl) needs its port — the secret
|
|
412
|
+
// itself is never printed; the endpoint rejects anything that doesn't already have it.
|
|
413
|
+
console.log(`Update endpoint listening on 127.0.0.1:${endpointPort}`);
|
|
414
|
+
updateEndpoint = { baseUrl: `http://127.0.0.1:${endpointPort}`, secret };
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
console.log('Update endpoint disabled for this run (--no-update-endpoint).');
|
|
418
|
+
}
|
|
401
419
|
const workspace = writeWorkspace(config, updateEndpoint);
|
|
402
420
|
let debounceTimer;
|
|
403
421
|
const onSourceChange = (_eventType, relativePath) => {
|
|
@@ -426,7 +444,7 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
426
444
|
clearTimeout(configDebounceTimer);
|
|
427
445
|
configDebounceTimer = setTimeout(() => {
|
|
428
446
|
try {
|
|
429
|
-
config = reloadWorkspace(config.projectRoot, updateEndpoint);
|
|
447
|
+
config = reloadWorkspace(config.projectRoot, updateEndpoint, config.workspaceDir);
|
|
430
448
|
version.refreshAutoCheck();
|
|
431
449
|
}
|
|
432
450
|
catch (error) {
|
|
@@ -451,7 +469,7 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
451
469
|
child.on('exit', (code) => {
|
|
452
470
|
for (const watcher of watchers)
|
|
453
471
|
watcher.close();
|
|
454
|
-
endpointServer
|
|
472
|
+
endpointServer?.close();
|
|
455
473
|
process.exitCode = code ?? 0;
|
|
456
474
|
});
|
|
457
475
|
// Node's default SIGTERM/SIGINT/SIGHUP behavior exits this process without touching its
|
|
@@ -464,7 +482,7 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
464
482
|
process.on(signal, () => {
|
|
465
483
|
for (const watcher of watchers)
|
|
466
484
|
watcher.close();
|
|
467
|
-
endpointServer
|
|
485
|
+
endpointServer?.close();
|
|
468
486
|
killGroup(child, signal);
|
|
469
487
|
process.exit(0);
|
|
470
488
|
});
|
|
@@ -476,7 +494,7 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
476
494
|
console.error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
477
495
|
for (const watcher of watchers)
|
|
478
496
|
watcher.close();
|
|
479
|
-
endpointServer
|
|
497
|
+
endpointServer?.close();
|
|
480
498
|
killGroup(child, 'SIGTERM');
|
|
481
499
|
process.exit(1);
|
|
482
500
|
};
|
package/dist/cli/endpoint.js
CHANGED
|
@@ -50,6 +50,17 @@ function sendJson(res, status, body, allowedOrigin) {
|
|
|
50
50
|
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': allowedOrigin });
|
|
51
51
|
res.end(JSON.stringify(body));
|
|
52
52
|
}
|
|
53
|
+
/** For a refusal the page is *allowed* to read: a wrong secret or an unknown route, where the
|
|
54
|
+
* Origin check has already passed. Without the allow-origin header the browser blocks the
|
|
55
|
+
* response and the page cannot tell "wrong secret" from "server not running" — both arrive as
|
|
56
|
+
* the same opaque network failure, which is how a stale secret looked like a dead server.
|
|
57
|
+
*
|
|
58
|
+
* A rejected or absent Origin deliberately gets no CORS headers at all (see the Origin checks
|
|
59
|
+
* below): a page from somewhere else is told nothing, not even which origin is allowed. */
|
|
60
|
+
function sendText(res, status, body, allowedOrigin) {
|
|
61
|
+
res.writeHead(status, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': allowedOrigin });
|
|
62
|
+
res.end(body);
|
|
63
|
+
}
|
|
53
64
|
/** Design §5 "Local endpoint safeguards", every one of them, in the exact order this plan's own
|
|
54
65
|
* `spikes/cors-endpoint/` spike proved: Origin (including `OPTIONS`) → secret → route. Does not
|
|
55
66
|
* call `.listen()` itself — Task 18's `dev.ts` owns the real port (`0` → OS-assigned, `127.0.0.1`
|
|
@@ -102,7 +113,7 @@ export function createUpdateEndpoint(deps) {
|
|
|
102
113
|
return;
|
|
103
114
|
}
|
|
104
115
|
if (!secretMatches(req.headers[DS_VIEWER_SECRET_HEADER], deps.secret)) {
|
|
105
|
-
res
|
|
116
|
+
sendText(res, 401, 'Missing or wrong secret.', deps.allowedOrigin);
|
|
106
117
|
return;
|
|
107
118
|
}
|
|
108
119
|
if (req.method === 'POST' && req.url === '/plan') {
|
|
@@ -245,6 +256,6 @@ export function createUpdateEndpoint(deps) {
|
|
|
245
256
|
}
|
|
246
257
|
return;
|
|
247
258
|
}
|
|
248
|
-
res
|
|
259
|
+
sendText(res, 404, 'Not found.', deps.allowedOrigin);
|
|
249
260
|
});
|
|
250
261
|
}
|
package/dist/cli/init.js
CHANGED
|
@@ -7,6 +7,7 @@ import { detectComponentFolders, detectTokenFiles } from "./detect.js";
|
|
|
7
7
|
import { ensureAgentsFileSection } from "./agentsFile.js";
|
|
8
8
|
import { ensureGithubAction } from "./githubAction.js";
|
|
9
9
|
import { readOwnVersion } from "./packageVersion.js";
|
|
10
|
+
import { DEFAULT_WORKSPACE_DIR, RENDER_WORKSPACE_DIR } from "./types.js";
|
|
10
11
|
async function promptConfirm(message) {
|
|
11
12
|
// A closed, non-interactive stdin (CI, piped npx) would reject `question` with an AbortError;
|
|
12
13
|
// say how to proceed instead.
|
|
@@ -129,11 +130,15 @@ function ensureSharedProjectFiles(projectRoot) {
|
|
|
129
130
|
written.push(packageJsonPath);
|
|
130
131
|
}
|
|
131
132
|
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
132
|
-
|
|
133
|
+
// Both generated workspaces: the one `dev` owns, and the one `doctor --render`'s own server uses
|
|
134
|
+
// so it never writes the files `dev` is serving.
|
|
135
|
+
const gitignoreEntries = [`${DEFAULT_WORKSPACE_DIR}/`, `${RENDER_WORKSPACE_DIR}/`];
|
|
133
136
|
const existingGitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
|
|
134
|
-
|
|
137
|
+
const presentLines = new Set(existingGitignore.split('\n').map((line) => line.replace(/\r$/, '')));
|
|
138
|
+
const missing = gitignoreEntries.filter((entry) => !presentLines.has(entry));
|
|
139
|
+
if (missing.length > 0) {
|
|
135
140
|
const separator = existingGitignore === '' || existingGitignore.endsWith('\n') ? '' : '\n';
|
|
136
|
-
writeFileSync(gitignorePath, `${existingGitignore}${separator}${
|
|
141
|
+
writeFileSync(gitignorePath, `${existingGitignore}${separator}${missing.join('\n')}\n`);
|
|
137
142
|
written.push(gitignorePath);
|
|
138
143
|
}
|
|
139
144
|
written.push(...ensureAgentsFileSection(projectRoot));
|
package/dist/cli/main.js
CHANGED
|
@@ -7,6 +7,7 @@ import { dev } from "./dev.js";
|
|
|
7
7
|
import { sync } from "./sync.js";
|
|
8
8
|
import { formatHuman, runDoctor, toDoctorJson, withExtraIssues } from "./doctor.js";
|
|
9
9
|
import { renderCheck } from "./render.js";
|
|
10
|
+
import { RENDER_WORKSPACE_DIR } from "./types.js";
|
|
10
11
|
import { explainPage, formatExplain, parseHeights } from "./explain.js";
|
|
11
12
|
import { readOwnVersion } from "./packageVersion.js";
|
|
12
13
|
import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
|
|
@@ -25,6 +26,10 @@ Commands:
|
|
|
25
26
|
dev Sync, then serve the catalog on a local web address and keep it current as files change
|
|
26
27
|
[--port <number>]
|
|
27
28
|
Pin the catalog (and its update endpoint's restart handoff) to this port
|
|
29
|
+
[--no-update-endpoint]
|
|
30
|
+
Serve with no local update endpoint, leaving the one a running dev server recorded alone
|
|
31
|
+
[--workspace-dir <name>]
|
|
32
|
+
Generate into this directory instead of .ds-viewer, so two servers never share files
|
|
28
33
|
doctor Check every page for drift and coverage gaps
|
|
29
34
|
--json print the machine-readable report instead
|
|
30
35
|
--ci exit 1 when any error was found (never on a warning alone)
|
|
@@ -243,7 +248,22 @@ async function main() {
|
|
|
243
248
|
const portIndex = rest.indexOf('--port');
|
|
244
249
|
const parsedPort = portIndex !== -1 ? Number(rest[portIndex + 1]) : undefined;
|
|
245
250
|
const pinnedPort = parsedPort !== undefined && Number.isInteger(parsedPort) ? parsedPort : undefined;
|
|
246
|
-
|
|
251
|
+
// `--no-update-endpoint`: run the catalog without the local update endpoint, and leave the
|
|
252
|
+
// endpoint an already-running `dev` recorded in the shared workspace alone. `doctor --render`
|
|
253
|
+
// starts its own server this way.
|
|
254
|
+
const noUpdateEndpoint = rest.includes('--no-update-endpoint');
|
|
255
|
+
// `--workspace-dir <name>`: which generated directory this run owns, so a second server (a
|
|
256
|
+
// render check's own) never writes the files the first one is serving. A bare directory name
|
|
257
|
+
// only — anything with a path separator, or a parent reference, would let an argument write
|
|
258
|
+
// outside the project.
|
|
259
|
+
const dirIndex = rest.indexOf('--workspace-dir');
|
|
260
|
+
const requestedDir = dirIndex !== -1 ? rest[dirIndex + 1] : undefined;
|
|
261
|
+
if (requestedDir !== undefined && (requestedDir === '' || requestedDir !== path.basename(requestedDir) || requestedDir.startsWith('.') === false)) {
|
|
262
|
+
console.error(`--workspace-dir must be a single hidden directory name such as ${RENDER_WORKSPACE_DIR}.`);
|
|
263
|
+
process.exitCode = 1;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
await dev(config, { pinnedPort, noUpdateEndpoint, workspaceDir: requestedDir });
|
|
247
267
|
return;
|
|
248
268
|
}
|
|
249
269
|
console.error(`Unknown command "${command}".\n\n${USAGE}`);
|
package/dist/cli/render.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// only a real render shows (console errors, pages that fail to render, the viewer's own [Catalog]
|
|
3
3
|
// layout warnings). The browser is resolved from the project; ds-viewer never installs one.
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { get as httpGet } from 'node:http';
|
|
5
6
|
import { createRequire } from 'node:module';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { RENDER_WORKSPACE_DIR } from "./types.js";
|
|
8
10
|
const OVERFLOW = /is \d+px wide in a \d+px cell/;
|
|
9
11
|
/**
|
|
10
12
|
* Turns one page's console output into doctor findings. Console errors and a missing heading are
|
|
@@ -173,10 +175,34 @@ function outputTail() {
|
|
|
173
175
|
}
|
|
174
176
|
/** Starts the project's own `dev` in its own process group. `onSpawn` receives the child at once,
|
|
175
177
|
* so a signal during startup can still stop it. */
|
|
178
|
+
/** Polls `http://localhost:<port>/` until it answers, so a caller never navigates to a port Metro
|
|
179
|
+
* has not bound yet. Any HTTP reply counts — the page's own content is checked afterwards. */
|
|
180
|
+
async function waitForServer(port, timeoutMs = DEV_START_TIMEOUT_MS) {
|
|
181
|
+
const deadline = Date.now() + timeoutMs;
|
|
182
|
+
for (;;) {
|
|
183
|
+
const answered = await new Promise((resolve) => {
|
|
184
|
+
const request = httpGet({ host: 'localhost', port, path: '/', timeout: 2000 }, () => resolve(true));
|
|
185
|
+
request.on('error', () => resolve(false));
|
|
186
|
+
request.on('timeout', () => {
|
|
187
|
+
request.destroy();
|
|
188
|
+
resolve(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
if (answered)
|
|
192
|
+
return;
|
|
193
|
+
if (Date.now() > deadline)
|
|
194
|
+
throw new Error(`Timed out waiting for the catalog to answer on port ${port}.`);
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
176
198
|
function startDev(projectRoot, onSpawn) {
|
|
177
199
|
const main = path.join(path.dirname(fileURLToPath(import.meta.url)), 'main.js');
|
|
178
200
|
return new Promise((resolve, reject) => {
|
|
179
|
-
|
|
201
|
+
// Its own workspace directory and no update endpoint: this server exists to render pages, and
|
|
202
|
+
// every file it generates would otherwise overwrite the ones an already-running `dev` is
|
|
203
|
+
// serving — including that developer's endpoint, pointing their open catalog at a port that
|
|
204
|
+
// dies with this check.
|
|
205
|
+
const child = spawn(process.execPath, [main, 'dev', '--no-update-endpoint', '--workspace-dir', RENDER_WORKSPACE_DIR], {
|
|
180
206
|
cwd: projectRoot,
|
|
181
207
|
env: { ...process.env, CI: '1', DS_VIEWER_NO_UPDATE_CHECK: '1' },
|
|
182
208
|
detached: process.platform !== 'win32',
|
|
@@ -202,7 +228,13 @@ function startDev(projectRoot, onSpawn) {
|
|
|
202
228
|
if (match && !done) {
|
|
203
229
|
done = true;
|
|
204
230
|
clearTimeout(timer);
|
|
205
|
-
|
|
231
|
+
// `dev` prints that line before it spawns Expo, so the port is known well before Metro is
|
|
232
|
+
// listening on it. Resolving here raced the bundler: the first navigation could land on a
|
|
233
|
+
// port nothing was bound to yet. Wait for the server to actually answer.
|
|
234
|
+
const port = Number(match[1]);
|
|
235
|
+
waitForServer(port)
|
|
236
|
+
.then(() => resolve({ child, port }))
|
|
237
|
+
.catch((error) => fail(`${error.message} `));
|
|
206
238
|
}
|
|
207
239
|
});
|
|
208
240
|
child.on('exit', (code) => fail(`ds-viewer dev exited early (code ${code}).`));
|
|
@@ -272,10 +304,18 @@ export async function renderCheck(projectRoot, options = {}) {
|
|
|
272
304
|
}
|
|
273
305
|
}
|
|
274
306
|
// Runs in the browser; `document` is typed loosely because the CLI compiles without DOM types.
|
|
275
|
-
const
|
|
307
|
+
const readHrefs = () => home.evaluate(() => {
|
|
276
308
|
const doc = globalThis.document;
|
|
277
309
|
return [...new Set(Array.from(doc.querySelectorAll('a[href^="#"]')).map((a) => a.getAttribute('href') ?? ''))];
|
|
278
310
|
});
|
|
311
|
+
// The server answers before the bundle has run, and a workspace Metro has never bundled before
|
|
312
|
+
// (a fresh `.ds-viewer-render`) is slow the first time. Keep asking until the sidebar is there,
|
|
313
|
+
// rather than reading once and calling an empty page a failed render.
|
|
314
|
+
let hrefs = await readHrefs();
|
|
315
|
+
while (hrefs.length === 0 && Date.now() < deadline) {
|
|
316
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
317
|
+
hrefs = await readHrefs();
|
|
318
|
+
}
|
|
279
319
|
await home.close();
|
|
280
320
|
const ids = hrefs.map(idFromHref).filter(Boolean);
|
|
281
321
|
if (ids.length === 0) {
|
package/dist/cli/sync.js
CHANGED
|
@@ -4,13 +4,14 @@ import { globBaseFolder, resolveGlob } from "./glob.js";
|
|
|
4
4
|
import { createComponentReader } from "./props.js";
|
|
5
5
|
import { readTokenModules } from "./tokens.js";
|
|
6
6
|
import { discoverPages, writePageIndex } from "./pageIndex.js";
|
|
7
|
+
import { workspacePath } from "./workspace.js";
|
|
7
8
|
const GENERATED_INDEX = "export { default as components } from './components.json';\nexport { default as tokens } from './tokens.json';\n";
|
|
8
9
|
/** Reads detection, props, and tokens from source, then writes `.ds-viewer/generated/`
|
|
9
10
|
* (design §3 — "Output and speed": `components.json`, `tokens.json`, and the page import list,
|
|
10
11
|
* sorted and deterministic). Nothing from the project is executed. `resolveOptions` is test-only
|
|
11
12
|
* (see this task's Interfaces note) — production callers omit it. */
|
|
12
13
|
export function sync(config, resolveOptions) {
|
|
13
|
-
const generatedDir =
|
|
14
|
+
const generatedDir = workspacePath(config, 'generated');
|
|
14
15
|
mkdirSync(generatedDir, { recursive: true });
|
|
15
16
|
const excluded = new Set((config.exclude ?? []).flatMap((pattern) => resolveGlob(config.projectRoot, pattern)));
|
|
16
17
|
const componentEntryFiles = config.components.flatMap((pattern) => resolveGlob(config.projectRoot, pattern)).filter((file) => !excluded.has(file));
|
package/dist/cli/types.js
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
/** `DsViewerConfig` plus where it came from. Every CLI command after config loading works with
|
|
2
|
+
* this, not the raw `DsViewerConfig`, so paths are always resolved against the right project. */
|
|
3
|
+
/** The generated Expo workspace's directory name, relative to `projectRoot`. The default is the
|
|
4
|
+
* only one a user ever sees; a throwaway server (a render check's own `dev`) uses its own so the
|
|
5
|
+
* two never write the same files. */
|
|
6
|
+
export const DEFAULT_WORKSPACE_DIR = '.ds-viewer';
|
|
7
|
+
/** Used by `doctor --render`'s own `dev`. Gitignored next to the default. */
|
|
8
|
+
export const RENDER_WORKSPACE_DIR = '.ds-viewer-render';
|
package/dist/cli/updateCheck.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
|
|
5
|
+
import { DEFAULT_WORKSPACE_DIR } from "./types.js";
|
|
5
6
|
const CACHE_FILE_NAME = 'update-check.json';
|
|
6
7
|
const PACKAGE_NAME = '@krapwoo/ds-viewer';
|
|
7
8
|
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME).replace('%40', '@')}/latest`;
|
|
@@ -229,9 +230,10 @@ export function statusFromCheck(currentVersion, autoCheck, outcome) {
|
|
|
229
230
|
update: toNotice(outcome.result),
|
|
230
231
|
};
|
|
231
232
|
}
|
|
232
|
-
/** `dev` writes this next to `update.json`; the generated entry file imports it.
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
/** `dev` writes this next to `update.json`; the generated entry file imports it. `workspaceDir`
|
|
234
|
+
* is the run's own generated directory — see `workspacePath`. */
|
|
235
|
+
export function writeVersionFile(projectRoot, status, workspaceDir = DEFAULT_WORKSPACE_DIR) {
|
|
236
|
+
const file = path.join(projectRoot, workspaceDir, 'version.json');
|
|
235
237
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
236
238
|
writeFileSync(file, JSON.stringify(status));
|
|
237
239
|
return file;
|
|
@@ -239,8 +241,8 @@ export function writeVersionFile(projectRoot, status) {
|
|
|
239
241
|
/** Design §5: "`dev` writes the result to `.ds-viewer/update.json`; the viewer reads it." Always
|
|
240
242
|
* writes something — `null` when there is no result — so the generated entry file (Task 17) can
|
|
241
243
|
* unconditionally `import updateNotice from './update.json'` without ever hitting a missing file. */
|
|
242
|
-
export function writeUpdateFile(projectRoot, result) {
|
|
243
|
-
const file = path.join(projectRoot,
|
|
244
|
+
export function writeUpdateFile(projectRoot, result, workspaceDir = DEFAULT_WORKSPACE_DIR) {
|
|
245
|
+
const file = path.join(projectRoot, workspaceDir, 'update.json');
|
|
244
246
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
245
247
|
writeFileSync(file, JSON.stringify(result ?? null));
|
|
246
248
|
return file;
|
package/dist/cli/workspace.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { globBaseFolder } from "./glob.js";
|
|
4
|
+
import { DEFAULT_WORKSPACE_DIR } from "./types.js";
|
|
4
5
|
function metroConfigJs(extraWatchFolders) {
|
|
5
6
|
return `const path = require('path');
|
|
6
7
|
const fs = require('fs');
|
|
@@ -99,11 +100,23 @@ function configGlobFolders(config) {
|
|
|
99
100
|
function projectIncludeGlobs(config) {
|
|
100
101
|
return configGlobFolders(config).map((folder) => `../${folder}/**/*`);
|
|
101
102
|
}
|
|
103
|
+
/** This run's generated workspace directory. Every writer goes through here, so a run that owns a
|
|
104
|
+
* different directory (a render check's throwaway server) cannot touch another server's files. */
|
|
105
|
+
export function workspacePath(config, ...segments) {
|
|
106
|
+
return path.join(config.projectRoot, config.workspaceDir ?? DEFAULT_WORKSPACE_DIR, ...segments);
|
|
107
|
+
}
|
|
102
108
|
/** Writes the self-contained Expo web workspace at `.ds-viewer/` (design §2 "The preview
|
|
103
109
|
* workspace and `npx ds-viewer dev`"). Rewritten freely on every `dev` run — nothing here is
|
|
104
|
-
* user-owned.
|
|
110
|
+
* user-owned.
|
|
111
|
+
*
|
|
112
|
+
* `updateEndpoint` is the caller's own, inlined into `entry.tsx`. Without one (a render check's
|
|
113
|
+
* throwaway server, `--no-update-endpoint`) the catalog is served with no endpoint at all, so it
|
|
114
|
+
* makes no calls: the shell polls `/version` from every page, so inlining a *different* server's
|
|
115
|
+
* endpoint here would have every page call it from the wrong origin. Two `dev` servers share this
|
|
116
|
+
* one directory, so the last writer decides — see the README on running a render check beside an
|
|
117
|
+
* open catalog. */
|
|
105
118
|
export function writeWorkspace(config, updateEndpoint) {
|
|
106
|
-
const workspace =
|
|
119
|
+
const workspace = workspacePath(config);
|
|
107
120
|
mkdirSync(workspace, { recursive: true });
|
|
108
121
|
writeFileSync(path.join(workspace, 'package.json'), `${JSON.stringify({ name: 'ds-viewer-workspace', private: true, main: 'entry.tsx' }, null, 2)}\n`);
|
|
109
122
|
writeFileSync(path.join(workspace, 'app.json'), `${JSON.stringify({ expo: { name: config.name, slug: 'ds-viewer-workspace', platforms: ['web'] } }, null, 2)}\n`);
|