@krapwoo/ds-viewer 0.3.0 → 0.4.1

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.
@@ -0,0 +1,143 @@
1
+ // cli/updateCheck.ts
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
5
+ const CACHE_FILE_NAME = 'update-check.json';
6
+ const PACKAGE_NAME = '@krapwoo/ds-viewer';
7
+ const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME).replace('%40', '@')}/latest`;
8
+ const GITHUB_REPO = 'krapwoo/design-system-viewer';
9
+ const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
10
+ /** Design §5: a user-wide (not project-wide) cache folder, shared across every project running
11
+ * this CLI — one real release per package version, so there is nothing to key by project. */
12
+ export function cacheDirFor(platform, env, homedir) {
13
+ if (platform === 'darwin')
14
+ return path.posix.join(homedir, 'Library', 'Caches', 'ds-viewer');
15
+ if (platform === 'win32')
16
+ return path.win32.join(env.LOCALAPPDATA ?? path.win32.join(homedir, 'AppData', 'Local'), 'ds-viewer');
17
+ return path.posix.join(env.XDG_CACHE_HOME ?? path.posix.join(homedir, '.cache'), 'ds-viewer');
18
+ }
19
+ export function readCache(cacheDir) {
20
+ const file = path.join(cacheDir, CACHE_FILE_NAME);
21
+ if (!existsSync(file))
22
+ return undefined;
23
+ try {
24
+ return JSON.parse(readFileSync(file, 'utf8'));
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ function writeCache(cacheDir, cached) {
31
+ mkdirSync(cacheDir, { recursive: true });
32
+ writeFileSync(path.join(cacheDir, CACHE_FILE_NAME), JSON.stringify(cached));
33
+ }
34
+ export function isCacheFresh(cache, nowMs) {
35
+ return nowMs - Date.parse(cache.checkedAt) < TWENTY_FOUR_HOURS_MS;
36
+ }
37
+ /** The one place "is this actually worth showing" is decided: a cached/fetched `latest` that is
38
+ * not strictly newer than the version asking (already up to date, or — this repository's own
39
+ * `kit-host` before a real publish — temporarily *ahead* of what the registry reports) resolves
40
+ * to `undefined`, the same uniform "no update known" every other non-case already returns
41
+ * (Critical finding, Fable correction pass: without this, a stale/foreign cache entry could show
42
+ * a downgrade, or keep showing an update that was already applied). */
43
+ function toResultOrUndefined(cached, currentVersion) {
44
+ const current = parseVersion(currentVersion);
45
+ const latest = parseVersion(cached.latest);
46
+ if (compareVersions(latest, current) <= 0)
47
+ return undefined;
48
+ return { ...cached, current: currentVersion, breaking: isBreakingUpgrade(current, latest) };
49
+ }
50
+ /** `config` only ever needs its one field here — a plain object literal, not the full
51
+ * `ResolvedConfig`, is deliberately accepted so this has no import-time dependency on `cli/types.ts`. */
52
+ export function isUpdateCheckEnabled(config, env) {
53
+ if (env.DS_VIEWER_NO_UPDATE_CHECK === '1')
54
+ return false;
55
+ return config.updateCheck ?? true;
56
+ }
57
+ /** `- ` or `* ` markdown list items only, trimmed, in order, capped at 5 — a release body's prose
58
+ * paragraphs are never shown as if they were bullets. */
59
+ export function extractSummaryBullets(body) {
60
+ return body
61
+ .split('\n')
62
+ .map((line) => /^[-*]\s+(.*)$/.exec(line.trim())?.[1])
63
+ .filter((line) => Boolean(line))
64
+ .slice(0, 5);
65
+ }
66
+ /** Exported for `cli/updatePlan.ts` (Task 9), which needs the exact same "don't hang forever on
67
+ * the GitHub release" behavior for its own plan-building fetch (Important finding, Fable
68
+ * correction pass: that fetch had no timeout of its own before). */
69
+ export async function fetchWithTimeout(url, fetchImpl, timeoutMs) {
70
+ const controller = new AbortController();
71
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
72
+ try {
73
+ const response = await fetchImpl(url, { signal: controller.signal });
74
+ if (!response.ok)
75
+ throw new Error(`${url} responded ${response.status}`);
76
+ return await response.json();
77
+ }
78
+ finally {
79
+ clearTimeout(timer);
80
+ }
81
+ }
82
+ /** Design §5 "Update check" in full. Never throws — every failure (a stale/missing cache with no
83
+ * reachable registry, a GitHub release that 404s, a request that times out) resolves to either a
84
+ * usable result or `undefined`, so every caller can treat "no update known" as the one, uniform
85
+ * outcome of being offline, blocked, or genuinely up to date with nothing cached yet. */
86
+ export async function checkForUpdate(currentVersion, options = {}) {
87
+ // M5 (Minor, Fable correction pass): this function is documented — and relied on by `dev()`,
88
+ // which `await`s it before starting anything else — as never throwing. `parseVersion` (inside
89
+ // `toResultOrUndefined`) throws on a non-semver `latest`, whether from a parseable-but-malformed
90
+ // cache file or an unexpected 200 body with no real `version`; either would otherwise stop `dev`
91
+ // from starting at all.
92
+ try {
93
+ const now = options.now ?? Date.now;
94
+ const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
95
+ const cached = readCache(cacheDir);
96
+ if (cached && isCacheFresh(cached, now()))
97
+ return toResultOrUndefined(cached, currentVersion);
98
+ const fetchImpl = options.fetchImpl ?? fetch;
99
+ const timeoutMs = options.timeoutMs ?? 900;
100
+ let latest;
101
+ try {
102
+ const registryResponse = (await fetchWithTimeout(REGISTRY_URL, fetchImpl, timeoutMs));
103
+ latest = registryResponse.version;
104
+ }
105
+ catch {
106
+ // A stale cache is still better than nothing once offline — still run through the same
107
+ // not-actually-newer guard, never a raw passthrough.
108
+ return cached ? toResultOrUndefined(cached, currentVersion) : undefined;
109
+ }
110
+ let summary = [];
111
+ let releasedAt;
112
+ try {
113
+ const release = (await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${latest}`, fetchImpl, timeoutMs));
114
+ summary = release.body ? extractSummaryBullets(release.body) : [];
115
+ releasedAt = release.published_at;
116
+ }
117
+ catch {
118
+ // The version comparison is the half that matters for the footer/banner; a missing release
119
+ // summary (private repo hiccup, a tag pushed slightly before its release note) degrades to an
120
+ // empty "What's new" list, never a failed check.
121
+ }
122
+ const cachedResult = { latest, summary, releasedAt, checkedAt: new Date(now()).toISOString() };
123
+ // compareVersions is used only to decide *whether* this is worth caching as "the latest" at all
124
+ // — a registry that (briefly, during its own propagation) reports an older "latest" than what
125
+ // is already cached never regresses the cache.
126
+ if (!cached || compareVersions(parseVersion(cachedResult.latest), parseVersion(cached.latest)) >= 0) {
127
+ writeCache(cacheDir, cachedResult);
128
+ }
129
+ return toResultOrUndefined(cachedResult, currentVersion);
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ }
135
+ /** Design §5: "`dev` writes the result to `.ds-viewer/update.json`; the viewer reads it." Always
136
+ * writes something — `null` when there is no result — so the generated entry file (Task 17) can
137
+ * unconditionally `import updateNotice from './update.json'` without ever hitting a missing file. */
138
+ export function writeUpdateFile(projectRoot, result) {
139
+ const file = path.join(projectRoot, '.ds-viewer', 'update.json');
140
+ mkdirSync(path.dirname(file), { recursive: true });
141
+ writeFileSync(file, JSON.stringify(result ?? null));
142
+ return file;
143
+ }
@@ -0,0 +1,211 @@
1
+ // cli/updatePlan.ts
2
+ import { execFile, execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { detectPackageManager, platformCommand } from "./packageManager.js";
8
+ import { listKitFileDiffs } from "./kitDiff.js";
9
+ import { extractSummaryBullets, fetchWithTimeout } from "./updateCheck.js";
10
+ import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
11
+ const PACKAGE_NAME = '@krapwoo/ds-viewer';
12
+ const OFFLINE_MESSAGE = "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed.";
13
+ function tarballFileName(version) {
14
+ return `krapwoo-ds-viewer-${version}.tgz`;
15
+ }
16
+ function lockfileName(pm) {
17
+ if (pm === 'pnpm')
18
+ return 'pnpm-lock.yaml';
19
+ if (pm === 'yarn-classic' || pm === 'yarn-berry')
20
+ return 'yarn.lock';
21
+ return 'package-lock.json';
22
+ }
23
+ /** `git status --porcelain`'s format is `XY path` (plain) or `XY old -> new` (a rename) — the
24
+ * path starts at column 3 either way; a rename's own "dirty" identity is its *new* path (the one
25
+ * this plan's own `files` list names), never the old one (Important finding, Fable correction
26
+ * pass: `R old -> new` was previously read as the single literal path `"old -> new"`, which
27
+ * could never match a planned file). */
28
+ function parseDirtyPaths(porcelain) {
29
+ return new Set(porcelain
30
+ .split('\n')
31
+ .filter((line) => line.length > 3)
32
+ .map((line) => {
33
+ const raw = line.slice(3).trim();
34
+ const arrow = raw.indexOf(' -> ');
35
+ return arrow === -1 ? raw : raw.slice(arrow + 4);
36
+ }));
37
+ }
38
+ /** Strips the repository-root prefix `git rev-parse --show-prefix` reports from every path in a
39
+ * `git status --porcelain` listing (both sides of a rename), so the result reads relative to
40
+ * `projectRoot` the same way every test's injected fake already does. */
41
+ export function stripRepoPrefix(porcelain, prefix) {
42
+ if (!prefix)
43
+ return porcelain;
44
+ return porcelain
45
+ .split('\n')
46
+ .map((line) => {
47
+ if (line.length <= 3)
48
+ return line;
49
+ const status = line.slice(0, 3);
50
+ const rest = line.slice(3).split(' -> ').map((part) => (part.startsWith(prefix) ? part.slice(prefix.length) : part));
51
+ return `${status}${rest.join(' -> ')}`;
52
+ })
53
+ .join('\n');
54
+ }
55
+ /** Thrown by `defaultGitStatus` for any git failure that is *not* "not a git repository" (or git
56
+ * missing) — `buildUpdatePlan` below catches exactly this to return a plan `{ error }`, naming
57
+ * git, instead of silently treating the failure as "outside a git repository" (Minor finding,
58
+ * Fable correction pass: `fatal: detected dubious ownership`, a corrupt repo, or any other
59
+ * non-zero exit previously fell into the same `undefined` branch as genuinely being outside a
60
+ * repository, fail-open on the feature's one core safeguard — the dirty-file check — the CLI then
61
+ * printing the misleading "(Not a git repository …)" note over a real error). */
62
+ export class GitStatusError extends Error {
63
+ }
64
+ function isMissingRepo(error) {
65
+ const e = error;
66
+ if (e.code === 'ENOENT')
67
+ return true;
68
+ return /not a git repository/i.test(e.stderr ?? e.message ?? '');
69
+ }
70
+ function defaultGitStatus(projectRoot, plannedPaths) {
71
+ let prefix;
72
+ try {
73
+ prefix = execFileSync('git', ['rev-parse', '--show-prefix'], { cwd: projectRoot, encoding: 'utf8' }).trim();
74
+ }
75
+ catch (error) {
76
+ if (isMissingRepo(error))
77
+ return undefined;
78
+ throw new GitStatusError(`git rev-parse failed: ${error.message}`);
79
+ }
80
+ try {
81
+ // Errata 6: pathspecs are relative to the cwd (`projectRoot`), so they are passed unprefixed
82
+ // — only the *output* paths (which `git status --porcelain` always reports relative to the
83
+ // repository root) need `stripRepoPrefix` below.
84
+ const porcelain = execFileSync('git', ['status', '--porcelain', '--', ...plannedPaths], { cwd: projectRoot, encoding: 'utf8' });
85
+ return stripRepoPrefix(porcelain, prefix);
86
+ }
87
+ catch (error) {
88
+ throw new GitStatusError(`git status failed: ${error.message}`);
89
+ }
90
+ }
91
+ /** "Already on the latest version" is reported through the plan's `{ error }` channel, but it is not a
92
+ * failure: `update` exits 0 for it, and the viewer shows it as up to date (`isAlreadyUpToDateError`). */
93
+ export const ALREADY_UP_TO_DATE = 'Already on the latest version';
94
+ export function isAlreadyUpToDate(error) {
95
+ return error.startsWith(ALREADY_UP_TO_DATE);
96
+ }
97
+ /** Design §5 "Update plan", steps 1-3, in full. Every external effect is injected (Global
98
+ * Constraints) — the real defaults are a real `npm view`/`npm pack`/`tar`/`node migrate` and a
99
+ * real `git status --porcelain`, exactly what Task 20's own Verdaccio spike already exercised. */
100
+ export async function buildUpdatePlan(config, currentVersion, options = {}) {
101
+ const execFileAsync = promisify(execFile);
102
+ const execImpl = options.execImpl ?? (async (cmd, args, opts) => {
103
+ const run = platformCommand(cmd, args);
104
+ return (await execFileAsync(run.command, run.args, { ...opts, shell: run.shell })).stdout;
105
+ });
106
+ const fetchImpl = options.fetchImpl ?? fetch;
107
+ const tmpDirImpl = options.tmpDirImpl ?? (() => mkdtempSync(path.join(tmpdir(), 'ds-viewer-update-')));
108
+ const gitStatusImpl = options.gitStatusImpl ?? defaultGitStatus;
109
+ // Important finding, Fable correction pass: a real, unreachable registry otherwise hangs `npm
110
+ // view`/`npm pack` for minutes (observed: "Checking…" sat for 140s, with `/update/status` not
111
+ // answering at all in that time — this whole function runs inside the endpoint's own request
112
+ // handler). 20s is generous for a real, reachable registry, and short enough that offline reaches
113
+ // "Couldn't prepare" in a bounded time instead of hanging.
114
+ const NETWORK_TIMEOUT_MS = 20_000;
115
+ let targetVersion;
116
+ try {
117
+ targetVersion = (await execImpl('npm', ['view', PACKAGE_NAME, 'version'], { cwd: config.projectRoot, encoding: 'utf8', timeout: NETWORK_TIMEOUT_MS })).trim();
118
+ }
119
+ catch {
120
+ return { error: OFFLINE_MESSAGE };
121
+ }
122
+ // Errata 13: "Already on the latest version" when target <= current, not only on equality — an
123
+ // installed version ahead of the registry's own `npm view` result (e.g. a local prerelease) is
124
+ // not something `update` should try to "downgrade" into.
125
+ if (compareVersions(parseVersion(targetVersion), parseVersion(currentVersion)) <= 0) {
126
+ return { error: `${ALREADY_UP_TO_DATE} (${currentVersion}).` };
127
+ }
128
+ let migrateResult;
129
+ // M4 (Minor, Fable correction pass): compared against the *downloaded target version's* own
130
+ // `starter-kit/`, not the currently-installed one — the copy ("differ from {latest}'s kit") and
131
+ // design §5 both name the target, never what's already on disk. Computed here, before the
132
+ // `finally` below cleans the extracted tarball up.
133
+ let kitFilesDiffering = 0;
134
+ let tmp;
135
+ try {
136
+ tmp = tmpDirImpl();
137
+ // Step 1: "Download the target version into a temporary folder" — `npm pack` is an ordinary
138
+ // install-shaped request (it honors the project's own `.npmrc`/registry config exactly like
139
+ // `npm install` would), never a URL this file hardcodes.
140
+ await execImpl('npm', ['pack', `${PACKAGE_NAME}@${targetVersion}`, '--silent', '--pack-destination', tmp], { cwd: config.projectRoot, encoding: 'utf8', timeout: NETWORK_TIMEOUT_MS });
141
+ await execImpl('tar', ['-xzf', path.join(tmp, tarballFileName(targetVersion)), '-C', tmp], { cwd: tmp, encoding: 'utf8' });
142
+ // Step 2: the *downloaded* version's own migrations, never the currently-running one — so the
143
+ // plan always reflects the migrations the version being installed actually ships. This runs
144
+ // `migrateEntry.js`, never `main.js` (Critical finding, Fable correction pass, spiked for real
145
+ // in `spikes/migrate-entry/`): the extracted tarball has no `node_modules` of its own, and
146
+ // `main.js`'s own import chain pulls in the `typescript` npm package at module load, which
147
+ // would fail there with `ERR_MODULE_NOT_FOUND`; `migrateEntry.js` imports nothing but
148
+ // `migrate.ts`/`migrations/`/`semver.ts`, none of which may ever import `typescript`.
149
+ const migrateOutput = await execImpl('node', [path.join(tmp, 'package', 'dist', 'cli', 'migrateEntry.js'), '--from', currentVersion, '--dry-run', '--json'], { cwd: config.projectRoot, encoding: 'utf8' });
150
+ migrateResult = JSON.parse(migrateOutput);
151
+ kitFilesDiffering = listKitFileDiffs(config, path.join(tmp, 'package', 'starter-kit')).filter((d) => d.status !== 'identical').length;
152
+ }
153
+ catch {
154
+ return { error: OFFLINE_MESSAGE };
155
+ }
156
+ finally {
157
+ // Minor finding, Fable correction pass: every "Check again" (a fresh plan) used to leak this
158
+ // tarball and its extracted folder — cleaned up here regardless of success or failure.
159
+ if (tmp)
160
+ rmSync(tmp, { recursive: true, force: true });
161
+ }
162
+ const pm = detectPackageManager(config.projectRoot);
163
+ const files = [
164
+ { path: 'package.json', reason: 'version', dirty: false },
165
+ { path: lockfileName(pm), reason: 'version', dirty: false },
166
+ ...migrateResult.changes.map((change) => ({ path: change.file, reason: `migration: ${change.change}`, dirty: false })),
167
+ ];
168
+ // Scoped to exactly the files above (decision 4 (brief): "limited to planned files") — called
169
+ // only now that `files` is known, so the real `defaultGitStatus` can pass their paths as `git
170
+ // status`'s own pathspec.
171
+ // M9 (Minor, Fable correction pass): `gitStatusImpl` throwing (rather than returning `undefined`,
172
+ // reserved for "outside a git repository") means a real git failure — fail closed with a plan
173
+ // `{ error }` naming git, never silently fall back to "outside a git repository, nothing is
174
+ // dirty" the way returning `undefined` here would.
175
+ let porcelain;
176
+ try {
177
+ porcelain = gitStatusImpl(config.projectRoot, files.map((f) => f.path));
178
+ }
179
+ catch (error) {
180
+ return { error: `Couldn't check for uncommitted changes (git): ${error.message}` };
181
+ }
182
+ const outsideGitRepo = porcelain === undefined;
183
+ const dirtyPaths = porcelain === undefined ? new Set() : parseDirtyPaths(porcelain);
184
+ for (const file of files)
185
+ file.dirty = dirtyPaths.has(file.path);
186
+ let summary = [];
187
+ let releasedAt;
188
+ try {
189
+ // `fetchWithTimeout` (Task 4, exported) — a black-holed `api.github.com` must not hang this
190
+ // plan forever (Important finding, Fable correction pass: this request previously had no
191
+ // timeout of its own, unlike the identical request inside `checkForUpdate`).
192
+ const release = (await fetchWithTimeout(`https://api.github.com/repos/krapwoo/design-system-viewer/releases/tags/v${targetVersion}`, fetchImpl, 900));
193
+ summary = release.body ? extractSummaryBullets(release.body) : [];
194
+ releasedAt = release.published_at;
195
+ }
196
+ catch {
197
+ // Non-fatal — Task 4's identical reasoning: the version/migration half of the plan is what
198
+ // matters; a missing release summary degrades to an empty "What's new" list.
199
+ }
200
+ return {
201
+ current: currentVersion,
202
+ latest: targetVersion,
203
+ breaking: isBreakingUpgrade(parseVersion(currentVersion), parseVersion(targetVersion)),
204
+ summary,
205
+ releasedAt,
206
+ files,
207
+ dirtyFiles: files.filter((f) => f.dirty).map((f) => f.path),
208
+ kitFilesDiffering,
209
+ outsideGitRepo,
210
+ };
211
+ }
@@ -49,7 +49,7 @@ module.exports = config;
49
49
  * at bundle time, so a runtime try/catch `require` cannot skip it when the optional peer is
50
50
  * missing. Importing and wrapping must instead be baked into the generated entry (design §1
51
51
  * "Three parts", Viewer row). */
52
- function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath) {
52
+ function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath, updateEndpoint) {
53
53
  const safeAreaImport = hasSafeArea ? "import { SafeAreaProvider } from 'react-native-safe-area-context';\n" : '';
54
54
  // A literal `require(...)` with a path computed at generation time: Metro resolves every import
55
55
  // statically, so the logo's actual (arbitrary, user-configured) location must already be baked
@@ -61,13 +61,18 @@ function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath) {
61
61
  // Fable's implementation review, cli/workspace.ts:65).
62
62
  const logoImport = logoRequirePath ? `const logoSource = require(${JSON.stringify(logoRequirePath)});\n` : '';
63
63
  const logoProp = logoRequirePath ? ' logoImageSource={logoSource}' : '';
64
- const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} />`;
64
+ // `JSON.stringify` of a plain `{ baseUrl, secret }` object is valid JS object-literal syntax too
65
+ // (no reserved words in either key) — the same reasoning the existing `logoRequirePath` baking
66
+ // already relies on, extended to a whole object instead of one string.
67
+ const endpointConst = `const updateEndpoint = ${updateEndpoint ? JSON.stringify(updateEndpoint) : 'undefined'};\n`;
68
+ const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} update={updateNotice} updateEndpoint={updateEndpoint} />`;
65
69
  const root = hasSafeArea ? `<SafeAreaProvider>${viewer}</SafeAreaProvider>` : viewer;
