@krapwoo/ds-viewer 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +151 -0
  2. package/config/index.ts +4 -2
  3. package/dist/cli/agentsFile.js +31 -0
  4. package/dist/cli/dev.js +312 -15
  5. package/dist/cli/doctor.js +502 -0
  6. package/dist/cli/endpoint.js +142 -0
  7. package/dist/cli/explain.js +151 -0
  8. package/dist/cli/githubAction.js +65 -0
  9. package/dist/cli/init.js +9 -5
  10. package/dist/cli/kitDiff.js +132 -0
  11. package/dist/cli/kitRoot.js +20 -0
  12. package/dist/cli/main.js +123 -15
  13. package/dist/cli/migrate.js +30 -0
  14. package/dist/cli/migrateEntry.js +19 -0
  15. package/dist/cli/migrations/index.js +14 -0
  16. package/dist/cli/migrations/types.js +9 -0
  17. package/dist/cli/packageManager.js +29 -0
  18. package/dist/cli/packageVersion.js +24 -0
  19. package/dist/cli/pageIndex.js +27 -1
  20. package/dist/cli/props.js +4 -3
  21. package/dist/cli/semver.js +26 -0
  22. package/dist/cli/staticPage.js +356 -0
  23. package/dist/cli/sync.js +11 -4
  24. package/dist/cli/update.js +93 -0
  25. package/dist/cli/updateCheck.js +143 -0
  26. package/dist/cli/updatePlan.js +202 -0
  27. package/dist/cli/workspace.js +10 -5
  28. package/dist/native/catalog/catalogNavigation.js +129 -0
  29. package/dist/native/catalog/comparison.js +283 -0
  30. package/dist/native/catalog/tokens.js +134 -0
  31. package/dist/native/catalog/types.js +1 -0
  32. package/native/catalog/CatalogShell.tsx +105 -11
  33. package/native/catalog/CatalogSidebar.tsx +58 -2
  34. package/native/catalog/ComparisonGrid.tsx +9 -7
  35. package/native/catalog/SectionBlock.tsx +5 -0
  36. package/native/catalog/SpacingScaleGallery.tsx +1 -1
  37. package/native/catalog/UpdatePanel.tsx +556 -0
  38. package/native/catalog/catalogNavigation.ts +70 -1
  39. package/native/catalog/comparison.ts +63 -5
  40. package/native/catalog/index.ts +5 -1
  41. package/native/catalog/pageApi.ts +11 -2
  42. package/native/catalog/tokens.ts +15 -0
  43. package/native/catalog/types.ts +19 -5
  44. package/native/catalog/updatePanelState.ts +74 -0
  45. package/package.json +5 -1
  46. package/starter-kit/components/Avatar/Avatar.catalog.tsx +4 -22
  47. package/starter-kit/components/Badge/Badge.catalog.tsx +15 -23
  48. package/starter-kit/components/Banner/Banner.tsx +1 -1
  49. package/starter-kit/components/BottomSheet/BottomSheet.tsx +2 -2
  50. package/starter-kit/components/Button/Button.catalog.tsx +15 -29
  51. package/starter-kit/components/Dialog/Dialog.tsx +2 -2
  52. package/starter-kit/components/Pill/Pill.catalog.tsx +12 -26
