From 4aad0584d92131626b16d4ff4d77f0455385013c Mon Sep 17 00:00:00 2001 From: Shxiao Date: Sun, 6 Sep 2026 20:29:23 +0900 Subject: [PATCH] feat(cli): add --dry-run to init to preview install actions without writing (#489) feat(cli): add --dry-run to init to preview install actions without writing (#489) Closes #291 - Adds resolveInstallPaths helper shared by real install and dry-run preview - New planPlatformInstallActions / planAllPlatformInstallActions for read-only preview - 3 e2e tests verify output content, no-write guarantee, and all-platforms coverage - README docs included *Approved and merged by github-maintain cron* --- README.md | 2 + cli/src/commands/init.ts | 31 +++++++- cli/src/index.ts | 2 + cli/src/utils/template.ts | 140 ++++++++++++++++++++++++++++------ cli/tests/e2e/dry-run.spec.ts | 40 ++++++++++ 5 files changed, 189 insertions(+), 26 deletions(-) create mode 100644 cli/tests/e2e/dry-run.spec.ts diff --git a/README.md b/README.md index 017e030..a936893 100755 --- a/README.md +++ b/README.md @@ -280,6 +280,7 @@ uipro versions # List available versions uipro update # Refresh skill files from installed CLI package uipro update --global # Refresh global skill files from installed CLI package uipro init --offline # Compatibility flag; installs bundled templates +uipro init --dry-run # Preview install actions without writing files uipro uninstall # Remove skill (auto-detect platform) uipro uninstall --ai claude # Remove specific platform uipro uninstall --global # Remove from global install @@ -483,6 +484,7 @@ npm run typecheck # `npm run build` uses Bun when available and falls back to TypeScript compiler output after `npm ci`. npm run build node dist/index.js init --ai claude --offline # Test in a temp folder +node dist/index.js init --ai claude --dry-run # Preview install actions (no writes) # 6. Create PR (never push directly to main) git checkout -b feat/your-feature diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index e3ae27e..9f0a59a 100755 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -7,7 +7,12 @@ import prompts from 'prompts'; import type { AIType } from '../types/index.js'; import { AI_TYPES } from '../types/index.js'; import { copyFolders, installFromZip, createTempDir, cleanup } from '../utils/extract.js'; -import { generatePlatformFiles, generateAllPlatformFiles } from '../utils/template.js'; +import { + generatePlatformFiles, + generateAllPlatformFiles, + planPlatformInstallActions, + planAllPlatformInstallActions, +} from '../utils/template.js'; import { detectAIType, getAITypeDescription } from '../utils/detect.js'; import { logger } from '../utils/logger.js'; import { @@ -34,6 +39,7 @@ interface InitOptions { offline?: boolean; legacy?: boolean; // Use old ZIP-based install global?: boolean; // Install to home directory (global mode) + dryRun?: boolean; // Preview install actions without writing files token?: string; // GitHub PAT for higher API rate limits } @@ -160,6 +166,29 @@ export async function initCommand(options: InitOptions): Promise { const modeLabel = isGlobal ? ' (global)' : ''; logger.info(`Installing for: ${chalk.cyan(getAITypeDescription(aiType))}${modeLabel}`); + // Dry run: print what the install would do, write nothing, exit 0 + if (options.dryRun) { + const cwd = process.cwd(); + console.log(); + logger.info('Planned install actions (nothing will be written):'); + + if (aiType === 'all') { + const planned = await planAllPlatformInstallActions(cwd, isGlobal, options.force); + planned.forEach((actions, type) => { + console.log(); + console.log(chalk.bold(getAITypeDescription(type as AIType))); + actions.forEach(action => console.log(` ${chalk.cyan('·')} ${action}`)); + }); + } else { + const actions = await planPlatformInstallActions(cwd, aiType, isGlobal, options.force); + actions.forEach(action => console.log(` ${chalk.cyan('·')} ${action}`)); + } + + console.log(); + logger.success('Dry run complete — no files were written.'); + return; + } + const spinner = ora('Installing files...').start(); const cwd = process.cwd(); let copiedFolders: string[] = []; diff --git a/cli/src/index.ts b/cli/src/index.ts index e17fd04..afa4379 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -30,6 +30,7 @@ program .option('-o, --offline', 'Compatibility flag; template installs use bundled assets') .option('-g, --global', 'Install globally to home directory (~/) instead of current project') .option('-t, --token ', 'GitHub Personal Access Token for higher API rate limits') + .option('--dry-run', 'Preview install actions without writing files') .action(async (options) => { if (options.ai && !AI_TYPES.includes(options.ai)) { console.error(`Invalid AI type: ${options.ai}`); @@ -41,6 +42,7 @@ program force: options.force, offline: options.offline, global: options.global, + dryRun: options.dryRun, token: options.token, }); }); diff --git a/cli/src/utils/template.ts b/cli/src/utils/template.ts index c6de4b3..df0cfe7 100755 --- a/cli/src/utils/template.ts +++ b/cli/src/utils/template.ts @@ -232,6 +232,52 @@ async function copySubSkills(skillsParentDir: string, force: boolean): Promise { const config = await loadPlatformConfig(aiType); const createdFolders: string[] = []; - - // For global install, target the user's home directory - const effectiveDir = isGlobal ? homedir() : targetDir; - - // Determine full skill directory path - const skillDir = join( - effectiveDir, - config.folderStructure.root, - config.folderStructure.skillPath + const { skillDir, skillFilePath, dataDir, skillsParentDir } = resolveInstallPaths( + config, + targetDir, + isGlobal ); // Create directory structure @@ -261,7 +302,6 @@ export async function generatePlatformFiles( // Render and write skill file (pass isGlobal to adjust paths) const skillContent = await renderSkillFile(config, isGlobal); - const skillFilePath = join(skillDir, config.folderStructure.filename); const fileAlreadyExists = await exists(skillFilePath); if (fileAlreadyExists && !force) { @@ -272,30 +312,80 @@ export async function generatePlatformFiles( await writeFile(skillFilePath, skillContent, 'utf-8'); createdFolders.push(config.folderStructure.root); - // Copy data and scripts into the data directory (may differ from skill file location) - const dataDir = config.folderStructure.dataPath - ? join(effectiveDir, config.folderStructure.root, config.folderStructure.dataPath) - : skillDir; await mkdir(dataDir, { recursive: true }); await copyDataAndScripts(dataDir); // Install the sibling sub-skills (banner-design, brand, design, ...) next to - // the orchestrator so all 7 skills are delivered. The skills parent is the - // orchestrator's parent dir (skills/ for most platforms, prompts/ for - // copilot, steering/ for kiro) — derived, not hardcoded. For platforms with - // a separate dataPath (copilot), the orchestrator's data dir is the anchor. - const skillsParentDir = join( - effectiveDir, - config.folderStructure.root, - config.folderStructure.dataPath - ? dirname(config.folderStructure.dataPath) - : dirname(config.folderStructure.skillPath) - ); + // the orchestrator so all 7 skills are delivered. await copySubSkills(skillsParentDir, force); return createdFolders; } +/** + * Preview the actions generatePlatformFiles would take for one AI type, + * without writing anything. Used by `uipro init --dry-run`. + */ +export async function planPlatformInstallActions( + targetDir: string, + aiType: string, + isGlobal = false, + force = false +): Promise { + const config = await loadPlatformConfig(aiType); + const { skillFilePath, dataDir, skillsParentDir } = resolveInstallPaths(config, targetDir, isGlobal); + + const actions: string[] = []; + const skillFileExists = await exists(skillFilePath); + actions.push( + skillFileExists && !force + ? `Would skip (exists, use --force): ${skillFilePath}` + : `Would write: ${skillFilePath}` + ); + actions.push(`Would copy data + scripts: ${dataDir}`); + + const subSkills = await listBundledSubSkills(); + if (subSkills.length > 0) { + actions.push( + `Would copy ${subSkills.length} sub-skills (${subSkills.join(', ')}): ${skillsParentDir}` + ); + } + + return actions; +} + +/** + * Preview the actions generateAllPlatformFiles would take, grouped per unique + * platform layout, without writing anything. Used by `uipro init --dry-run`. + */ +export async function planAllPlatformInstallActions( + targetDir: string, + isGlobal = false, + force = false +): Promise> { + const planned = new Map(); + const generatedSkillFiles = new Set(); + + for (const aiType of Object.keys(AI_TO_PLATFORM)) { + try { + const config = await loadPlatformConfig(aiType); + const skillFile = join( + config.folderStructure.root, + config.folderStructure.skillPath, + config.folderStructure.filename + ); + if (generatedSkillFiles.has(skillFile)) continue; + generatedSkillFiles.add(skillFile); + + planned.set(aiType, await planPlatformInstallActions(targetDir, aiType, isGlobal, force)); + } catch { + // Skip if config doesn't exist + } + } + + return planned; +} + /** * Generate files for all AI types */ diff --git a/cli/tests/e2e/dry-run.spec.ts b/cli/tests/e2e/dry-run.spec.ts new file mode 100644 index 0000000..728e322 --- /dev/null +++ b/cli/tests/e2e/dry-run.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { mkdtemp, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + planAllPlatformInstallActions, + planPlatformInstallActions, +} from '../../src/utils/template.js'; + +test('dry-run plan lists the install actions for one platform', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'uipro-dry-run-')); + + const actions = await planPlatformInstallActions(scratch, 'claude'); + + const joined = actions.join('\n'); + expect(joined).toContain( + join(scratch, '.claude', 'skills', 'ui-ux-pro-max', 'SKILL.md') + ); + expect(joined).toContain('Would copy data + scripts:'); + expect(joined).toContain('Would copy 6 sub-skills ('); +}); + +test('dry-run plan writes nothing to the target directory', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'uipro-dry-run-write-')); + const before = await readdir(scratch); + + await planPlatformInstallActions(scratch, 'claude'); + await planAllPlatformInstallActions(scratch); + + expect(await readdir(scratch)).toEqual(before); +}); + +test('dry-run plan for all platforms covers every unique layout', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'uipro-dry-run-all-')); + + const planned = await planAllPlatformInstallActions(scratch); + + expect(planned.size).toBeGreaterThan(1); + expect(planned.get('claude')!.length).toBeGreaterThan(0); +});