@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.
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { detectPackageManager } from "./packageManager.js";
3
4
  // Design §4 "GitHub Action" + §5's lockfile-detection assumption. `checkout`/`setup-node` pins
4
5
  // reused verbatim from this repository's own `.github/workflows/ci.yml` (Global Constraints);
5
6
  // `pnpm/action-setup` is the one new SHA this plan resolves (`gh api
@@ -20,21 +21,17 @@ function pnpmVersion(projectRoot) {
20
21
  }
21
22
  // Important finding 6 (Fable's review): Yarn 1 (still the common Expo lockfile) has no
22
23
  // `--immutable` flag — its equivalent is `--frozen-lockfile`. Yarn Berry needs `corepack enable`
23
- // first, since `actions/setup-node` ships Yarn 1 on `ubuntu-latest`. A v1 lockfile's own first line
24
- // is always exactly `# yarn lockfile v1`; anything else (Berry's own header/`__metadata`) is Berry.
25
- function yarnInstallStep(projectRoot) {
26
- const firstLine = readFileSync(path.join(projectRoot, 'yarn.lock'), 'utf8').split('\n', 1)[0];
27
- if (firstLine === '# yarn lockfile v1')
28
- return ' - run: yarn install --frozen-lockfile\n';
29
- return ' - run: corepack enable\n - run: yarn install --immutable\n';
30
- }
24
+ // first, since `actions/setup-node` ships Yarn 1 on `ubuntu-latest`.
31
25
  function installStep(projectRoot) {
32
- if (existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) {
26
+ const pm = detectPackageManager(projectRoot);
27
+ if (pm === 'pnpm') {
33
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`;
34
29
  }
35
30
  const setupNode = ` - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0\n with:\n node-version: '20'\n`;
36
- if (existsSync(path.join(projectRoot, 'yarn.lock')))
37
- return `${setupNode}${yarnInstallStep(projectRoot)}`;
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`;
38
35
  // `package-lock.json`, or no lockfile at all (a fresh project before its first commit) — `npm
39
36
  // ci` is npm's own documented behavior for the latter: it installs from `package.json` and
40
37
  // writes a fresh lockfile, exactly like a plain `npm install` would.
package/dist/cli/init.js CHANGED
@@ -6,6 +6,7 @@ import { preflight } from "./preflight.js";
6
6
  import { detectComponentFolders, detectTokenFiles } from "./detect.js";
7
7
  import { ensureAgentsFileSection } from "./agentsFile.js";
8
8
  import { ensureGithubAction } from "./githubAction.js";
9
+ import { readOwnVersion } from "./packageVersion.js";
9
10
  async function promptConfirm(message) {
10
11
  // A closed, non-interactive stdin (CI, piped npx) would reject `question` with an AbortError;
11
12
  // say how to proceed instead.
@@ -37,8 +38,7 @@ function packageRoot() {
37
38
  throw new Error("Could not find @krapwoo/ds-viewer's own package.json to read its version.");
38
39
  }
39
40
  function ownVersion() {
40
- const pkg = JSON.parse(readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
41
- return pkg.version;
41
+ return readOwnVersion(packageRoot());
42
42
  }
43
43
  /** Detects the existing indent of a JSON file's first indented line, so rewriting it (e.g.
44
44
  * `package.json` below) doesn't reformat the user's own indentation style. Falls back to two
@@ -208,7 +208,7 @@ export default defineConfig({
208
208
  tokens: ['${kitRoot}/tokens/index.ts'],
209
209
  pages: ['${kitRoot}/pages/*.catalog.tsx'],
210
210
  groupOrder: ${JSON.stringify(KIT_GROUP_ORDER)},
211
- starterKit: { version: '${starterKitVersion}' },
211
+ starterKit: { version: '${starterKitVersion}', root: '${kitRoot}' },
212
212
  });
213
213
  `;
214
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,5 +1,4 @@
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";
@@ -8,6 +7,11 @@ import { dev } from "./dev.js";
8
7
  import { sync } from "./sync.js";
9
8
  import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
10
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";
11
15
  const USAGE = `Usage: ds-viewer <command>
12
16
 
13
17
  Commands:
@@ -18,6 +22,8 @@ Commands:
18
22
  --yes accept the detected components and tokens without asking (--existing only)
19
23
  sync Regenerate the component, props, token, and page data from source
20
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
21
27
  doctor Check every page for drift and coverage gaps
22
28
  --json print the machine-readable report instead
23
29
  --ci exit 1 when any error was found (never on a warning alone)
@@ -25,24 +31,25 @@ Commands:
25
31
  Print why a page's specimens are laid out the way they are
26
32
  --heights <first>,<second> check side-by-side placement with these measured heights
27
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
28
43
 
29
44
  Options:
30
45
  -h, --help Show this help
31
46
  -v, --version Show the installed version`;
32
- /** Walks up from this file (cli/ in source, dist/cli/ when installed) to the package's own package.json. */
33
47
  function packageVersion() {
34
- let dir = path.dirname(fileURLToPath(import.meta.url));
35
- while (true) {
36
- const candidate = path.join(dir, 'package.json');
37
- if (existsSync(candidate)) {
38
- const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
39
- if (pkg.name === '@krapwoo/ds-viewer' && pkg.version)
40
- return pkg.version;
41
- }
42
- const parent = path.dirname(dir);
43
- if (parent === dir)
44
- return 'unknown';
45
- dir = parent;
48
+ try {
49
+ return readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
50
+ }
51
+ catch {
52
+ return 'unknown';
46
53
  }
47
54
  }
48
55
  /** `--kit-root`'s raw CLI value, normalized to a project-root-relative, forward-slash path — or an
@@ -107,10 +114,25 @@ export async function runInit(projectRoot, rest, options = {}) {
107
114
  /** Pulled out of `main()` for the same reason `runInit` was in 0.1/0.2 (Fable's review): testable
108
115
  * without spawning a process. `--ci`'s exit code is read from `runDoctor`'s own `exitCode` — a
109
116
  * plain `doctor` (no `--ci`) always returns 0 here regardless of errors found. */
110
- export function runDoctorCommand(projectRoot, rest) {
117
+ export async function runDoctorCommand(projectRoot, rest, options = {}) {
111
118
  const config = resolveConfig(projectRoot);
112
119
  const result = runDoctor(config);
113
- const output = rest.includes('--json') ? JSON.stringify(toDoctorJson(result), null, 2) : formatHuman(result);
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}`;
114
136
  return { output, exitCode: rest.includes('--ci') ? result.exitCode : 0 };
115
137
  }
116
138
  /** Same "testable without spawning" shape as `runDoctorCommand` above. */
@@ -157,7 +179,7 @@ async function main() {
157
179
  return;
158
180
  }
159
181
  if (command === 'doctor') {
160
- const { output, exitCode } = runDoctorCommand(projectRoot, rest);
182
+ const { output, exitCode } = await runDoctorCommand(projectRoot, rest);
161
183
  console.log(output);
162
184
  if (exitCode)
163
185
  process.exitCode = exitCode;
@@ -171,9 +193,46 @@ async function main() {
171
193
  process.exitCode = exitCode;
172
194
  return;
173
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
+ }
174
230
  if (command === 'dev') {
175
231
  const config = resolveConfig(projectRoot);
176
- 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 });
177
236
  return;
178
237
  }
179
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));
@@ -0,0 +1,14 @@
1
+ // cli/migrations/index.ts
2
+ import { compareVersions, parseVersion } from "../semver.js";
3
+ /** The real, production migration registry. Empty in 0.4 — design §5 "Versioning": "From 0.4,
4
+ * every breaking change ships an automatic migration" describes the *mechanism* this plan builds,
5
+ * not a requirement to invent a breaking change 0.4 does not have. The framework itself is proven
6
+ * by a test-only fixture migration (`cli/__tests__/migrate.test.ts`) that is never added here. */
7
+ export const MIGRATIONS = [];
8
+ /** Every migration after `fromVersion`, oldest first. */
9
+ export function migrationsAfter(fromVersion) {
10
+ const from = parseVersion(fromVersion);
11
+ return MIGRATIONS
12
+ .filter((migration) => compareVersions(parseVersion(migration.version), from) > 0)
13
+ .sort((a, b) => compareVersions(parseVersion(a.version), parseVersion(b.version)));
14
+ }
@@ -0,0 +1,9 @@
1
+ // cli/migrations/types.ts
2
+ //
3
+ // This file, and every file `cli/migrateEntry.ts` transitively imports through `Migration` values
4
+ // placed in `cli/migrations/index.ts` (`migrate.ts`, `migrations/index.ts`, `semver.ts`), must
5
+ // never import the `typescript` npm package (directly or transitively) — `migrateEntry.ts` is the
6
+ // one thing this plan runs from inside an `npm pack`-extracted tarball that has no `node_modules`
7
+ // of its own, and `typescript` is a real `dependencies` entry (Task 9's own `buildUpdatePlan`,
8
+ // spiked for real in `spikes/migrate-entry/`).
9
+ export {};
@@ -0,0 +1,40 @@
1
+ // cli/packageManager.ts
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ /** The same lockfile-sniffing `cli/githubAction.ts`'s own `installStep` already did inline (0.3)
5
+ * — promoted here so `cli/update.ts` (Task 10) can detect the same thing without duplicating it.
6
+ * A v1 yarn.lock's own first line is always exactly `# yarn lockfile v1`; anything else (Berry's
7
+ * own header/`__metadata`) is Berry. */
8
+ export function detectPackageManager(projectRoot) {
9
+ if (existsSync(path.join(projectRoot, 'pnpm-lock.yaml')))
10
+ return 'pnpm';
11
+ const yarnLockPath = path.join(projectRoot, 'yarn.lock');
12
+ if (existsSync(yarnLockPath)) {
13
+ const firstLine = readFileSync(yarnLockPath, 'utf8').split('\n', 1)[0];
14
+ return firstLine === '# yarn lockfile v1' ? 'yarn-classic' : 'yarn-berry';
15
+ }
16
+ return 'npm'; // package-lock.json, or no lockfile at all (npm's own documented default either way).
17
+ }
18
+ /** The one command `cli/update.ts`'s "Upgrades with the project's package manager" step (design
19
+ * §5 "`npx ds-viewer update`" step 3) runs, for whichever manager `detectPackageManager` found.
20
+ * Yarn (classic and Berry both) use the same `yarn add --dev` syntax for upgrading a single
21
+ * package to an exact version. */
22
+ export function installUpgradeCommand(pm, pkg, version) {
23
+ const spec = `${pkg}@${version}`;
24
+ if (pm === 'npm')
25
+ return { command: 'npm', args: ['install', '--save-dev', spec] };
26
+ if (pm === 'pnpm')
27
+ return { command: 'pnpm', args: ['add', '--save-dev', spec] };
28
+ return { command: 'yarn', args: ['add', '--dev', spec] };
29
+ }
30
+ const WINDOWS_SHIMS = new Set(['npm', 'npx', 'pnpm', 'yarn']);
31
+ /** On Windows, `npm`, `npx`, `pnpm` and `yarn` are `.cmd` shims, which Node refuses to run
32
+ * without a shell (the CVE-2024-27980 fix). With a shell, Node joins the arguments with spaces,
33
+ * so any argument containing a space or a shell metacharacter (a temp folder under
34
+ * `C:\Users\Jane Doe\…`) is wrapped in double quotes. Real executables (`node`, `tar`, `git`)
35
+ * and every other platform keep running without a shell. */
36
+ export function platformCommand(command, args, platform = process.platform) {
37
+ if (platform !== 'win32' || !WINDOWS_SHIMS.has(command))
38
+ return { command, args, shell: false };
39
+ return { command, args: args.map((arg) => (/[\s"&|<>^()]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg)), shell: true };
40
+ }
@@ -0,0 +1,24 @@
1
+ // cli/packageVersion.ts
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ /** Walks up from `startDir` looking for the `package.json` named `@krapwoo/ds-viewer` — needed
5
+ * because this file sits one level under the repo root as `cli/packageVersion.ts` (how tests and
6
+ * `init`/`main` run it from source) but two levels under it once compiled, as
7
+ * `dist/cli/packageVersion.js`. Throws when it reaches the filesystem root without finding one,
8
+ * which should never happen for a real install (`readOwnVersion` is only ever called from code
9
+ * that is itself inside the installed/running package). */
10
+ export function readOwnVersion(startDir) {
11
+ let dir = startDir;
12
+ while (true) {
13
+ const candidate = path.join(dir, 'package.json');
14
+ if (existsSync(candidate)) {
15
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
16
+ if (pkg.name === '@krapwoo/ds-viewer' && pkg.version)
17
+ return pkg.version;
18
+ }
19
+ const parent = path.dirname(dir);
20
+ if (parent === dir)
21
+ throw new Error("Could not find @krapwoo/ds-viewer's own package.json to read its version.");
22
+ dir = parent;
23
+ }
24
+ }
@@ -0,0 +1,26 @@
1
+ // cli/semver.ts
2
+ const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/;
3
+ export function parseVersion(raw) {
4
+ const match = VERSION_RE.exec(raw.trim());
5
+ if (!match)
6
+ throw new Error(`Not a version: "${raw}"`);
7
+ const [, major, minor, patch, prerelease] = match;
8
+ return { major: Number(major), minor: Number(minor), patch: Number(patch), prerelease };
9
+ }
10
+ /** -1 when `a` is older than `b`, 1 when newer, 0 when equal — prerelease tags are ignored for
11
+ * ordering (this package only ever compares a real installed version against a real registry
12
+ * "latest", which is never a prerelease; the throwaway spike tag above is test-only). */
13
+ export function compareVersions(a, b) {
14
+ for (const key of ['major', 'minor', 'patch']) {
15
+ if (a[key] !== b[key])
16
+ return a[key] < b[key] ? -1 : 1;
17
+ }
18
+ return 0;
19
+ }
20
+ /** Design §5 "Releasing" — "From 1.0, breaking changes only in major versions"; before that, this
21
+ * plan still needs one consistent rule to decide the banner vs. the quiet footer line (design
22
+ * §5 "Viewer notice"), and a major-version bump is the only rule that needs no per-release
23
+ * judgment call. */
24
+ export function isBreakingUpgrade(current, latest) {
25
+ return latest.major > current.major;
26
+ }
@@ -0,0 +1,96 @@
1
+ // cli/update.ts
2
+ import { execFileSync } from 'node:child_process';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { buildUpdatePlan, isAlreadyUpToDate } from "./updatePlan.js";
7
+ import { detectPackageManager, installUpgradeCommand, platformCommand } from "./packageManager.js";
8
+ import { readOwnVersion } from "./packageVersion.js";
9
+ import { lastSummaryLine } from "./doctor.js";
10
+ function formatPlanHuman(plan) {
11
+ const lines = [`${plan.current} → ${plan.latest}${plan.breaking ? ' (major — includes breaking changes)' : ''}`];
12
+ if (plan.summary.length > 0)
13
+ lines.push('', "What's new:", ...plan.summary.map((line) => ` - ${line}`));
14
+ lines.push('', 'Files that would change:', ...plan.files.map((f) => ` ${f.path} (${f.dirty ? 'uncommitted' : f.reason})`));
15
+ if (plan.kitFilesDiffering > 0) {
16
+ lines.push('', `${plan.kitFilesDiffering} kit file${plan.kitFilesDiffering === 1 ? '' : 's'} differ from the installed kit's version. Compare with npx ds-viewer kit diff <Component>.`);
17
+ }
18
+ if (plan.outsideGitRepo)
19
+ lines.push('', '(Not a git repository — dirty-file detection was skipped; every file above is treated as not dirty.)');
20
+ return lines.join('\n');
21
+ }
22
+ async function defaultConfirm(message) {
23
+ if (!process.stdin.isTTY) {
24
+ console.log('Non-interactive stdin: rerun with --yes to accept.');
25
+ return false;
26
+ }
27
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
28
+ const answer = await rl.question(message);
29
+ rl.close();
30
+ return /^y(es)?$/i.test(answer.trim());
31
+ }
32
+ function installedMainJs(projectRoot) {
33
+ return path.join(projectRoot, 'node_modules', '@krapwoo', 'ds-viewer', 'dist', 'cli', 'main.js');
34
+ }
35
+ /** Design §5 "`npx ds-viewer update`", steps 1-6. */
36
+ export async function runUpdate(config, options = {}) {
37
+ const currentVersion = options.currentVersion ?? readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
38
+ const plan = await (options.buildPlan ?? buildUpdatePlan)(config, currentVersion);
39
+ if ('error' in plan)
40
+ return { output: plan.error, exitCode: isAlreadyUpToDate(plan.error) ? 0 : 1 };
41
+ const planText = formatPlanHuman(plan);
42
+ if (options.dryRun)
43
+ return { output: planText, exitCode: 0 };
44
+ // Step 2: refuses on any dirty planned file, unless --force — checked before the confirmation
45
+ // prompt, so a script passing --yes without --force still gets refused instead of silently
46
+ // overwriting uncommitted work.
47
+ if (!options.force && plan.dirtyFiles.length > 0) {
48
+ return {
49
+ output: `${planText}\n\nRefusing: the following files have uncommitted changes:\n${plan.dirtyFiles.map((f) => ` ${f}`).join('\n')}\n\nCommit or stash them, or rerun with --force.`,
50
+ exitCode: 1,
51
+ };
52
+ }
53
+ if (!options.yes) {
54
+ const proceed = await (options.confirm ?? defaultConfirm)(`${planText}\n\nUpdate to ${plan.latest}? [y/N] `);
55
+ if (!proceed)
56
+ return { output: 'Aborted: nothing was changed.', exitCode: 0 };
57
+ }
58
+ const execImpl = options.execImpl ?? ((cmd, args, opts) => {
59
+ const run = platformCommand(cmd, args);
60
+ return execFileSync(run.command, run.args, { ...opts, encoding: 'utf8', shell: run.shell });
61
+ });
62
+ const { command, args } = installUpgradeCommand(detectPackageManager(config.projectRoot), '@krapwoo/ds-viewer', plan.latest);
63
+ try {
64
+ execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
65
+ }
66
+ catch (error) {
67
+ return { output: `The update stopped while installing.\n${error.message}\n\nRecover with: npx ds-viewer update`, exitCode: 1 };
68
+ }
69
+ // Step 4: "Re-runs the newly installed binary" — the freshly-installed package's own
70
+ // `dist/cli/main.js`, never the one that is already running this process.
71
+ try {
72
+ execImpl('node', [installedMainJs(config.projectRoot), 'migrate', '--from', currentVersion], { cwd: config.projectRoot, encoding: 'utf8' });
73
+ }
74
+ catch (error) {
75
+ return {
76
+ output: `Installed ${plan.latest}, but migrate failed.\n${error.message}\n\nRecover with: npx ds-viewer migrate --from ${currentVersion}`,
77
+ exitCode: 1,
78
+ };
79
+ }
80
+ // M6 (Minor, Fable correction pass): the doctor step wasn't wrapped in try/catch — a crash here
81
+ // rejected `runUpdate` itself, losing the "installed and migrated successfully" context and
82
+ // leaving `main` to print only the raw error, mirroring `performUpdate`'s own identical guard.
83
+ let doctorOutput;
84
+ try {
85
+ doctorOutput = execImpl('node', [installedMainJs(config.projectRoot), 'doctor'], { cwd: config.projectRoot, encoding: 'utf8' });
86
+ }
87
+ catch (error) {
88
+ return {
89
+ output: `Installed and migrated to ${plan.latest}, but doctor failed to run.\n${error.message}\n\nRecover with: npx ds-viewer doctor`,
90
+ exitCode: 1,
91
+ };
92
+ }
93
+ // I5 (Important, raised from Minor by the controller): only the report's own last summary line —
94
+ // the mockup's success page shows one line, not the whole doctor report.
95
+ return { output: `Updated to ${plan.latest}. Changes are not committed — review them in your editor.\n\n${lastSummaryLine(doctorOutput)}`, exitCode: 0 };
96
+ }