@@ -0,0 +1,151 @@
1
+ import path from 'node:path';
2
+ import { readStaticPages } from "./staticPage.js";
3
+ import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
4
+ import { resolveGlob } from "./glob.js";
5
+ import { axisItems, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
6
+ import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
7
+ function placeholderItem(item) {
8
+ return { key: item.key, name: item.name, node: null, group: item.group };
9
+ }
10
+ /** A throwaway `SectionDef` built from a `StaticPage` (Task 3) — every specimen's `node` is `null`
11
+ * (design §4: specimen nodes are never evaluated), since none of the layout functions below ever
12
+ * look at it; only the counts/keys/sizes they do look at are carried over. Every field Task 3
13
+ * reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill` —
14
+ * an earlier draft dropped these four, so `explain` reported different geometry than the real
15
+ * viewer for any page that set one of them (25 kit pages set `itemsFill`; every `kit-host`
16
+ * Viewer page sets `hide`). */
17
+ function toSectionDef(page) {
18
+ const comparison = page.comparison && {
19
+ rowLabel: page.comparison.rowLabel ?? '',
20
+ columnLabel: page.comparison.columnLabel ?? '',
21
+ rows: page.comparison.rows.prop !== undefined ? { prop: page.comparison.rows.prop, items: page.comparison.rows.items } : page.comparison.rows.items,
22
+ columns: page.comparison.columns.prop !== undefined ? { prop: page.comparison.columns.prop, items: page.comparison.columns.items } : page.comparison.columns.items,
23
+ cells: [],
24
+ size: page.comparison.size,
25
+ };
26
+ return {
27
+ id: resolvePageId(page, page.file),
28
+ description: '',
29
+ path: page.file,
30
+ tokenGallery: page.tokenGallery,
31
+ fullWidthLabel: page.fullWidthLabel,
32
+ specimenSize: page.specimenSize,
33
+ previewWidths: page.previewWidths,
34
+ variants: page.variantsItems ? { itemsFill: page.variantsItemsFill, items: page.variantsItems.map(placeholderItem) } : undefined,
35
+ states: page.statesItems ? { itemsFill: page.statesItemsFill, items: page.statesItems.map(placeholderItem) } : undefined,
36
+ comparison,
37
+ render: page.hasRender ? () => null : undefined,
38
+ hide: (page.hideVariants || page.hideStates) ? { variants: page.hideVariants, states: page.hideStates } : undefined,
39
+ };
40
+ }
41
+ function explainBlock(block) {
42
+ if (block.kind === 'grid') {
43
+ const rows = axisItems(block.comparison.rows).length;
44
+ const columns = axisItems(block.comparison.columns).length;
45
+ const limit = gridColumnLimit(block.size);
46
+ return {
47
+ kind: 'grid', title: block.title,
48
+ reason: `${rows} rows × ${columns} columns (${block.size}); ${columns} of ${limit} max ${block.size} columns used — ` +
49
+ `${columns <= limit ? 'fits' : 'does NOT fit'} a 1280px laptop.`,
50
+ };
51
+ }
52
+ if (block.kind === 'list') {
53
+ const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size);
54
+ return {
55
+ kind: 'list', title: block.title,
56
+ reason: `${block.items.length} items (${block.size}) → ${geometry.columns} columns × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
57
+ (geometry.fillers > 0 ? ` (${geometry.fillers} filler${geometry.fillers === 1 ? '' : 's'})` : '') + '.',
58
+ };
59
+ }
60
+ if (block.kind === 'grouped') {
61
+ return { kind: 'grouped', title: block.title, reason: `${block.groups.length} variant row(s), each with its own configurations.` };
62
+ }
63
+ if (block.kind === 'preview') {
64
+ const widths = block.widths === 'full' ? 'full width' : `${block.widths.join('/')}px`;
65
+ return { kind: 'preview', title: block.title, reason: `render() content at ${widths}.` };
66
+ }
67
+ return { kind: 'empty', title: block.title, reason: block.message };
68
+ }
69
+ /** Mirrors `native/catalog/SectionBlock.tsx`'s own `blockWidth` helper: a list block's width is its
70
+ * own natural, content-hugging width (`listGeometry`'s `containerWidth`) — the real viewer only
71
+ * ever moves a *second* block beside a *first* one when that first block doesn't already fill the
72
+ * whole row, so `choosePlacement` only reads `first.width` (never `second.width`) — but it's
73
+ * computed uniformly here for either role, since setting it on `second` is simply unused, not wrong. */
74
+ function toPlacementBlock(block, height, available) {
75
+ if (block.kind === 'list') {
76
+ return { kind: 'list', height, itemCount: block.items.length, size: block.size, width: listGeometry(block.items.length, available, block.size).containerWidth };
77
+ }
78
+ return { kind: block.kind, height };
79
+ }
80
+ /** Finds `pageId` among every page `cli/doctor.ts`'s own `discoverAllPageFiles` would find, builds
81
+ * its throwaway `SectionDef`, and asks the viewer's own pure layout functions for each block's
82
+ * decision — undefined when no page resolves to that id. */
83
+ export function explainPage(config, pageId, options = {}) {
84
+ // Same exclusion `cli/doctor.ts`'s `runDoctor` applies (mirroring `sync`) — an excluded folder's
85
+ // page should never resolve here either.
86
+ const excluded = new Set((config.exclude ?? []).flatMap((pattern) => resolveGlob(config.projectRoot, pattern)));
87
+ const componentFolders = [
88
+ ...new Set(config.components
89
+ .flatMap((pattern) => resolveGlob(config.projectRoot, pattern))
90
+ .filter((file) => !excluded.has(file))
91
+ .map((file) => path.dirname(file))),
92
+ ];
93
+ const pageFiles = discoverAllPageFiles({ componentFolders, standalonePageGlobs: config.pages ?? [], projectRoot: config.projectRoot });
94
+ const page = readStaticPages(pageFiles).find((p) => resolvePageId(p, p.file) === pageId);
95
+ if (!page)
96
+ return undefined;
97
+ // Important finding 4 (Fable's review): a page whose layout data isn't a literal (or that has a
98
+ // syntax error) must never be explained from partial/empty data — design §4 requires `explain`
99
+ // to never drift from what the viewer actually renders.
100
+ if (!page.checkable)
101
+ return { pageId, notCheckable: true, blocks: [] };
102
+ const def = toSectionDef(page);
103
+ const blocks = presentationBlocks(def, { defaultPreviewWidths: [402] });
104
+ const explained = blocks.map(explainBlock);
105
+ let placement;
106
+ if (blocks.length === 2) {
107
+ if (options.heights) {
108
+ const [firstHeight, secondHeight] = options.heights;
109
+ const decision = choosePlacement({
110
+ available: MATRIX_LAYOUT.laptopContentWidth,
111
+ gap: CATALOG_LAYOUT.blockGap,
112
+ first: toPlacementBlock(blocks[0], firstHeight, MATRIX_LAYOUT.laptopContentWidth),
113
+ second: toPlacementBlock(blocks[1], secondHeight, MATRIX_LAYOUT.laptopContentWidth),
114
+ });
115
+ placement = {
116
+ decision,
117
+ reason: `With heights ${firstHeight}px/${secondHeight}px on a 1280px laptop (${MATRIX_LAYOUT.laptopContentWidth}px content width): ${decision}.`,
118
+ };
119
+ }
120
+ else {
121
+ placement = {
122
+ decision: 'decided-in-viewer',
123
+ reason: "Side-by-side placement depends on each block's measured height — decided in the viewer at runtime. Pass --heights <first>,<second> to check it here.",
124
+ };
125
+ }
126
+ }
127
+ return { pageId, blocks: explained, placement };
128
+ }
129
+ export function formatExplain(result) {
130
+ if (result.notCheckable) {
131
+ return `${result.pageId}\n\nPage not statically checkable — some of its layout data is not a literal doctor can read.`;
132
+ }
133
+ const lines = [result.pageId, ''];
134
+ for (const block of result.blocks) {
135
+ lines.push(block.title);
136
+ lines.push(` ${block.reason}`);
137
+ lines.push('');
138
+ }
139
+ if (result.placement) {
140
+ lines.push('Placement');
141
+ lines.push(` ${result.placement.reason}`);
142
+ }
143
+ return lines.join('\n').replace(/\n+$/, '');
144
+ }
145
+ /** `"80,300"` → `[80, 300]`; undefined for anything else (not exactly two positive numbers). */
146
+ export function parseHeights(raw) {
147
+ const parts = raw.split(',').map((part) => Number(part.trim()));
148
+ if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n) || n <= 0))
149
+ return undefined;
150
+ return [parts[0], parts[1]];
151
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { detectPackageManager } from "./packageManager.js";
4
+ // Design §4 "GitHub Action" + §5's lockfile-detection assumption. `checkout`/`setup-node` pins
5
+ // reused verbatim from this repository's own `.github/workflows/ci.yml` (Global Constraints);
6
+ // `pnpm/action-setup` is the one new SHA this plan resolves (`gh api
7
+ // repos/pnpm/action-setup/git/refs/tags/v6.1.0`, dereferenced to its commit). `node-version: '20'`
8
+ // matches the package's own published `engines` floor (`>=20.19.0`): a user's project only ever
9
+ // runs the compiled `dist/cli/main.js` via `npx ds-viewer`, which needs no type-stripping (unlike
10
+ // this repo's own CI, which type-strips its *source* and therefore needs Node 22).
11
+ // Errata #5: `pnpm/action-setup` needs an explicit pnpm version — it has no lockfile to infer one
12
+ // from. Read the project's own `packageManager` field (`"pnpm@9.1.0"`) when present; otherwise
13
+ // default to 10, matching the pin's own major version line.
14
+ function pnpmVersion(projectRoot) {
15
+ const packageJsonPath = path.join(projectRoot, 'package.json');
16
+ if (!existsSync(packageJsonPath))
17
+ return '10';
18
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
19
+ const match = packageJson.packageManager?.match(/^pnpm@(\d+)/);
20
+ return match ? match[1] : '10';
21
+ }
22
+ // Important finding 6 (Fable's review): Yarn 1 (still the common Expo lockfile) has no
23
+ // `--immutable` flag — its equivalent is `--frozen-lockfile`. Yarn Berry needs `corepack enable`
24
+ // first, since `actions/setup-node` ships Yarn 1 on `ubuntu-latest`.
25
+ function installStep(projectRoot) {
26
+ const pm = detectPackageManager(projectRoot);
27
+ if (pm === 'pnpm') {
28
+ return ` - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0\n with:\n version: ${pnpmVersion(projectRoot)}\n - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0\n with:\n node-version: '20'\n - run: pnpm install --frozen-lockfile\n`;
29
+ }
30
+ const setupNode = ` - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0\n with:\n node-version: '20'\n`;
31
+ if (pm === 'yarn-classic')
32
+ return `${setupNode} - run: yarn install --frozen-lockfile\n`;
33
+ if (pm === 'yarn-berry')
34
+ return `${setupNode} - run: corepack enable\n - run: yarn install --immutable\n`;
35
+ // `package-lock.json`, or no lockfile at all (a fresh project before its first commit) — `npm
36
+ // ci` is npm's own documented behavior for the latter: it installs from `package.json` and
37
+ // writes a fresh lockfile, exactly like a plain `npm install` would.
38
+ return `${setupNode} - run: npm ci\n`;
39
+ }
40
+ function workflowContent(projectRoot) {
41
+ return `name: DS Viewer
42
+
43
+ on:
44
+ pull_request:
45
+
46
+ jobs:
47
+ doctor:
48
+ runs-on: ubuntu-latest
49
+ steps:
50
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
51
+ ${installStep(projectRoot)} - run: npx ds-viewer doctor --ci
52
+ `;
53
+ }
54
+ /** Design §4 "GitHub Action", §1 Ownership: written once, never overwritten — a user's own edits
55
+ * to this file (or a differently-named workflow they wrote instead) are never touched again. The
56
+ * install step is picked from whichever lockfile is present *right now*, at `init` time — never
57
+ * re-picked later, since this function never runs again once the file exists. */
58
+ export function ensureGithubAction(projectRoot) {
59
+ const workflowPath = path.join(projectRoot, '.github', 'workflows', 'ds-viewer.yml');
60
+ if (existsSync(workflowPath))
61
+ return [];
62
+ mkdirSync(path.dirname(workflowPath), { recursive: true });
63
+ writeFileSync(workflowPath, workflowContent(projectRoot));
64
+ return [workflowPath];
65
+ }
package/dist/cli/init.js CHANGED
@@ -4,6 +4,9 @@ import { createInterface } from 'node:readline/promises';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { preflight } from "./preflight.js";
6
6
  import { detectComponentFolders, detectTokenFiles } from "./detect.js";
7
+ import { ensureAgentsFileSection } from "./agentsFile.js";
8
+ import { ensureGithubAction } from "./githubAction.js";
9
+ import { readOwnVersion } from "./packageVersion.js";
7
10
  async function promptConfirm(message) {
8
11
  // A closed, non-interactive stdin (CI, piped npx) would reject `question` with an AbortError;
9
12
  // say how to proceed instead.
@@ -35,8 +38,7 @@ function packageRoot() {
35
38
  throw new Error("Could not find @krapwoo/ds-viewer's own package.json to read its version.");
36
39
  }
37
40
  function ownVersion() {
38
- const pkg = JSON.parse(readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
39
- return pkg.version;
41
+ return readOwnVersion(packageRoot());
40
42
  }
41
43
  /** Detects the existing indent of a JSON file's first indented line, so rewriting it (e.g.
42
44
  * `package.json` below) doesn't reformat the user's own indentation style. Falls back to two
@@ -104,8 +106,8 @@ export async function initExistingProject(projectRoot, options = {}) {
104
106
  return { messages, written };
105
107
  }
106
108
  /** Design §2, "Both paths then write what is missing": `ds-viewer.config.ts` is written by each
107
- * path separately (its contents differ), but the package script, the devDependency entry, and the
108
- * `.gitignore` entry are identical either way. */
109
+ * path separately (its contents differ), but the package script, the devDependency entry, the
110
+ * `.gitignore` entry, and (from 0.3) the `AGENTS.md` section are identical either way. */
109
111
  function ensureSharedProjectFiles(projectRoot) {
110
112
  const written = [];
111
113
  const packageJsonPath = path.join(projectRoot, 'package.json');
@@ -134,6 +136,8 @@ function ensureSharedProjectFiles(projectRoot) {
134
136
  writeFileSync(gitignorePath, `${existingGitignore}${separator}${gitignoreEntry}\n`);
135
137
  written.push(gitignorePath);
136
138
  }
139
+ written.push(...ensureAgentsFileSection(projectRoot));
140
+ written.push(...ensureGithubAction(projectRoot));
137
141
  return written;
138
142
  }
139
143
  const KIT_REQUIRED_PACKAGES = ['react-native-svg', 'react-native-safe-area-context'];
@@ -204,7 +208,7 @@ export default defineConfig({
204
208
  tokens: ['${kitRoot}/tokens/index.ts'],
205
209
  pages: ['${kitRoot}/pages/*.catalog.tsx'],
206
210
  groupOrder: ${JSON.stringify(KIT_GROUP_ORDER)},
207
- starterKit: { version: '${starterKitVersion}' },
211
+ starterKit: { version: '${starterKitVersion}', root: '${kitRoot}' },
208
212
  });
209
213
  `;
210
214
  }
@@ -0,0 +1,132 @@
1
+ // cli/kitDiff.ts
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { resolveKitRoot } from "./kitRoot.js";
5
+ function listFilesRecursively(dir, prefix = '') {
6
+ if (!existsSync(dir))
7
+ return [];
8
+ const out = [];
9
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
10
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
11
+ if (entry.isDirectory())
12
+ out.push(...listFilesRecursively(path.join(dir, entry.name), relative));
13
+ else if (entry.isFile())
14
+ out.push(relative);
15
+ }
16
+ return out;
17
+ }
18
+ function installedKitDir(projectRoot) {
19
+ return path.join(projectRoot, 'node_modules', '@krapwoo', 'ds-viewer', 'starter-kit');
20
+ }
21
+ /** Design §5 "Starter-kit changes": every kit file on both sides, compared by exact content —
22
+ * never applied, only reported. `undefined` from `resolveKitRoot` (no `starterKit` configured at
23
+ * all) means "not a kit project", not an error: `[]` either way (Task 9 reads this directly for
24
+ * its `kitFilesDiffering` count, which is simply 0 for a non-kit project).
25
+ *
26
+ * `compareDir` overrides the "other side" of the comparison — the currently-*installed* kit
27
+ * (`node_modules/@krapwoo/ds-viewer/starter-kit`) by default, used by `kit diff`; `buildUpdatePlan`
28
+ * (M4, Fable correction pass) instead passes the *downloaded target version's* own extracted
29
+ * `starter-kit/`, matching its own copy ("differ from {latest}'s kit") and design §5. */
30
+ export function listKitFileDiffs(config, compareDir) {
31
+ const kitRoot = resolveKitRoot(config);
32
+ if (!kitRoot)
33
+ return [];
34
+ const userDir = path.join(config.projectRoot, kitRoot);
35
+ const installedDir = compareDir ?? installedKitDir(config.projectRoot);
36
+ const userFiles = new Set(listFilesRecursively(userDir));
37
+ const installedFiles = new Set(listFilesRecursively(installedDir));
38
+ const diffs = [];
39
+ for (const relativePath of [...new Set([...userFiles, ...installedFiles])].sort()) {
40
+ if (userFiles.has(relativePath) && !installedFiles.has(relativePath)) {
41
+ diffs.push({ relativePath, status: 'user-only' });
42
+ }
43
+ else if (!userFiles.has(relativePath) && installedFiles.has(relativePath)) {
44
+ diffs.push({ relativePath, status: 'kit-only' });
45
+ }
46
+ else {
47
+ const same = readFileSync(path.join(userDir, relativePath), 'utf8') === readFileSync(path.join(installedDir, relativePath), 'utf8');
48
+ diffs.push({ relativePath, status: same ? 'identical' : 'modified' });
49
+ }
50
+ }
51
+ return diffs;
52
+ }
53
+ /** A plain LCS line diff, `-`/`+`/` ` (two-space) prefixed — not a strict POSIX unified diff (no
54
+ * `@@` hunk headers, no surrounding-context trimming): design §5 says `kit diff` "shows" the two
55
+ * files against each other, and every kit file is already small enough that a full-file line diff
56
+ * reads more plainly than hunk-trimmed context would. */
57
+ export function diffLines(a, b) {
58
+ const aLines = a.split('\n');
59
+ const bLines = b.split('\n');
60
+ const m = aLines.length;
61
+ const n = bLines.length;
62
+ const lcs = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
63
+ for (let i = m - 1; i >= 0; i -= 1) {
64
+ for (let j = n - 1; j >= 0; j -= 1) {
65
+ lcs[i][j] = aLines[i] === bLines[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
66
+ }
67
+ }
68
+ const out = [];
69
+ let i = 0;
70
+ let j = 0;
71
+ while (i < m && j < n) {
72
+ if (aLines[i] === bLines[j]) {
73
+ out.push(` ${aLines[i]}`);
74
+ i += 1;
75
+ j += 1;
76
+ }
77
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
78
+ out.push(`- ${aLines[i]}`);
79
+ i += 1;
80
+ }
81
+ else {
82
+ out.push(`+ ${bLines[j]}`);
83
+ j += 1;
84
+ }
85
+ }
86
+ while (i < m) {
87
+ out.push(`- ${aLines[i]}`);
88
+ i += 1;
89
+ }
90
+ while (j < n) {
91
+ out.push(`+ ${bLines[j]}`);
92
+ j += 1;
93
+ }
94
+ return out;
95
+ }
96
+ /** `npx ds-viewer kit diff <Component>` (design §5). `installed` is always the "before" (`-`) side
97
+ * and `yours` the "after" (`+`) side — the installed kit is what shipped; the user's file is what
98
+ * they changed it to. Exit code 1 only for a usage problem (no kit configured, unknown component);
99
+ * a real diff is informational and exits 0. */
100
+ export function runKitDiffCommand(config, componentName) {
101
+ const kitRoot = resolveKitRoot(config);
102
+ if (!kitRoot)
103
+ return { output: 'This project has no starter kit configured (no "starterKit" in ds-viewer.config.ts).', exitCode: 1 };
104
+ const diffs = listKitFileDiffs(config).filter((d) => d.relativePath.startsWith(`components/${componentName}/`));
105
+ if (diffs.length === 0) {
106
+ return { output: `No kit files found for "${componentName}" under ${kitRoot}/components/${componentName}/.`, exitCode: 1 };
107
+ }
108
+ const installedDir = installedKitDir(config.projectRoot);
109
+ const userDir = path.join(config.projectRoot, kitRoot);
110
+ const lines = [];
111
+ let anyDiff = false;
112
+ for (const diff of diffs) {
113
+ if (diff.status === 'identical')
114
+ continue;
115
+ anyDiff = true;
116
+ lines.push(`--- ${diff.relativePath} (installed kit)`, `+++ ${diff.relativePath} (yours)`);
117
+ if (diff.status === 'kit-only') {
118
+ lines.push('(only in the installed kit — you have not copied this file)', '');
119
+ continue;
120
+ }
121
+ if (diff.status === 'user-only') {
122
+ lines.push('(only in your project — not part of the installed kit)', '');
123
+ continue;
124
+ }
125
+ const installedContent = readFileSync(path.join(installedDir, diff.relativePath), 'utf8');
126
+ const userContent = readFileSync(path.join(userDir, diff.relativePath), 'utf8');
127
+ lines.push(...diffLines(installedContent, userContent), '');
128
+ }
129
+ if (!anyDiff)
130
+ return { output: `${componentName}'s kit files match the installed kit exactly.`, exitCode: 0 };
131
+ return { output: lines.join('\n'), exitCode: 0 };
132
+ }
@@ -0,0 +1,20 @@
1
+ // cli/kitRoot.ts
2
+ import path from 'node:path';
3
+ import { globBaseFolder } from "./glob.js";
4
+ /** Design decision 6 (brief): `starterKit.root` when the config sets it (every config `init --new`
5
+ * writes from 0.4 on); otherwise inferred from the first `components` glob's base folder's own
6
+ * parent — `globBaseFolder('src/ds/components/*\/index.ts')` is `'src/ds/components'`, and the kit
7
+ * root is always that folder's parent, since the starter kit's own fixed internal layout (design
8
+ * §1) is `<kit root>/components`, `<kit root>/tokens`, `<kit root>/icons`. Returns `undefined` for
9
+ * a project with no `starterKit` at all — an existing (non-kit) project has no kit root to find,
10
+ * and `kit diff`/`update`'s kit-diff count both treat that as "0 kit files, nothing to compare." */
11
+ export function resolveKitRoot(config) {
12
+ if (!config.starterKit)
13
+ return undefined;
14
+ if (config.starterKit.root)
15
+ return config.starterKit.root;
16
+ if (config.components.length === 0)
17
+ return undefined;
18
+ const base = globBaseFolder(config.components[0]);
19
+ return path.dirname(base).split(path.sep).join('/');
20
+ }
package/dist/cli/main.js CHANGED
@@ -1,11 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync } from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { fileURLToPath } from 'node:url';
5
4
  import { resolveConfig } from "./config.js";
6
5
  import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
7
6
  import { dev } from "./dev.js";
8
7
  import { sync } from "./sync.js";
8
+ import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
9
+ import { explainPage, formatExplain, parseHeights } from "./explain.js";
10
+ import { readOwnVersion } from "./packageVersion.js";
11
+ import { checkForUpdate, isUpdateCheckEnabled } from "./updateCheck.js";
12
+ import { runKitDiffCommand } from "./kitDiff.js";
13
+ import { formatMigrateHuman, runMigrate } from "./migrate.js";
14
+ import { runUpdate } from "./update.js";
9
15
  const USAGE = `Usage: ds-viewer <command>
10
16
 
11
17
  Commands:
@@ -16,24 +22,34 @@ Commands:
16
22
  --yes accept the detected components and tokens without asking (--existing only)
17
23
  sync Regenerate the component, props, token, and page data from source
18
24
  dev Sync, then serve the catalog on a local web address and keep it current as files change
25
+ [--port <number>]
26
+ Pin the catalog (and its update endpoint's restart handoff) to this port
27
+ doctor Check every page for drift and coverage gaps
28
+ --json print the machine-readable report instead
29
+ --ci exit 1 when any error was found (never on a warning alone)
30
+ explain <Page>
31
+ Print why a page's specimens are laid out the way they are
32
+ --heights <first>,<second> check side-by-side placement with these measured heights
33
+ --json print the machine-readable report instead
34
+ kit diff <Component>
35
+ Show the user's kit file(s) for <Component> against the installed kit's copy
36
+ migrate --from <version>
37
+ Run every migration introduced after <version>
38
+ --dry-run report the changes without writing them
39
+ --json print the machine-readable report instead
40
+ update [--dry-run] Build and show the update plan; stop without installing
41
+ [--yes] Skip the confirmation prompt
42
+ [--force] Install even if planned files have uncommitted changes
19
43
 
20
44
  Options:
21
45
  -h, --help Show this help
22
46
  -v, --version Show the installed version`;
23
- /** Walks up from this file (cli/ in source, dist/cli/ when installed) to the package's own package.json. */
24
47
  function packageVersion() {
25
- let dir = path.dirname(fileURLToPath(import.meta.url));
26
- while (true) {
27
- const candidate = path.join(dir, 'package.json');
28
- if (existsSync(candidate)) {
29
- const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
30
- if (pkg.name === '@krapwoo/ds-viewer' && pkg.version)
31
- return pkg.version;
32
- }
33
- const parent = path.dirname(dir);
34
- if (parent === dir)
35
- return 'unknown';
36
- dir = parent;
48
+ try {
49
+ return readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
50
+ }
51
+ catch {
52
+ return 'unknown';
37
53
  }
38
54
  }
39
55
  /** `--kit-root`'s raw CLI value, normalized to a project-root-relative, forward-slash path — or an
@@ -95,6 +111,46 @@ export async function runInit(projectRoot, rest, options = {}) {
95
111
  ? initNewProject(projectRoot, { kitRoot, installer: options.installer })
96
112
  : initExistingProject(projectRoot, { yes: rest.includes('--yes'), confirm: options.confirm });
97
113
  }
114
+ /** Pulled out of `main()` for the same reason `runInit` was in 0.1/0.2 (Fable's review): testable
115
+ * without spawning a process. `--ci`'s exit code is read from `runDoctor`'s own `exitCode` — a
116
+ * plain `doctor` (no `--ci`) always returns 0 here regardless of errors found. */
117
+ export async function runDoctorCommand(projectRoot, rest, options = {}) {
118
+ const config = resolveConfig(projectRoot);
119
+ const result = runDoctor(config);
120
+ // Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
121
+ // `updateCheck` is otherwise enabled; `result.update` stays `null`.
122
+ if (!rest.includes('--ci') && isUpdateCheckEnabled(config, options.env ?? process.env)) {
123
+ const ownVersion = readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
124
+ const updateResult = await (options.checkForUpdate ?? checkForUpdate)(ownVersion);
125
+ if (updateResult) {
126
+ result.update = { current: updateResult.current, latest: updateResult.latest, breaking: updateResult.breaking };
127
+ }
128
+ }
129
+ // The human formatter (`formatHuman`, unchanged) knows nothing about updates — it predates this
130
+ // release and every other release's own doctor output; appending one line here, rather than
131
+ // threading `update` through `formatHuman`'s own signature, keeps that function's existing
132
+ // tests untouched (Minor finding, Fable correction pass: the human path previously said nothing
133
+ // about an update `--json` already reported).
134
+ const updateLine = result.update ? `\nUpdate available: ${result.update.current} → ${result.update.latest}` : '';
135
+ const output = rest.includes('--json') ? JSON.stringify(toDoctorJson(result), null, 2) : `${formatHuman(result)}${updateLine}`;
136
+ return { output, exitCode: rest.includes('--ci') ? result.exitCode : 0 };
137
+ }
138
+ /** Same "testable without spawning" shape as `runDoctorCommand` above. */
139
+ export function runExplainCommand(projectRoot, pageId, rest) {
140
+ if (!pageId)
141
+ return { output: 'Usage: ds-viewer explain <Page> [--heights <first>,<second>] [--json]', exitCode: 1 };
142
+ const heightsIndex = rest.indexOf('--heights');
143
+ const heightsRaw = heightsIndex !== -1 ? rest[heightsIndex + 1] : undefined;
144
+ const heights = heightsRaw ? parseHeights(heightsRaw) : undefined;
145
+ if (heightsRaw && !heights) {
146
+ return { output: `--heights must be two comma-separated numbers, e.g. --heights 420,610 (got "${heightsRaw}")`, exitCode: 1 };
147
+ }
148
+ const config = resolveConfig(projectRoot);
149
+ const result = explainPage(config, pageId, { heights });
150
+ if (!result)
151
+ return { output: `No page named "${pageId}" was found.`, exitCode: 1 };
152
+ return { output: rest.includes('--json') ? JSON.stringify(result, null, 2) : formatExplain(result), exitCode: 0 };
153
+ }
98
154
  async function main() {
99
155
  const [command, ...rest] = process.argv.slice(2);
100
156
  const projectRoot = process.cwd();
@@ -122,9 +178,61 @@ async function main() {
122
178
  console.log(`Synced ${result.componentCount} components, ${result.pageCount} pages.`);
123
179
  return;
124
180
  }
181
+ if (command === 'doctor') {
182
+ const { output, exitCode } = await runDoctorCommand(projectRoot, rest);
183
+ console.log(output);
184
+ if (exitCode)
185
+ process.exitCode = exitCode;
186
+ return;
187
+ }
188
+ if (command === 'explain') {
189
+ const [pageId, ...explainRest] = rest;
190
+ const { output, exitCode } = runExplainCommand(projectRoot, pageId, explainRest);
191
+ console.log(output);
192
+ if (exitCode)
193
+ process.exitCode = exitCode;
194
+ return;
195
+ }
196
+ if (command === 'kit') {
197
+ const [sub, componentName] = rest;
198
+ if (sub !== 'diff' || !componentName) {
199
+ console.error(`Usage: ds-viewer kit diff <Component>\n\n${USAGE}`);
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+ const config = resolveConfig(projectRoot);
204
+ const { output, exitCode } = runKitDiffCommand(config, componentName);
205
+ console.log(output);
206
+ if (exitCode)
207
+ process.exitCode = exitCode;
208
+ return;
209
+ }
210
+ if (command === 'migrate') {
211
+ const fromIndex = rest.indexOf('--from');
212
+ const fromVersion = fromIndex !== -1 ? rest[fromIndex + 1] : undefined;
213
+ if (!fromVersion) {
214
+ console.error(`Usage: ds-viewer migrate --from <version> [--dry-run] [--json]\n\n${USAGE}`);
215
+ process.exitCode = 1;
216
+ return;
217
+ }
218
+ const result = runMigrate(projectRoot, fromVersion, { dryRun: rest.includes('--dry-run') });
219
+ console.log(rest.includes('--json') ? JSON.stringify(result, null, 2) : formatMigrateHuman(result));
220
+ return;
221
+ }
222
+ if (command === 'update') {
223
+ const config = resolveConfig(projectRoot);
224
+ const result = await runUpdate(config, { dryRun: rest.includes('--dry-run'), yes: rest.includes('--yes'), force: rest.includes('--force') });
225
+ console.log(result.output);
226
+ if (result.exitCode)
227
+ process.exitCode = result.exitCode;
228
+ return;
229
+ }
125
230
  if (command === 'dev') {
126
231
  const config = resolveConfig(projectRoot);
127
- await dev(config);
232
+ const portIndex = rest.indexOf('--port');
233
+ const parsedPort = portIndex !== -1 ? Number(rest[portIndex + 1]) : undefined;
234
+ const pinnedPort = parsedPort !== undefined && Number.isInteger(parsedPort) ? parsedPort : undefined;
235
+ await dev(config, { pinnedPort });
128
236
  return;
129
237
  }
130
238
  console.error(`Unknown command "${command}".\n\n${USAGE}`);
@@ -0,0 +1,30 @@
1
+ // cli/migrate.ts
2
+ import { migrationsAfter } from "./migrations/index.js";
3
+ import { compareVersions, parseVersion } from "./semver.js";
4
+ /** Design §5 "`npx ds-viewer update`" step 4, and the endpoint's own call to the newly-downloaded
5
+ * version's `migrate --from <installed> --dry-run --json` (Task 9's plan step). Runs every
6
+ * applicable migration in order, concatenating each one's reported changes — one broken migration
7
+ * still lets the rest report (design's Error-handling rule), though in practice a migration that
8
+ * throws here means the whole upgrade fails loudly, since there is no "partial migration" state
9
+ * worth leaving a project in; `cli/update.ts` (Task 10) treats any thrown error from this as the
10
+ * "failure" result, not a silently-skipped migration. */
11
+ export function runMigrate(projectRoot, fromVersion, options) {
12
+ // Always filter/sort by fromVersion here, even for an explicitly-injected `migrations` list —
13
+ // `migrationsAfter` already does this for the real registry, but a test-only injected list (out
14
+ // of order, including versions at or before fromVersion) needs the same treatment to match this
15
+ // function's documented "runs every migration after fromVersion, in order" contract.
16
+ const from = parseVersion(fromVersion);
17
+ const migrations = (options.migrations ?? migrationsAfter(fromVersion))
18
+ .filter((migration) => compareVersions(parseVersion(migration.version), from) > 0)
19
+ .sort((a, b) => compareVersions(parseVersion(a.version), parseVersion(b.version)));
20
+ const changes = [];
21
+ for (const migration of migrations) {
22
+ changes.push(...(options.dryRun ? migration.dryRun(projectRoot) : migration.apply(projectRoot)));
23
+ }
24
+ return { version: 1, from: fromVersion, changes, dryRun: options.dryRun };
25
+ }
26
+ export function formatMigrateHuman(result) {
27
+ if (result.changes.length === 0)
28
+ return `Nothing to migrate from ${result.from}.`;
29
+ return result.changes.map((change) => `${change.file}: ${change.change}`).join('\n');
30
+ }
@@ -0,0 +1,19 @@
1
+ // cli/migrateEntry.ts
2
+ //
3
+ // A standalone, dependency-free CLI entry — imports only `migrate.ts` (and, transitively,
4
+ // `migrations/`/`semver.ts`), never `main.ts` (whose `config.ts`/`doctor.ts`/`init.ts`/
5
+ // `explain.ts` imports each pull in the `typescript` npm package at module load). `updatePlan.ts`
6
+ // (Task 9) runs this file's own compiled `dist/cli/migrateEntry.js` directly from an `npm
7
+ // pack`-extracted tarball, which has no `node_modules` of its own — `main.js` would fail there
8
+ // with `ERR_MODULE_NOT_FOUND` for `'typescript'` (Critical finding, Fable correction pass, proven
9
+ // for real in `spikes/migrate-entry/`: see this plan's own final report).
10
+ import { formatMigrateHuman, runMigrate } from "./migrate.js";
11
+ const args = process.argv.slice(2);
12
+ const fromIndex = args.indexOf('--from');
13
+ const fromVersion = fromIndex !== -1 ? args[fromIndex + 1] : undefined;
14
+ if (!fromVersion) {
15
+ console.error('Usage: migrateEntry --from <version> [--dry-run] [--json]');
16
+ process.exit(1);
17
+ }
18
+ const result = runMigrate(process.cwd(), fromVersion, { dryRun: args.includes('--dry-run') });
19
+ console.log(args.includes('--json') ? JSON.stringify(result) : formatMigrateHuman(result));