diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 4bf5da8..52127e9 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -1,12 +1,15 @@ +import { access, mkdtemp, mkdir, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import chalk from 'chalk'; import ora from 'ora'; import prompts from 'prompts'; -import type { AIType } from '../types/index.js'; +import type { AIType, Release } from '../types/index.js'; import { AI_TYPES } from '../types/index.js'; -import { copyFolders } from '../utils/extract.js'; +import { cleanup, copyFolders, extractZip } from '../utils/extract.js'; import { detectAIType, getAITypeDescription } from '../utils/detect.js'; +import { downloadRelease, getAssetUrl, getReleaseByTag } from '../utils/github.js'; import { logger } from '../utils/logger.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -16,6 +19,67 @@ const ASSETS_DIR = join(__dirname, '..', 'assets'); interface InitOptions { ai?: AIType; force?: boolean; + version?: string; + release?: Release; +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function resolveExtractRoot(extractDir: string): Promise { + const entries = await readdir(extractDir, { withFileTypes: true }); + const dirs = entries.filter(entry => entry.isDirectory()); + const files = entries.filter(entry => !entry.isDirectory()); + + if (dirs.length === 1 && files.length === 0) { + return join(extractDir, dirs[0].name); + } + + return extractDir; +} + +async function findAssetsDir(extractDir: string): Promise { + const rootDir = await resolveExtractRoot(extractDir); + const candidates = [ + join(rootDir, 'cli', 'assets'), + join(rootDir, 'assets'), + ]; + + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return candidate; + } + } + + throw new Error('Release assets not found. Expected "cli/assets" or "assets" in the release zip.'); +} + +async function installFromRelease(release: Release, aiType: AIType): Promise { + const assetUrl = getAssetUrl(release); + if (!assetUrl) { + throw new Error(`No .zip asset found for release ${release.tag_name}`); + } + + const tempDir = await mkdtemp(join(tmpdir(), 'uipro-')); + const safeTag = release.tag_name.replace(/[^a-zA-Z0-9._-]/g, '-'); + const zipPath = join(tempDir, `${safeTag}.zip`); + const extractDir = join(tempDir, 'extract'); + + try { + await mkdir(extractDir, { recursive: true }); + await downloadRelease(assetUrl, zipPath); + await extractZip(zipPath, extractDir); + const assetsDir = await findAssetsDir(extractDir); + return copyFolders(assetsDir, process.cwd(), aiType); + } finally { + await cleanup(tempDir); + } } export async function initCommand(options: InitOptions): Promise { @@ -56,7 +120,15 @@ export async function initCommand(options: InitOptions): Promise { try { const cwd = process.cwd(); - const copiedFolders = await copyFolders(ASSETS_DIR, cwd, aiType); + let copiedFolders: string[]; + + if (options.release || options.version) { + const release = options.release ?? await getReleaseByTag(options.version ?? ''); + spinner.text = `Installing ${release.tag_name}...`; + copiedFolders = await installFromRelease(release, aiType); + } else { + copiedFolders = await copyFolders(ASSETS_DIR, cwd, aiType); + } spinner.succeed('Installation complete!'); diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index 39ea09b..bbd063d 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -19,12 +19,13 @@ export async function updateCommand(options: UpdateOptions): Promise { spinner.succeed(`Latest version: ${chalk.cyan(release.tag_name)}`); console.log(); - logger.info('Running update (same as init with latest version)...'); + logger.info('Installing latest release...'); console.log(); await initCommand({ ai: options.ai, force: true, + release, }); } catch (error) { spinner.fail('Update check failed'); diff --git a/cli/src/index.ts b/cli/src/index.ts index a243af0..b174949 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -19,6 +19,7 @@ program .description('Install UI/UX Pro Max skill to current project') .option('-a, --ai ', `AI assistant type (${AI_TYPES.join(', ')})`) .option('-f, --force', 'Overwrite existing files') + .option('--version ', 'Install a specific version') .action(async (options) => { if (options.ai && !AI_TYPES.includes(options.ai)) { console.error(`Invalid AI type: ${options.ai}`); @@ -28,6 +29,7 @@ program await initCommand({ ai: options.ai as AIType | undefined, force: options.force, + version: options.version, }); }); diff --git a/cli/src/utils/github.ts b/cli/src/utils/github.ts index c56fb18..d83ae6d 100644 --- a/cli/src/utils/github.ts +++ b/cli/src/utils/github.ts @@ -39,6 +39,24 @@ export async function getLatestRelease(): Promise { return response.json(); } +export async function getReleaseByTag(tag: string): Promise { + const encodedTag = encodeURIComponent(tag); + const url = `${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${encodedTag}`; + + const response = await fetch(url, { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'uipro-cli', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch release ${tag}: ${response.statusText}`); + } + + return response.json(); +} + export async function downloadRelease(url: string, dest: string): Promise { const response = await fetch(url, { headers: {