"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const https = __importStar(require("https"));
const yaml = __importStar(require("js-yaml"));
const hub_auth_1 = require("../hub-auth");
const logger_1 = require("../logger");
const log = (0, logger_1.modLogger)('hub');
/**
 * CCWeb Hub proxy.
 *
 * Serves a read-only view of `zbc0315/ccweb-hub`, which stores community-shared
 * Quick Prompts (Shortcuts) and Agent Prompts as individual `.md` files in
 * `quick-prompts/` and `agent-prompts/` directories. Each file has YAML
 * frontmatter (`label`, `kind`, `author`, `tags`, `description`) and a body
 * that is the prompt itself.
 *
 * Submission is handled client-side via a pre-filled GitHub Issue URL — there
 * is no longer any write path through this server (avoids pitfalls #30: no
 * token bundled in the npm package, ever).
 *
 * Plugin Hub (`/plugins` endpoint) still proxies `plugins.json` at repo root
 * for the separate PluginDock install flow.
 */
const router = (0, express_1.Router)();
const REPO_OWNER = 'zbc0315';
const REPO_NAME = 'ccweb-hub';
const RAW_BASE = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/main`;
const API_BASE = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}`;
// ── HTTP ─────────────────────────────────────────────────────────────────────
//
// Two distinct helpers: `apiGet` talks to the GitHub v3 API (JSON, versioned
// Accept header); `rawGet` fetches raw file content with no content negotiation
// assumption.  Previously a single helper sent the API Accept header for raw
// requests too — harmless today but would break if GitHub raw ever starts to
// respect the Accept header strictly.
function request(url, accept, maxRedirects = 5) {
    return new Promise((resolve, reject) => {
        https.get(url, { headers: { 'User-Agent': 'CCWeb', 'Accept': accept } }, (res) => {
            if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
                if (maxRedirects <= 0) {
                    reject(new Error('Too many redirects'));
                    return;
                }
                const loc = res.headers.location;
                if (!loc.startsWith('https://')) {
                    reject(new Error('Redirect to non-HTTPS URL blocked'));
                    return;
                }
                request(loc, accept, maxRedirects - 1).then(resolve, reject);
                return;
            }
            let data = '';
            res.on('data', (chunk) => { data += chunk; });
            res.on('end', () => {
                if (res.statusCode && res.statusCode >= 400) {
                    reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
                }
                else {
                    resolve(data);
                }
            });
            res.on('error', reject);
        }).on('error', reject);
    });
}
const apiGet = (url) => request(url, 'application/vnd.github.v3+json');
const rawGet = (url) => request(url, '*/*');
/** POST to GitHub API with an Authorization: Bearer token.  Used for Issue
 *  creation under the caller's own PAT — the token never lives on the
 *  server beyond the duration of this call's Bearer header. */
