Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions cli/src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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));
Expand All @@ -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<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}

async function resolveExtractRoot(extractDir: string): Promise<string> {
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<string> {
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<string[]> {
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<void> {
Expand Down Expand Up @@ -56,7 +120,15 @@ export async function initCommand(options: InitOptions): Promise<void> {

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!');

Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
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');
Expand Down
2 changes: 2 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ program
.description('Install UI/UX Pro Max skill to current project')
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
.option('-f, --force', 'Overwrite existing files')
.option('--version <tag>', 'Install a specific version')
.action(async (options) => {
if (options.ai && !AI_TYPES.includes(options.ai)) {
console.error(`Invalid AI type: ${options.ai}`);
Expand All @@ -28,6 +29,7 @@ program
await initCommand({
ai: options.ai as AIType | undefined,
force: options.force,
version: options.version,
});
});

Expand Down
18 changes: 18 additions & 0 deletions cli/src/utils/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ export async function getLatestRelease(): Promise<Release> {
return response.json();
}

export async function getReleaseByTag(tag: string): Promise<Release> {
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<void> {
const response = await fetch(url, {
headers: {
Expand Down