mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
80eef46b4e
* refactor: move adapters from src/clis/ to root clis/ for monorepo separation Separates CLI adapters from the core runtime to prepare for independent adapter distribution via postinstall fetch. Key changes: - Move src/clis/ → clis/ (adapters at repo root) - Change tsconfig rootDir from "src" to "." so tsc compiles both - Create root-level shim files (registry.ts, errors.ts, etc.) so adapter relative imports (../../registry.js) resolve correctly - Update build-manifest.ts, main.ts paths for new dist/src/ structure - Expand ensureUserCliCompatShims() to cover all adapter import targets (types, utils, logger, launcher, browser/*, download/*, pipeline/*) - Add scripts/fetch-adapters.js postinstall for ~/.opencli/clis/ sync - Update vitest.config.ts adapter test paths - Add package.json files field to exclude adapters from npm package Official adapter files are unconditionally overwritten on update; user-created files not in the manifest are preserved. * fix: add dist/clis/ and cli-manifest.json to npm files, harden fetch-adapters - Add dist/clis/ and dist/cli-manifest.json to package.json files field so built-in adapters and manifest ship with the npm package - Replace execSync with execFileSync to prevent command injection - Add version check to skip redundant adapter fetches - Track tmpRoot explicitly for reliable cleanup * fix: address review blockers — manifest-based updates, global-only fetch, first-run fallback 1. Manifest-based update strategy: - Read old manifest to identify previously-official files - Clean up files removed upstream (in old manifest but not new) - User-created files (never in any manifest) remain untouched 2. Only run fetch-adapters on global install (npm_config_global=true) or explicit OPENCLI_FETCH=1, preventing heavy side effects for local/dev installs 3. First-run fallback in discovery.ts: - ensureUserAdapters() checks for adapter-manifest.json - If missing and ~/.opencli/clis/ is empty, spawns fetch-adapters.js - Guarantees adapters are available even with --ignore-scripts * fix: remove OPENCLI_FETCH env var, use internal _OPENCLI_FIRST_RUN instead * feat: also support OPENCLI_FETCH=1 for explicit adapter fetch trigger * simplify: replace git clone with local copy from dist/clis/ Adapters already ship in the npm package (dist/clis/), so there's no need to clone from GitHub. Copy directly from the installed package: - Eliminates git, curl, tar dependencies - No network calls in postinstall - No timeout/offline issues - Version always matches the installed CLI - ~65 lines of clone/download code replaced by one cpSync loop
123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
/**
|
|
* Product Hunt shared helpers.
|
|
*/
|
|
|
|
export interface PhPost {
|
|
rank: number;
|
|
name: string;
|
|
tagline: string;
|
|
author: string;
|
|
date: string;
|
|
url: string;
|
|
}
|
|
|
|
export interface ProductHuntVoteCandidate {
|
|
text: string;
|
|
tagName?: string;
|
|
className?: string;
|
|
role?: string;
|
|
inButton?: boolean;
|
|
inReviewLink?: boolean;
|
|
}
|
|
|
|
export const PRODUCTHUNT_CATEGORY_SLUGS = [
|
|
'ai-agents',
|
|
'ai-coding-agents',
|
|
'ai-code-editors',
|
|
'ai-chatbots',
|
|
'ai-workflow-automation',
|
|
'vibe-coding',
|
|
'developer-tools',
|
|
'productivity',
|
|
'design-creative',
|
|
'marketing-sales',
|
|
'no-code-platforms',
|
|
'llms',
|
|
'finance',
|
|
'social-community',
|
|
'engineering-development',
|
|
] as const;
|
|
|
|
const UA = 'Mozilla/5.0 (compatible; opencli/1.0)';
|
|
|
|
/**
|
|
* Fetch Product Hunt Atom RSS feed.
|
|
* @param category Optional category slug (e.g. "ai", "developer-tools")
|
|
*/
|
|
export async function fetchFeed(category?: string): Promise<PhPost[]> {
|
|
const url = category
|
|
? `https://www.producthunt.com/feed?category=${encodeURIComponent(category)}`
|
|
: 'https://www.producthunt.com/feed';
|
|
|
|
const resp = await fetch(url, { headers: { 'User-Agent': UA } });
|
|
if (!resp.ok) return [];
|
|
const xml = await resp.text();
|
|
return parseFeed(xml);
|
|
}
|
|
|
|
export function parseFeed(xml: string): PhPost[] {
|
|
const posts: PhPost[] = [];
|
|
const entryRegex = /<entry>([\s\S]*?)<\/entry>/g;
|
|
let match;
|
|
let rank = 1;
|
|
|
|
while ((match = entryRegex.exec(xml))) {
|
|
const block = match[1];
|
|
|
|
const name = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
|
|
const author = block.match(/<name>([\s\S]*?)<\/name>/)?.[1]?.trim() ?? '';
|
|
const pubRaw = block.match(/<published>(.*?)<\/published>/)?.[1]?.trim() ?? '';
|
|
const date = pubRaw.slice(0, 10);
|
|
const link = block.match(/<link[^>]*href="([^"]+)"/)?.[1]?.trim() ?? '';
|
|
|
|
// Extract tagline from HTML content (first <p> text)
|
|
const contentRaw = block.match(/<content[^>]*>([\s\S]*?)<\/content>/)?.[1] ?? '';
|
|
const contentDecoded = contentRaw
|
|
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"');
|
|
const tagline = contentDecoded
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.replace(/\s*Discussion\s*\|?\s*/gi, '')
|
|
.replace(/\s*\|?\s*Link\s*$/gi, '')
|
|
.trim()
|
|
.slice(0, 120);
|
|
|
|
if (name) {
|
|
posts.push({ rank: rank++, name, tagline, author, date, url: link });
|
|
}
|
|
}
|
|
return posts;
|
|
}
|
|
|
|
export function pickVoteCount(candidates: ProductHuntVoteCandidate[]): string {
|
|
const scored = candidates
|
|
.map((candidate) => {
|
|
const text = String(candidate.text ?? '').trim();
|
|
if (!/^\d+$/.test(text)) return null;
|
|
if (candidate.inReviewLink) return null;
|
|
|
|
const value = parseInt(text, 10);
|
|
if (!Number.isFinite(value) || value <= 0) return null;
|
|
|
|
const signal = `${candidate.tagName ?? ''} ${candidate.className ?? ''} ${candidate.role ?? ''}`.toLowerCase();
|
|
let score = 0;
|
|
if (candidate.inButton) score += 4;
|
|
if (signal.includes('vote') || signal.includes('upvote')) score += 3;
|
|
if (signal.includes('button')) score += 1;
|
|
return { text, score, value };
|
|
})
|
|
.filter((candidate): candidate is { text: string; score: number; value: number } => Boolean(candidate))
|
|
.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
if (b.value !== a.value) return b.value - a.value;
|
|
return a.text.localeCompare(b.text);
|
|
});
|
|
|
|
return scored[0]?.text ?? '';
|
|
}
|
|
|
|
/** Format ISO date string to YYYY-MM-DD */
|
|
export function toDate(iso: string): string {
|
|
return iso.slice(0, 10);
|
|
}
|