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*
This commit is contained in:
Shxiao 2026-09-06 20:29:23 +09:00 committed by GitHub
parent ce1586f774
commit 4aad0584d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 189 additions and 26 deletions

View File

@ -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

View File

@ -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<void> {
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[] = [];

View File

@ -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 <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,
});
});

View File

@ -232,6 +232,52 @@ async function copySubSkills(skillsParentDir: string, force: boolean): Promise<v
}
}
/**
* Resolve every path an install touches for one platform. Shared by the real
* install (generatePlatformFiles) and the read-only preview
* (planPlatformInstallActions) so the two can never disagree.
*/
function resolveInstallPaths(
config: PlatformConfig,
targetDir: string,
isGlobal: boolean
): {
skillDir: string;
skillFilePath: string;
dataDir: string;
skillsParentDir: 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 skillFilePath = join(skillDir, config.folderStructure.filename);
// 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;
// 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)
);
return { skillDir, skillFilePath, dataDir, skillsParentDir };
}
/**
* Generate platform files for a specific AI type
* All platforms use self-contained installation with data and scripts
@ -245,15 +291,10 @@ export async function generatePlatformFiles(
): Promise<string[]> {
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<string[]> {
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<Map<string, string[]>> {
const planned = new Map<string, string[]>();
const generatedSkillFiles = new Set<string>();
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
*/

View File

@ -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);
});