@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.
- package/README.md +151 -0
- package/config/index.ts +4 -2
- package/dist/cli/agentsFile.js +31 -0
- package/dist/cli/dev.js +312 -15
- package/dist/cli/doctor.js +502 -0
- package/dist/cli/endpoint.js +142 -0
- package/dist/cli/explain.js +151 -0
- package/dist/cli/githubAction.js +65 -0
- package/dist/cli/init.js +9 -5
- package/dist/cli/kitDiff.js +132 -0
- package/dist/cli/kitRoot.js +20 -0
- package/dist/cli/main.js +123 -15
- package/dist/cli/migrate.js +30 -0
- package/dist/cli/migrateEntry.js +19 -0
- package/dist/cli/migrations/index.js +14 -0
- package/dist/cli/migrations/types.js +9 -0
- package/dist/cli/packageManager.js +29 -0
- package/dist/cli/packageVersion.js +24 -0
- package/dist/cli/pageIndex.js +27 -1
- package/dist/cli/props.js +4 -3
- package/dist/cli/semver.js +26 -0
- package/dist/cli/staticPage.js +356 -0
- package/dist/cli/sync.js +11 -4
- package/dist/cli/update.js +93 -0
- package/dist/cli/updateCheck.js +143 -0
- package/dist/cli/updatePlan.js +202 -0
- package/dist/cli/workspace.js +10 -5
- package/dist/native/catalog/catalogNavigation.js +129 -0
- package/dist/native/catalog/comparison.js +283 -0
- package/dist/native/catalog/tokens.js +134 -0
- package/dist/native/catalog/types.js +1 -0
- package/native/catalog/CatalogShell.tsx +105 -11
- package/native/catalog/CatalogSidebar.tsx +58 -2
- package/native/catalog/ComparisonGrid.tsx +9 -7
- package/native/catalog/SectionBlock.tsx +5 -0
- package/native/catalog/SpacingScaleGallery.tsx +1 -1
- package/native/catalog/UpdatePanel.tsx +556 -0
- package/native/catalog/catalogNavigation.ts +70 -1
- package/native/catalog/comparison.ts +63 -5
- package/native/catalog/index.ts +5 -1
- package/native/catalog/pageApi.ts +11 -2
- package/native/catalog/tokens.ts +15 -0
- package/native/catalog/types.ts +19 -5
- package/native/catalog/updatePanelState.ts +74 -0
- package/package.json +5 -1
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +4 -22
- package/starter-kit/components/Badge/Badge.catalog.tsx +15 -23
- package/starter-kit/components/Banner/Banner.tsx +1 -1
- package/starter-kit/components/BottomSheet/BottomSheet.tsx +2 -2
- package/starter-kit/components/Button/Button.catalog.tsx +15 -29
- package/starter-kit/components/Dialog/Dialog.tsx +2 -2
- 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
|
-
|
|
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,
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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));
|