mirror of
https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git
synced 2026-09-07 12:40:42 +00:00
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:
parent
ce1586f774
commit
4aad0584d9
@ -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
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
40
cli/tests/e2e/dry-run.spec.ts
Normal file
40
cli/tests/e2e/dry-run.spec.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user