@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 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(['.ds-viewer', 'node_modules', '.git']);
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
- const config = resolveConfig(projectRoot);
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.close();
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
- let config = initialConfig;
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(initialConfig, ownVersion),
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 = path.join(config.projectRoot, '.ds-viewer', 'update-status.json');
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
- const endpointServer = createUpdateEndpoint(endpointDeps);
394
- await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
395
- const endpointPort = endpointServer.address().port;
396
- // Printed for the same reason the Metro port above is printed: a developer (or Task 20's own
397
- // verification) checking the endpoint directly (e.g. with curl) needs its port — the secret
398
- // itself is never printed; the endpoint rejects anything that doesn't already have it.
399
- console.log(`Update endpoint listening on 127.0.0.1:${endpointPort}`);
400
- const updateEndpoint = { baseUrl: `http://127.0.0.1:${endpointPort}`, secret };
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.close();
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.close();
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.close();
497
+ endpointServer?.close();
480
498
  killGroup(child, 'SIGTERM');
481
499
  process.exit(1);
482
500
  };
@@ -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.writeHead(401).end('Missing or wrong secret.');
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.writeHead(404).end('Not found.');
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
- const gitignoreEntry = '.ds-viewer/';
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
- if (!existingGitignore.split('\n').some((line) => line.replace(/\r$/, '') === gitignoreEntry)) {
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}${gitignoreEntry}\n`);
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
- await dev(config, { pinnedPort });
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}`);
@@ -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
- const child = spawn(process.execPath, [main, 'dev'], {
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
- resolve({ child, port: Number(match[1]) });
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 hrefs = await home.evaluate(() => {
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 = path.join(config.projectRoot, '.ds-viewer', 'generated');
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
- export {};
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';
@@ -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
- export function writeVersionFile(projectRoot, status) {
234
- const file = path.join(projectRoot, '.ds-viewer', 'version.json');
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, '.ds-viewer', 'update.json');
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;
@@ -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 = path.join(config.projectRoot, '.ds-viewer');
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`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",