function apiPost(urlPath, token, body) {
    const url = new URL(`${API_BASE}${urlPath}`);
    const postData = JSON.stringify(body);
    return new Promise((resolve, reject) => {
        const req = https.request({
            hostname: url.hostname,
            path: url.pathname + url.search,
            method: 'POST',
            headers: {
                'User-Agent': 'CCWeb',
                'Accept': 'application/vnd.github.v3+json',
                'Authorization': `Bearer ${token}`,
                'Content-Type': 'application/json',
                'Content-Length': Buffer.byteLength(postData),
            },
        }, (res) => {
            let data = '';
            res.on('data', (chunk) => { data += chunk; });
            res.on('end', () => {
                if (res.statusCode && res.statusCode >= 400) {
                    reject(new Error(`GitHub API ${res.statusCode}: ${data.slice(0, 300)}`));
                }
                else {
                    resolve(data);
                }
            });
            res.on('error', reject);
        });
        req.on('error', reject);
        req.write(postData);
        req.end();
    });
}
function parseMarkdown(raw) {
    // Recognize `---\n<yaml>\n---\n<body>`; anything else → whole file is body.
    const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
    if (!match)
        return { meta: {}, body: raw };
    let meta = {};
    try {
        const loaded = yaml.load(match[1], { schema: yaml.FAILSAFE_SCHEMA });
        if (loaded && typeof loaded === 'object')
            meta = loaded;
    }
    catch {
        // YAML malformed — treat as no frontmatter rather than crashing the hub
    }
    return { meta, body: match[2].trimStart() };
}
function coerceTags(raw) {
    if (Array.isArray(raw)) {
        const out = raw.filter((x) => typeof x === 'string');
        return out.length ? out : undefined;
    }
    if (typeof raw === 'string') {
        // Single string → split on comma for forgiving input
        const split = raw.split(',').map((s) => s.trim()).filter(Boolean);
        return split.length ? split : undefined;
    }
    return undefined;
}
// ── Cache ────────────────────────────────────────────────────────────────────
let cachedItems = null;
let cacheTime = 0;
const CACHE_TTL = 5 * 60 * 1000;
async function listDir(dir) {
    try {
        const raw = await apiGet(`${API_BASE}/contents/${dir}`);
        const parsed = JSON.parse(raw);
        return Array.isArray(parsed)
            ? parsed.filter((f) => f && f.type === 'file' && f.name.endsWith('.md'))
            : [];
    }
    catch {
        return []; // 404 = directory empty / absent — not an error
    }
}
async function fetchKind(kind) {
    const dir = kind === 'quick-prompt' ? 'quick-prompts' : 'agent-prompts';
    const files = await listDir(dir);
    if (files.length === 0)
        return [];
    // Raw fetches are on raw.githubusercontent.com (not counted against the
    // 60/hr GitHub API anon limit), so parallelizing is safe.
    const results = await Promise.allSettled(files.map(async (file) => {
        if (!file.download_url)
            return null;
        try {
            const content = await rawGet(file.download_url);
            const { meta, body } = parseMarkdown(content);
            const basename = file.name.replace(/\.md$/, '');
            const label = typeof meta.label === 'string' && meta.label ? meta.label : basename;
            return {
                id: `${kind}/${basename}`,
                kind,
                label,
                body: body.trim(),
                author: typeof meta.author === 'string' ? meta.author : undefined,
                tags: coerceTags(meta.tags),
                description: typeof meta.description === 'string' ? meta.description : undefined,
                file: `${dir}/${file.name}`,
            };
        }
        catch {
            return null; // One bad file can't poison the whole kind
        }
    }));
    return results
        .map((r) => (r.status === 'fulfilled' ? r.value : null))
        .filter((x) => x !== null);
}
async function fetchAllItems(forceRefresh = false) {
    const now = Date.now();
    if (!forceRefresh && cachedItems && (now - cacheTime) < CACHE_TTL) {
        return cachedItems;
    }
    // Resolve each kind independently — a transient failure on one directory
    // should not make the whole hub appear broken.
    const [quickRes, agentRes] = await Promise.allSettled([
        fetchKind('quick-prompt'),
        fetchKind('agent-prompt'),
    ]);
    const quick = quickRes.status === 'fulfilled' ? quickRes.value : [];
    const agent = agentRes.status === 'fulfilled' ? agentRes.value : [];
    if (quickRes.status === 'rejected')
        log.warn({ err: quickRes.reason }, 'hub quick-prompts fetch failed');
    if (agentRes.status === 'rejected')
        log.warn({ err: agentRes.reason }, 'hub agent-prompts fetch failed');
    cachedItems = [...quick, ...agent];
    cacheTime = now;
    return cachedItems;
}
// ── Routes ───────────────────────────────────────────────────────────────────
// GET /items  — unified list of Quick Prompts + Agent Prompts in ccweb-hub
router.get('/items', async (_req, res) => {
    try {
        const items = await fetchAllItems();
        res.json(items);
    }
    catch (err) {
        res.status(502).json({ error: 'Failed to fetch ccweb-hub', detail: err.message });
    }
});
// GET /skills  — legacy alias for pre-hub clients.  Returns the Quick Prompts
// subset in the old SkillHubItem shape.  `createdAt` is set to current ISO so
// clients that sort by date don't choke on empty strings.
router.get('/skills', async (_req, res) => {
    try {
        const items = await fetchAllItems();
        const iso = new Date().toISOString();
        const legacy = items
            .filter((i) => i.kind === 'quick-prompt')
            .map((i) => ({
            id: i.id,
            label: i.label,
            command: i.body,
            description: i.description ?? '',
            author: i.author ?? 'anonymous',
            tags: i.tags ?? [],
            downloads: 0,
            createdAt: iso,
        }));
        res.json(legacy);
    }
    catch (err) {
        res.status(502).json({ error: 'Failed to fetch ccweb-hub', detail: err.message });
    }
});
// ── Hub auth (per-user GitHub PAT for one-click Issue submit) ──────────────
function requireUsername(req, res) {
    const u = req.user?.username;
    if (!u) {
        res.status(401).json({ error: 'Unauthenticated' });
        return null;
    }
    return u;
}
// GET /auth  → { configured, needsReset }
router.get('/auth', (req, res) => {
    const user = requireUsername(req, res);
    if (!user)
        return;
    res.json((0, hub_auth_1.getHubTokenStatus)(user));
});
// PUT /auth  body { token: string }
router.put('/auth', (req, res) => {
    const user = requireUsername(req, res);
    if (!user)
        return;
    const token = typeof req.body?.token === 'string'
        ? req.body.token.trim()
        : '';
    if (!token) {
        res.status(400).json({ error: 'token required' });
        return;
    }
    // Light sanity check — GitHub PATs start with ghp_ / github_pat_ etc. and
    // are ≥40 chars.  Don't block unusual forms outright; GitHub will reject.
    // Printable ASCII only — GitHub PATs live in this range; rejecting
    // zero-width spaces / BOM / other invisibles prevents the common
    // copy-paste-from-formatted-source bug that would otherwise store a
    // token GitHub will silently 401 on forever.
    if (token.length < 20 || token.length > 200 || !/^[\x21-\x7E]+$/.test(token)) {
        res.status(400).json({ error: 'token contains whitespace / non-printable characters, or has an unexpected length' });
        return;
    }
    (0, hub_auth_1.setHubToken)(user, token);
    res.json({ ok: true, ...(0, hub_auth_1.getHubTokenStatus)(user) });
});
// DELETE /auth  → wipe stored token
router.delete('/auth', (req, res) => {
    const user = requireUsername(req, res);
    if (!user)
        return;
    (0, hub_auth_1.clearHubToken)(user);
    res.json({ ok: true });
});
// POST /submit  body { kind, label, body, description?, tags?, author? }
// Posts an Issue to zbc0315/ccweb-hub using the caller's stored PAT.
router.post('/submit', async (req, res) => {
    const user = requireUsername(req, res);
    if (!user)
        return;
    const token = (0, hub_auth_1.getHubToken)(user);
    if (!token) {
        res.status(412).json({ error: 'Hub token not configured — set one in Settings → CCWeb Hub' });
        return;
    }
    const b = (req.body ?? {});
    if (b.kind !== 'quick-prompt' && b.kind !== 'agent-prompt') {
        res.status(400).json({ error: 'kind must be "quick-prompt" or "agent-prompt"' });
        return;
    }
    if (!b.label || typeof b.label !== 'string' || !b.body || typeof b.body !== 'string') {
        res.status(400).json({ error: 'label and body are required strings' });
        return;
    }
    // Newlines in GitHub Issue title get silently truncated; reject early so
    // the submit flow doesn't produce a half-titled issue.  Same-ish for the
    // optional fields — keep them single-line, reasonable length.
    if (/[\r\n]/.test(b.label) || b.label.length > 200) {
        res.status(400).json({ error: 'label must be single-line and ≤ 200 chars' });
        return;
    }
    if (typeof b.description === 'string' && (/[\r\n]/.test(b.description) || b.description.length > 300)) {
        res.status(400).json({ error: 'description must be single-line and ≤ 300 chars' });
        return;
    }
    if (typeof b.author === 'string' && (/[\r\n\s]/.test(b.author) || b.author.length > 64)) {
        res.status(400).json({ error: 'author must be a single token (no whitespace), ≤ 64 chars' });
        return;
    }
    const kindLabel = b.kind === 'quick-prompt' ? 'Quick Prompt' : 'Agent Prompt';
    // Code-point-safe title slice to avoid splitting surrogate pairs
    const titleRaw = `[${kindLabel}] ${b.label}`;
    const title = Array.from(titleRaw).slice(0, 240).join('');
    // Dynamic fence — user content containing backticks can't close the block
    const backtickRuns = b.body.match(/`+/g) ?? [];
    const maxRun = backtickRuns.reduce((m, r) => Math.max(m, r.length), 0);
    const fence = '`'.repeat(Math.max(3, maxRun + 1));
    const author = typeof b.author === 'string' && b.author.trim() ? b.author.trim() : 'anonymous';
    const tagList = Array.isArray(b.tags)
        ? b.tags.filter((t) => typeof t === 'string')
        : [];
    const bodyMd = `<!-- Submitted via ccweb. Review, then commit to ${b.kind}s/ as a .md file. -->\n\n` +
        '```yaml\n' +
        '---\n' +
        `label: ${JSON.stringify(b.label)}\n` +
        `kind: ${JSON.stringify(b.kind)}\n` +
        `author: ${JSON.stringify(author)}\n` +
        (tagList.length ? `tags: ${JSON.stringify(tagList)}\n` : '') +
        (b.description && b.description.trim() ? `description: ${JSON.stringify(b.description.trim())}\n` : '') +
        '---\n' +
        '```\n\n' +
        '## Body\n\n' +
        `${fence}\n` +
        b.body +
        `\n${fence}\n`;
    try {
        const raw = await apiPost('/issues', token, {
            title,
            body: bodyMd,
            labels: [b.kind],
        });
        const parsed = JSON.parse(raw);
        res.json({ ok: true, issueNumber: parsed.number, issueUrl: parsed.html_url });
    }
    catch (err) {
        const msg = err.message;
        // Surface GitHub 401 / 403 distinctly so the UI can prompt for a fresh token
        const is401 = /GitHub API 401/.test(msg);
        const is403 = /GitHub API 403/.test(msg);
        res.status(is401 || is403 ? 401 : 502).json({
            error: is401
                ? 'GitHub token invalid or expired — update in Settings'
                : is403
                    ? 'GitHub rejected the request (token scope too narrow, or rate-limited)'
                    : `Failed to submit: ${msg}`,
        });
    }
});
// ── Plugin Hub (unchanged — proxies plugins.json at hub repo root) ─────────
let _pluginCache = null;
let _pluginCacheTime = 0;
const PLUGIN_CACHE_TTL = 5 * 60000;
router.get('/plugins', async (_req, res) => {
    const now = Date.now();
    if (_pluginCache && now - _pluginCacheTime < PLUGIN_CACHE_TTL) {
        return res.json(_pluginCache);
    }
    try {
        const data = await rawGet(`${RAW_BASE}/plugins.json`);
        const plugins = JSON.parse(data);
        _pluginCache = Array.isArray(plugins) ? plugins : [];
        _pluginCacheTime = now;
        res.json(_pluginCache);
    }
    catch {
        res.json(_pluginCache ?? []);
    }
});
exports.default = router;
//# sourceMappingURL=skillhub.js.map