diff --git a/.claude/skills/sync-tsdown-cli/SKILL.md b/.claude/skills/sync-tsdown-cli/SKILL.md index 3072f30cab..db8c75d106 100644 --- a/.claude/skills/sync-tsdown-cli/SKILL.md +++ b/.claude/skills/sync-tsdown-cli/SKILL.md @@ -1,21 +1,21 @@ --- name: sync-tsdown-cli -description: Compare tsdown CLI options with vp pack and sync any new or removed options. Use when tsdown is upgraded or when you need to check for CLI option drift between tsdown and vp pack. +description: Sync tsdown runtime CLI options with vp pack after a tsdown upgrade. Use for option forwarding changes; use sync-upstream-cli-help for static help wording. allowed-tools: Read, Grep, Glob, Edit, Bash --- -# Sync tsdown CLI Options with vp pack +# Sync tsdown CLI -Compare the upstream `tsdown` CLI options with `vp pack` (defined in `packages/cli/src/pack-bin.ts`) and sync any differences. +Runtime options live in `packages/cli/src/pack-bin.ts`; static help lives in +`packages/cli/src/help.ts`. -## Steps - -1. Run `npx tsdown --help` from `packages/cli/` to get tsdown's current CLI options -2. Read `packages/cli/src/pack-bin.ts` to see vp pack's current options -3. Compare and add any new tsdown options to `pack-bin.ts` using the existing cac `.option()` pattern -4. If tsdown removed options, do NOT remove them from `pack-bin.ts` -- instead add a code comment like `// NOTE: removed from tsdown CLI in vX.Y.Z` above the option so reviewers can decide whether to follow up -5. Preserve intentional differences: - - `-c, --config` is intentionally commented out (vp pack uses vite.config.ts) - - `--env-prefix` has a different default (`['VITE_PACK_', 'TSDOWN_']`) -6. Verify with `pnpm --filter vite-plus build-ts` and `vp pack -h` -7. If new parameters were added, add a corresponding PTY snapshot case under `crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/` to verify the new option works correctly +1. Run `npx tsdown --help` from `packages/cli/` and compare it with `pack-bin.ts`. +2. Add new forwarded options using the existing cac `.option()` pattern. For removed + options, add `// NOTE: removed from tsdown CLI in vX.Y.Z` for reviewer follow-up. +3. Preserve runtime differences: `-c, --config` stays disabled because Vite+ uses + `vite.config.ts`, and `--env-prefix` keeps the `['VITE_PACK_', 'TSDOWN_']` default. +4. For static labels and descriptions, follow `sync-upstream-cli-help`; do not adapt + upstream wording to explain runtime differences. +5. Run `pnpm --filter vite-plus build-ts` and `vp pack -h`. Add a focused PTY snapshot + case under `crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/` when a new runtime + option is exposed. diff --git a/.claude/skills/sync-upstream-cli-help/SKILL.md b/.claude/skills/sync-upstream-cli-help/SKILL.md new file mode 100644 index 0000000000..b64abcc2d8 --- /dev/null +++ b/.claude/skills/sync-upstream-cli-help/SKILL.md @@ -0,0 +1,49 @@ +--- +name: sync-upstream-cli-help +description: Sync Vite+'s static CLI help with Vite, Vitest, Oxlint, Oxfmt, and tsdown while preserving intentional omissions. Use when an upstream dependency upgrade changes CLI help. +allowed-tools: Read, Grep, Glob, Edit, Bash +--- + +# Sync upstream CLI help + +## Input and target + +- Read the diff from `$CLI_HELP_DIFF_REPORT`; first run + `test -r "$CLI_HELP_DIFF_REPORT"`. Act only when `$CLI_HELP_DIFF_CHANGED` is + `true`. +- Treat the upgraded tool's `--help` output as the source of truth. The report locates + changes and versions; rerun the exact version when its diff is truncated. +- Edit `commandHelpDocs` in `packages/cli/src/help.ts`. +- Do not edit `packages/cli/src/utils/help.ts` for content drift. It owns terminal + wrapping, alignment, and the right margin. + +| Upstream help | Document entry | +| --------------------- | -------------- | +| `vite --help` | `dev` | +| `vite build --help` | `build` | +| `vite preview --help` | `preview` | +| `vitest --help` | `test` | +| `oxlint --help` | `lint` | +| `oxfmt --help` | `fmt` | +| `tsdown --help` | `pack` | + +## Change + +- For items Vite+ exposes, copy upstream labels, descriptions, section titles, and + section guidance exactly. Preserve intentional lines and lists, but not terminal + padding, automatic wrapping, ANSI color, or version banners. +- Remove an upstream item only after confirming Vite+ no longer supports or + deliberately retains it. + +## Do not change + +- Keep Vite+-owned usage, summaries, examples, and documentation URLs. +- Keep config selectors/loaders hidden, including `--config`, `--configLoader`, and + `--disable-nested-config`. +- Do not add standalone modes such as `--init`, `--migrate`, or `--lsp`, top-level + `--version`, or options Vite+ does not forward. +- Do not change runtime forwarding or rewrite upstream wording for a Vite+-specific + runtime default. Use `sync-tsdown-cli` for tsdown runtime changes. + +Re-record the affected CLI help snapshots and inspect their diffs. Do not modify help +documents when the report contains no actionable exposed change. diff --git a/.github/scripts/__tests__/cli-help-diff.spec.ts b/.github/scripts/__tests__/cli-help-diff.spec.ts new file mode 100644 index 0000000000..befc5ea1fc --- /dev/null +++ b/.github/scripts/__tests__/cli-help-diff.spec.ts @@ -0,0 +1,111 @@ +/// + +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { afterEach, expect, test } from 'vitest'; + +const SCRIPT_PATH = resolve(import.meta.dirname, '../cli-help-diff.ts'); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { force: true, recursive: true }); + } +}); + +test('reports changed, unchanged, and not-updated CLI help in one comment', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'vite-plus-cli-help-test-')); + tempDirs.push(tempDir); + const beforePath = join(tempDir, 'before.json'); + const afterPath = join(tempDir, 'after.json'); + const githubOutputPath = join(tempDir, 'github-output.txt'); + const reportPath = join(tempDir, 'report.md'); + const before = { + tools: { + vite: { help: 'vite/1.0.0\n--old-option', version: '1.0.0' }, + vitest: { help: 'vitest/1.0.0\n--watch', version: '1.0.0' }, + oxlint: { help: 'oxlint 1.0.0\n--fix', version: '1.0.0' }, + oxfmt: { help: 'oxfmt\n--write', version: '1.0.0' }, + tsdown: { help: 'tsdown 1.0.0\n--old-option', version: '1.0.0' }, + }, + }; + const after = { + tools: { + vite: { help: 'vite/2.0.0\n--new-option', version: '2.0.0' }, + vitest: { help: 'vitest/1.0.0\n--watch', version: '1.0.0' }, + oxlint: { help: 'oxlint 2.0.0\n--fix', version: '2.0.0' }, + oxfmt: { help: 'oxfmt\n--write', version: '1.0.0' }, + tsdown: { help: 'tsdown 2.0.0\n--new-option', version: '2.0.0' }, + }, + }; + writeFileSync(beforePath, JSON.stringify(before)); + writeFileSync(afterPath, JSON.stringify(after)); + + execFileSync( + process.execPath, + [ + SCRIPT_PATH, + 'report', + '--before', + beforePath, + '--after', + afterPath, + '--output', + reportPath, + '--github-output', + githubOutputPath, + ], + { cwd: resolve(import.meta.dirname, '../../..') }, + ); + + const report = readFileSync(reportPath, 'utf8'); + expect(report).toContain('## ⚠️ Upstream CLI help changes detected'); + expect(report).toContain('⚠️ Vite: CLI help changed (1.0.0 → 2.0.0)'); + expect(report).toContain('✅ Oxlint: no CLI help changes (1.0.0 → 2.0.0)'); + expect(report).toContain('➖ Vitest: no version update (1.0.0)'); + expect(report).toContain('```diff\n--- vite@1.0.0\n+++ vite@2.0.0'); + expect(report).toContain('---old-option'); + expect(report).toContain('+--new-option'); + expect(report).not.toContain('-vite/1.0.0'); + expect(report).not.toContain('+vite/2.0.0'); + expect(readFileSync(githubOutputPath, 'utf8')).toBe('has-changes=true\n'); +}); + +test('reports no machine-readable changes when help is unchanged', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'vite-plus-cli-help-test-')); + tempDirs.push(tempDir); + const snapshotPath = join(tempDir, 'snapshot.json'); + const githubOutputPath = join(tempDir, 'github-output.txt'); + const reportPath = join(tempDir, 'report.md'); + const snapshot = { + tools: Object.fromEntries( + ['vite', 'vitest', 'oxlint', 'oxfmt', 'tsdown'].map((tool) => [ + tool, + { help: `${tool}/1.0.0\n--help`, version: '1.0.0' }, + ]), + ), + }; + writeFileSync(snapshotPath, JSON.stringify(snapshot)); + + execFileSync( + process.execPath, + [ + SCRIPT_PATH, + 'report', + '--before', + snapshotPath, + '--after', + snapshotPath, + '--output', + reportPath, + '--github-output', + githubOutputPath, + ], + { cwd: resolve(import.meta.dirname, '../../..') }, + ); + + expect(readFileSync(githubOutputPath, 'utf8')).toBe('has-changes=false\n'); +}); diff --git a/.github/scripts/cli-help-diff.ts b/.github/scripts/cli-help-diff.ts new file mode 100644 index 0000000000..54fbc35a20 --- /dev/null +++ b/.github/scripts/cli-help-diff.ts @@ -0,0 +1,309 @@ +/// + +import { spawnSync } from 'node:child_process'; +import { + appendFileSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { parseArgs, stripVTControlCharacters } from 'node:util'; + +type ToolName = 'vite' | 'vitest' | 'oxlint' | 'oxfmt' | 'tsdown'; + +type Tool = { + commands: string[][]; + name: ToolName; + packageName: string; + title: string; +}; + +type ToolSnapshot = { + help: string; + version: string; +}; + +type Snapshot = { + tools: Record; +}; + +type VersionMetadata = Partial< + Record< + ToolName, + { + new: string; + tag?: string; + } + > +>; + +const ROOT = process.cwd(); +const WORKSPACE_PATH = join(ROOT, 'pnpm-workspace.yaml'); +// Leave room for five reports plus Markdown within GitHub's 65,536-character comment limit. +const MAX_DIFF_LENGTH = 12_000; +const TOOLS: Tool[] = [ + { + // Vite+ mirrors the root command plus the build and preview option sets. + commands: [['--help'], ['build', '--help'], ['preview', '--help']], + name: 'vite', + packageName: 'vite', + title: 'Vite', + }, + { + commands: [['--help']], + name: 'vitest', + packageName: 'vitest', + title: 'Vitest', + }, + { + commands: [['--help']], + name: 'oxlint', + packageName: 'oxlint', + title: 'Oxlint', + }, + { + commands: [['--help']], + name: 'oxfmt', + packageName: 'oxfmt', + title: 'Oxfmt', + }, + { + commands: [['--help']], + name: 'tsdown', + packageName: 'tsdown', + title: 'tsdown', + }, +]; + +function readJson(filePath: string): unknown { + return JSON.parse(readFileSync(filePath, 'utf8')); +} + +function readToolVersion(tool: Tool, versions?: VersionMetadata): string { + if (versions) { + const change = versions[tool.name]; + const version = tool.name === 'vite' ? change?.tag?.replace(/^v/, '') : change?.new; + if (!version) { + throw new Error(`Upgrade metadata has no target version for ${tool.name}`); + } + return version; + } + + if (tool.name === 'vite') { + const pkg = readJson(join(ROOT, 'vite/packages/vite/package.json')) as { version?: unknown }; + if (typeof pkg.version !== 'string') { + throw new TypeError('vite/packages/vite/package.json has no version'); + } + return pkg.version; + } + + const workspace = readFileSync(WORKSPACE_PATH, 'utf8'); + const match = new RegExp(`^ ${tool.name}: [=~^]?([^\\s#]+)`, 'm').exec(workspace); + if (!match) { + throw new Error(`Could not find ${tool.name} in the pnpm workspace catalog`); + } + return match[1]; +} + +function normalizeOutput(output: string, version: string): string { + // Version banners change on every release but do not represent CLI option drift. + return stripVTControlCharacters(output) + .replaceAll('\r\n', '\n') + .replaceAll(version, '') + .split('\n') + .map((line) => line.trimEnd()) + .join('\n') + .trimEnd(); +} + +function captureToolHelp(tool: Tool, version: string): string { + return tool.commands + .map((command) => { + const result = spawnSync( + 'pnpm', + ['--silent', 'dlx', `${tool.packageName}@${version}`, ...command], + { + encoding: 'utf8', + env: { + ...process.env, + CI: '1', + COLUMNS: '120', + FORCE_COLOR: '0', + NO_COLOR: '1', + TERM: 'dumb', + }, + }, + ); + if (result.status !== 0) { + throw new Error( + `Failed to capture ${tool.title} help (${command.join(' ')}):\n${result.stderr}`, + ); + } + return [`$ ${tool.packageName} ${command.join(' ')}`, result.stdout.trimEnd()].join('\n'); + }) + .join('\n\n'); +} + +function captureSnapshot(outputPath: string, versionsPath?: string): void { + const versions = versionsPath ? (readJson(versionsPath) as VersionMetadata) : undefined; + const tools = {} as Record; + for (const tool of TOOLS) { + const version = readToolVersion(tool, versions); + console.log(`Capturing ${tool.title} ${version} help...`); + tools[tool.name] = { + help: captureToolHelp(tool, version), + version, + }; + } + + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify({ tools } satisfies Snapshot, null, 2)}\n`); +} + +function createUnifiedDiff(tool: Tool, before: ToolSnapshot, after: ToolSnapshot): string { + const tempDir = mkdtempSync(join(tmpdir(), 'vite-plus-cli-help-')); + const beforePath = join(tempDir, 'before.txt'); + const afterPath = join(tempDir, 'after.txt'); + + try { + writeFileSync(beforePath, `${normalizeOutput(before.help, before.version)}\n`); + writeFileSync(afterPath, `${normalizeOutput(after.help, after.version)}\n`); + const result = spawnSync( + 'git', + [ + 'diff', + '--no-index', + '--no-color', + '--no-ext-diff', + '--unified=3', + '--', + beforePath, + afterPath, + ], + { encoding: 'utf8' }, + ); + // `git diff --no-index` exits with 1 when it successfully finds differences. + if (result.status !== 1) { + throw new Error(`Failed to diff ${tool.title} help:\n${result.stderr}`); + } + const firstHunk = result.stdout.indexOf('@@'); + if (firstHunk === -1) { + throw new Error(`Git produced no diff hunk for ${tool.title}`); + } + return [ + `--- ${tool.packageName}@${before.version}`, + `+++ ${tool.packageName}@${after.version}`, + result.stdout.slice(firstHunk).trimEnd(), + ].join('\n'); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } +} + +function truncateDiff(diff: string): string { + if (diff.length <= MAX_DIFF_LENGTH) { + return diff; + } + return `${diff.slice(0, MAX_DIFF_LENGTH)}\n... diff truncated to fit in one GitHub comment ...`; +} + +function hasHelpChanges(before: Snapshot, after: Snapshot): boolean { + return TOOLS.some((tool) => { + const previous = before.tools[tool.name]; + const current = after.tools[tool.name]; + return ( + previous.version !== current.version && + normalizeOutput(previous.help, previous.version) !== + normalizeOutput(current.help, current.version) + ); + }); +} + +function renderReport(before: Snapshot, after: Snapshot, hasChanges: boolean): string { + const lines = [ + hasChanges + ? '## ⚠️ Upstream CLI help changes detected' + : '## ✅ No upstream CLI help changes detected', + '', + 'Compared normalized `--help` output for the upstream CLIs mirrored by Vite+.', + '', + ]; + + for (const tool of TOOLS) { + const previous = before.tools[tool.name]; + const current = after.tools[tool.name]; + lines.push('
'); + + if (previous.version === current.version) { + lines.push( + `➖ ${tool.title}: no version update (${current.version})`, + '', + 'No version update was detected, so there is no CLI help diff.', + ); + } else if ( + normalizeOutput(previous.help, previous.version) === + normalizeOutput(current.help, current.version) + ) { + lines.push( + `✅ ${tool.title}: no CLI help changes (${previous.version} → ${current.version})`, + '', + 'The version was updated, but the normalized CLI help output has no differences.', + ); + } else { + lines.push( + `⚠️ ${tool.title}: CLI help changed (${previous.version} → ${current.version})`, + '', + '```diff', + truncateDiff(createUnifiedDiff(tool, previous, current)), + '```', + ); + } + + lines.push('', '
', ''); + } + + return `${lines.join('\n').trimEnd()}\n`; +} + +function generateReport( + beforePath: string, + afterPath: string, + outputPath: string, + githubOutputPath?: string, +): void { + const before = readJson(beforePath) as Snapshot; + const after = readJson(afterPath) as Snapshot; + const hasChanges = hasHelpChanges(before, after); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, renderReport(before, after, hasChanges)); + if (githubOutputPath) { + appendFileSync(githubOutputPath, `has-changes=${hasChanges}\n`); + } + console.log(`Wrote CLI help report to ${outputPath}`); +} + +const { positionals, values } = parseArgs({ + allowPositionals: true, + options: { + after: { type: 'string' }, + before: { type: 'string' }, + 'github-output': { type: 'string' }, + output: { short: 'o', type: 'string' }, + versions: { type: 'string' }, + }, +}); +const [command] = positionals; + +if (command === 'capture' && values.output) { + captureSnapshot(values.output, values.versions); +} else if (command === 'report' && values.before && values.after && values.output) { + generateReport(values.before, values.after, values.output, values['github-output']); +} else { + throw new Error( + 'Usage: cli-help-diff.ts capture --output [--versions ] | report --before --after --output [--github-output ]', + ); +} diff --git a/.github/workflows/upgrade-deps.yml b/.github/workflows/upgrade-deps.yml index 12d7c47dfe..52c2c43c34 100644 --- a/.github/workflows/upgrade-deps.yml +++ b/.github/workflows/upgrade-deps.yml @@ -29,6 +29,9 @@ jobs: - uses: oxc-project/setup-node@4c588e9266bd930b6ddc34307df0659ed511d187 # v1.3.1 + - name: Capture current upstream CLI help + run: node .github/scripts/cli-help-diff.ts capture --output "${UPGRADE_DEPS_META_DIR}/cli-help-before.json" + - name: Rustup Adds Target run: rustup target add x86_64-unknown-linux-gnu @@ -60,11 +63,25 @@ jobs: env: RELEASE_BUILD: 'true' + - name: Generate upstream CLI help diff report + id: cli-help-diff + run: | + node .github/scripts/cli-help-diff.ts capture \ + --versions "${UPGRADE_DEPS_META_DIR}/versions.json" \ + --output "${UPGRADE_DEPS_META_DIR}/cli-help-after.json" + node .github/scripts/cli-help-diff.ts report \ + --before "${UPGRADE_DEPS_META_DIR}/cli-help-before.json" \ + --after "${UPGRADE_DEPS_META_DIR}/cli-help-after.json" \ + --output "${UPGRADE_DEPS_META_DIR}/cli-help-report.md" \ + --github-output "${GITHUB_OUTPUT}" + - name: Check upgrade dependencies id: check-upgrade-dependencies timeout-minutes: 180 uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1.0.165 env: + CLI_HELP_DIFF_CHANGED: ${{ steps.cli-help-diff.outputs.has-changes }} + CLI_HELP_DIFF_REPORT: ${{ env.UPGRADE_DEPS_META_DIR }}/cli-help-report.md RELEASE_BUILD: 'true' with: claude_code_oauth_token: ${{ secrets.ANTHROPIC_API_KEY }} @@ -117,15 +134,19 @@ jobs: and rebuild. 3. If the rolldown hash changed, follow `.claude/agents/cargo-workspace-merger.md` to resync the workspace. - 4. Compare tsdown CLI options with `vp pack` and sync new/removed options per + 4. If `CLI_HELP_DIFF_CHANGED` is `true`, read the upstream help diff at + `$CLI_HELP_DIFF_REPORT`, then follow + `.claude/skills/sync-upstream-cli-help/SKILL.md` to update the mirrored + help documents. If it is `false`, do not modify help documents. + 5. Compare tsdown CLI options with `vp pack` and sync new/removed options per `.claude/skills/sync-tsdown-cli/SKILL.md`. - 5. Install the global CLI: + 6. Install the global CLI: - `pnpm bootstrap-cli:ci` - `echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH` - 6. If any Rust code or `Cargo.toml` was modified, run `cargo check + 7. If any Rust code or `Cargo.toml` was modified, run `cargo check --all-targets --all-features` and `cargo shear`; fix anything they report. - 7. Run `pnpm run lint` (requires a prior `just build`); fix any errors. - 8. Smoke-test the CLI: `vp -h`, `vp run -h`, `vp lint -h`, `vp test -h`, + 8. Run `pnpm run lint` (requires a prior `just build`); fix any errors. + 9. Smoke-test the CLI: `vp -h`, `vp run -h`, `vp lint -h`, `vp test -h`, `vp build -h`, `vp fmt -h`, `vp pack -h`. ### Generated artifacts and build diffs @@ -319,6 +340,7 @@ jobs: fi - name: Create/Update PR + id: create-pr uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: base: main @@ -328,3 +350,10 @@ jobs: token: ${{ steps.app-token.outputs.token }} body: ${{ steps.pr-content.outputs.body }} commit-message: ${{ steps.pr-content.outputs.commit-message }} + + - name: Comment upstream CLI help diff + if: steps.create-pr.outputs.pull-request-number != '' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }} + run: gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${UPGRADE_DEPS_META_DIR}/cli-help-report.md"