66
70
  return `import { registerRootComponent } from 'expo';
67
71
  import { CatalogShell, buildCatalogSections } from '@krapwoo/ds-viewer';
68
72
  ${safeAreaImport}${logoImport}import pages from './generated/pages';
69
73
  import components from './generated/components.json';
70
-
74
+ import updateNotice from './update.json';
75
+ ${endpointConst}
71
76
  function App() {
72
77
  const { sections, groups } = buildCatalogSections(pages, components, ${JSON.stringify(groupOrder)});
73
78
  return ${root};
@@ -96,7 +101,7 @@ function projectIncludeGlobs(config) {
96
101
  /** Writes the self-contained Expo web workspace at `.ds-viewer/` (design §2 "The preview
97
102
  * workspace and `npx ds-viewer dev`"). Rewritten freely on every `dev` run — nothing here is
98
103
  * user-owned. */
99
- export function writeWorkspace(config) {
104
+ export function writeWorkspace(config, updateEndpoint) {
100
105
  const workspace = path.join(config.projectRoot, '.ds-viewer');
101
106
  mkdirSync(workspace, { recursive: true });
102
107
  writeFileSync(path.join(workspace, 'package.json'), `${JSON.stringify({ name: 'ds-viewer-workspace', private: true, main: 'entry.tsx' }, null, 2)}\n`);
@@ -122,6 +127,6 @@ export function writeWorkspace(config) {
122
127
  const logoRequirePath = config.logo
123
128
  ? path.relative(workspace, path.resolve(config.projectRoot, config.logo)).split(path.sep).join('/')
124
129
  : undefined;
125
- writeFileSync(path.join(workspace, 'entry.tsx'), entryTsx(config.name, hasSafeArea, config.groupOrder ?? [], logoRequirePath));
130
+ writeFileSync(path.join(workspace, 'entry.tsx'), entryTsx(config.name, hasSafeArea, config.groupOrder ?? [], logoRequirePath, updateEndpoint));
126
131
  return workspace;
127
132
  }
@@ -0,0 +1,129 @@
1
+ /** THE canonical within-group ordering of section ids. */
2
+ export function sortIds(ids) {
3
+ return ids.slice().sort((a, b) => a.localeCompare(b));
4
+ }
5
+ /** Every page id in sidebar order: groups in declared order, ids sorted within each group,
6
+ * first occurrence wins, and ids without a matching section (when `availableIds` is given) are
7
+ * dropped. */
8
+ export function orderedIds(groups, availableIds) {
9
+ const seen = new Set();
10
+ const order = [];
11
+ for (const group of groups) {
12
+ for (const id of sortIds(group.ids)) {
13
+ if (seen.has(id))
14
+ continue;
15
+ if (availableIds && !availableIds.has(id))
16
+ continue;
17
+ seen.add(id);
18
+ order.push(id);
19
+ }
20
+ }
21
+ return order;
22
+ }
23
+ export function groupLabelFor(groups, id) {
24
+ return groups.find((group) => group.ids.includes(id))?.label;
25
+ }
26
+ /** Previous and next page ids. Never wraps; null marks an unavailable direction. */
27
+ export function neighbors(order, id) {
28
+ const index = order.indexOf(id);
29
+ if (index < 0)
30
+ return { previous: null, next: null };
31
+ return {
32
+ previous: index > 0 ? order[index - 1] : null,
33
+ next: index < order.length - 1 ? order[index + 1] : null,
34
+ };
35
+ }
36
+ /** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. */
37
+ export function filterGroups(groups, query) {
38
+ const q = query.trim().toLowerCase();
39
+ return groups
40
+ .map((group) => ({
41
+ label: group.label,
42
+ ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)) : group.ids),
43
+ }))
44
+ .filter((group) => group.ids.length > 0);
45
+ }
46
+ export function hashForId(id) {
47
+ return `#${encodeURIComponent(id)}`;
48
+ }
49
+ /** The page named by a URL fragment, or the first page when the fragment is empty, malformed, or
50
+ * unknown. Undefined only when there are no pages. */
51
+ export function idFromHash(hash, order) {
52
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
53
+ let decoded = '';
54
+ try {
55
+ decoded = decodeURIComponent(raw);
56
+ }
57
+ catch {
58
+ decoded = '';
59
+ }
60
+ const match = order.find((id) => id === decoded);
61
+ return match ?? order[0];
62
+ }
63
+ /** Design §5: the update panel is its own catalog page at this fragment, "not listed in the
64
+ * sidebar groups" — it is therefore never a member of `order`, so `idFromHash` alone would treat
65
+ * it as an unknown fragment and fall back to the first real page. */
66
+ export const UPDATE_PAGE_ID = 'ds-viewer-update';
67
+ /** The request header both the local endpoint (Task 12, which checks it) and the panel's own
68
+ * fetch calls (Task 16, which send it) use for the per-`dev`-run secret (design §5 "Local
69
+ * endpoint safeguards"). Defined once, here, rather than as a literal string repeated in both the
70
+ * `cli/` and `native/catalog/` halves of the package, which may never import from each other in
71
+ * the other direction (`cli/` imports `native/catalog/`; `native/catalog/` imports nothing from
72
+ * `cli/`). */
73
+ export const DS_VIEWER_SECRET_HEADER = 'x-ds-viewer-secret';
74
+ /** A minimal `major.minor.patch` comparison, duplicated from `cli/semver.ts`'s own
75
+ * `compareVersions` rather than imported — `native/catalog/` is the published package root and
76
+ * never imports from `cli/` (the same reasoning `native/catalog/updatePanelState.ts`'s own
77
+ * `UpdatePlanView`/`UpdateStatusView`, Task 15, already established for duplicated types). Used
78
+ * only as a defense-in-depth guard (Critical finding, Fable correction pass) alongside
79
+ * `checkForUpdate`'s own newer-than-current check (Task 4) — not a general-purpose semver
80
+ * comparator, the same scope note `cli/semver.ts`'s own `Version` interface already carries.
81
+ * Errata 5: parses with a regex rather than `split('.').map(Number)` so a prerelease suffix on
82
+ * either string (e.g. `0.4.1-verify`) doesn't turn the patch component into `NaN`; a string that
83
+ * doesn't match at all is treated as not newer. */
84
+ function isNewerVersion(a, b) {
85
+ const ma = a.match(/^(\d+)\.(\d+)\.(\d+)/);
86
+ const mb = b.match(/^(\d+)\.(\d+)\.(\d+)/);
87
+ if (!ma || !mb)
88
+ return false;
89
+ for (let i = 1; i <= 3; i += 1) {
90
+ const na = Number(ma[i]);
91
+ const nb = Number(mb[i]);
92
+ if (na !== nb)
93
+ return na > nb;
94
+ }
95
+ return false;
96
+ }
97
+ export function resolveActiveFromHash(hash, order) {
98
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
99
+ let decoded = '';
100
+ try {
101
+ decoded = decodeURIComponent(raw);
102
+ }
103
+ catch {
104
+ decoded = '';
105
+ }
106
+ if (decoded === UPDATE_PAGE_ID)
107
+ return UPDATE_PAGE_ID;
108
+ return idFromHash(hash, order);
109
+ }
110
+ /** Design §5: "Major: ... dismissible per version; the footer line remains." `dismissedVersion`
111
+ * is whatever a previous dismissal recorded (`CatalogShell`'s own `localStorage` read, Task 14) —
112
+ * any value other than the *current* `update.latest` (including `null`, or an older dismissed
113
+ * major) still shows the banner, so dismissing 1.0.0 does not also suppress a later 2.0.0. */
114
+ export function shouldShowMajorBanner(update, dismissedVersion) {
115
+ if (!update || !update.breaking)
116
+ return false;
117
+ if (!isNewerVersion(update.latest, update.current))
118
+ return false;
119
+ return update.latest !== dismissedVersion;
120
+ }
121
+ /** The quiet footer line's exact copy (design §5: `"Update available · 0.5.0"`), or `undefined`
122
+ * when there is nothing to show. Independent of `shouldShowMajorBanner` — design: the footer line
123
+ * shows for *every* available update, minor or major, and stays even once the major banner is
124
+ * dismissed. */
125
+ export function footerLabel(update) {
126
+ if (!update || !isNewerVersion(update.latest, update.current))
127
+ return undefined;
128
+ return `Update available · ${update.latest}`;
129
+ }
@@ -59,6 +59,10 @@ export const CATALOG_LAYOUT = {
59
59
  logoMark: 32,
60
60
  logoWordmarkHeight: 28,
61
61
  logoCaptionGap: 6,
62
+ /** Max width of the update page's content column (approved mockup: direction "C · Update
63
+ * page", content at most 512px) — independent of `CATALOG_MAX_CONTENT_WIDTH` (1200px), which
64
+ * is every other page's column width. */
65
+ updateContentMaxWidth: 512,
62
66
  };
63
67
  /** Max width of the main content column. */
64
68
  export const CATALOG_MAX_CONTENT_WIDTH = 1200;
@@ -116,4 +120,15 @@ export const CATALOG_COLOR = {
116
120
  /** 3px keyboard focus ring on every catalog control. */
117
121
  focusRing: '#c9d7ff',
118
122
  code: 'Menlo',
123
+ /** Major-update banner background/border/text (design §5 "Viewer notice"). */
124
+ warning: '#8a4b00',
125
+ warningSubtle: '#fff4e0',
126
+ warningBorder: '#f0cf97',
127
+ /** "Can't prepare"/failure note-boxes on the update page. */
128
+ danger: '#b3261e',
129
+ dangerSubtle: '#fdecea',
130
+ dangerBorder: '#f2b8b5',
131
+ /** The update page's post-update success note-box and "Updated" pill. */
132
+ success: '#1e6b3a',
133
+ successSubtle: '#e8f5ec',
119
134
  };