Files
jackwener__opencli/clis/producthunt/utils.ts
jakevin 80eef46b4e refactor: monorepo adapter separation (clis/ at root) (#782)
* 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
2026-04-05 01:46:36 +08:00

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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/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);
}