#!/usr/bin/env node import { existsSync, mkdirSync, readFileSync, writeFileSync, watch } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path"; const DEFAULT_API_BASE = "http://127.0.0.1:3001/api"; const DEFAULT_APP_BASE = "http://127.0.0.1:5173"; const SIDECAR_NAME = ".htmlslide-sync.json"; const CONFIG_DIR = join(homedir(), ".htmlslide-agent"); const CONFIG_PATH = join(CONFIG_DIR, "config.json"); const parsed = parseArgs(process.argv.slice(2)); const command = parsed.command || "push"; if (command === "login") { await login(parsed).catch(fail); } else if (command === "list") { await listProjects(parsed).catch(fail); } else if (command === "pull") { await pullProject(parsed).catch(fail); } else if (command === "push") { await pushProject(parsed).catch(fail); } else { usage(`Unknown command: ${command}`); } async function login(args) { const config = readConfig(); const apiBase = stripTrailingSlash(args.apiBase || process.env.HTMLSLIDE_API_BASE || config.apiBase || DEFAULT_API_BASE); const appBase = stripTrailingSlash(args.appBase || process.env.HTMLSLIDE_APP_BASE || config.appBase || DEFAULT_APP_BASE); const deviceName = args.deviceName || `${process.env.COMPUTERNAME || process.env.HOSTNAME || "Local"} Agent`; const start = await apiJson(`${apiBase}/agent/oauth/start`, { method: "POST", body: { deviceName } }); const url = `${appBase}${start.verificationUriComplete || `/agent/authorize?code=${encodeURIComponent(start.userCode)}`}`; console.log("Open this URL in HTMLSlide while logged in:"); console.log(url); console.log(`Authorization code: ${start.userCode}`); console.log("Waiting for approval..."); const deadline = Date.now() + start.expiresIn * 1000; while (Date.now() < deadline) { await sleep((start.interval || 2) * 1000); const tokenRes = await apiJson(`${apiBase}/agent/oauth/token`, { method: "POST", body: { deviceCode: start.deviceCode }, soft: true }); if (tokenRes.ok === false) continue; const nextConfig = { ...config, apiBase, appBase, accessToken: tokenRes.accessToken, userId: tokenRes.userId, tokenType: tokenRes.tokenType || "Bearer", updatedAt: new Date().toISOString() }; writeConfig(nextConfig); console.log(`HTMLSlide agent login complete. Bound user: ${tokenRes.userId}`); return; } throw new Error("Authorization timed out. Run login again."); } async function listProjects(args) { const client = authedClient(args); const projects = await client.get("/projects"); projects.forEach((project) => { console.log(`${project.id}\t${project.kind || "slides"}\t${project.title}\t${project.updatedAt || ""}`); }); } async function pullProject(args) { const outPath = args.out ? resolve(args.out) : resolve("ppt", "index.html"); const client = authedClient(args); const project = await resolveProject(client, args); if (project.kind !== "html" || !project.html) { throw new Error(`Project "${project.title}" is not an HTML PPT project.`); } const outDir = dirname(outPath); if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); writeFileSync(outPath, project.html, "utf8"); writeSidecar(join(outDir, SIDECAR_NAME), { apiBase: client.apiBase, projectId: project.id, title: project.title, originalFileName: project.originalFileName || "index.html", pulledAt: new Date().toISOString() }); console.log(`[htmlslide-sync] pulled "${project.title}" -> ${outPath}`); } async function pushProject(args) { const htmlFile = args.file ? resolve(args.file) : null; if (!htmlFile) usage("Missing HTML file. Example: node sync-htmlslide.mjs push path/to/ppt/index.html"); if (!existsSync(htmlFile)) throw new Error(`HTML file not found: ${htmlFile}`); const deckDir = dirname(htmlFile); const sidecarPath = join(deckDir, SIDECAR_NAME); let sidecar = readJson(sidecarPath, {}); const client = authedClient({ ...sidecar, ...args }); const watchMode = Boolean(args.watch); let syncing = false; let pending = false; async function syncOnce() { if (syncing) { pending = true; return; } syncing = true; try { const { html, assets, title } = buildHtmlPayload(htmlFile, args.title || sidecar.title); const result = await client.post("/agent/projects/html", { projectId: args.projectId || sidecar.projectId, title, html, assets, originalFileName: basename(htmlFile), source: "guizang-ppt-skill 同步", author: "本地 Agent" }); sidecar = { ...sidecar, apiBase: client.apiBase, projectId: result.project.id, title: result.project.title, originalFileName: basename(htmlFile), updatedAt: result.project.updatedAt }; writeSidecar(sidecarPath, sidecar); console.log(`[htmlslide-sync] pushed "${result.project.title}" -> ${result.project.id}`); } finally { syncing = false; if (pending) { pending = false; await syncOnce(); } } } await syncOnce(); if (watchMode) { console.log(`[htmlslide-sync] watching ${deckDir}`); let timer = null; watch(deckDir, { recursive: true }, (_event, changed) => { if (!changed || String(changed).endsWith(SIDECAR_NAME)) return; if (timer) clearTimeout(timer); timer = setTimeout(() => { syncOnce().catch((err) => console.error(`[htmlslide-sync] ${err.message}`)); }, 450); }); } } async function resolveProject(client, args) { if (args.projectId) return client.get(`/projects/${encodeURIComponent(args.projectId)}`); if (!args.title) throw new Error("Pass --project-id or --title ."); const projects = await client.get("/projects"); const exact = projects.filter((project) => project.title === args.title); if (exact.length === 1) return exact[0]; if (exact.length > 1) throw new Error(`Multiple projects named "${args.title}". Use --project-id.`); const fuzzy = projects.filter((project) => project.title.includes(args.title)); if (fuzzy.length === 1) return fuzzy[0]; if (fuzzy.length > 1) { throw new Error(`Multiple projects match "${args.title}": ${fuzzy.map((project) => project.title).join(", ")}. Use --project-id.`); } throw new Error(`Project not found: ${args.title}`); } function authedClient(args) { const config = readConfig(); const apiBase = stripTrailingSlash(args.apiBase || process.env.HTMLSLIDE_API_BASE || config.apiBase || DEFAULT_API_BASE); const accessToken = args.token || process.env.HTMLSLIDE_AGENT_TOKEN || config.accessToken; if (!accessToken) { throw new Error("Not logged in. Run: node scripts/sync-htmlslide.mjs login"); } return { apiBase, get: (path) => apiJson(`${apiBase}${path}`, { token: accessToken }), post: (path, body) => apiJson(`${apiBase}${path}`, { method: "POST", token: accessToken, body }) }; } function buildHtmlPayload(htmlPath, titleOverride) { const rawHtml = readFileSync(htmlPath, "utf8"); const assets = {}; const html = inlineLocalAssets(rawHtml, htmlPath, assets); return { html, assets, title: titleOverride || titleFromHtml(rawHtml) || basename(htmlPath).replace(/\.html?$/i, "") }; } function inlineLocalAssets(html, htmlPath, assets) { const root = dirname(htmlPath); let next = html; next = next.replace(/\b(src|href)=["']([^"']+)["']/gi, (match, attr, ref) => { const dataUrl = localRefToDataUrl(ref, root, assets); return dataUrl ? `${attr}="${dataUrl}"` : match; }); next = next.replace(/url\((["']?)([^"')]+)\1\)/gi, (match, _quote, ref) => { const dataUrl = localRefToDataUrl(ref, root, assets); return dataUrl ? `url("${dataUrl}")` : match; }); return next; } function localRefToDataUrl(ref, root, assets) { if (!ref || ref.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith("//")) return ""; const cleanRef = decodeURIComponent(ref.split("#")[0].split("?")[0]); const filePath = resolve(root, cleanRef); if (!isInside(root, filePath) || !existsSync(filePath)) return ""; const ext = extname(filePath).toLowerCase(); if (!isEmbeddable(ext)) return ""; const rel = normalize(relative(root, filePath)).split(sep).join("/"); const mime = mimeFromExt(ext); const base64 = readFileSync(filePath).toString("base64"); const dataUrl = `data:${mime};base64,${base64}`; assets[rel] = dataUrl; return dataUrl; } async function apiJson(url, options = {}) { const res = await fetch(url, { method: options.method || "GET", headers: { "content-type": "application/json", ...(options.token ? { authorization: `Bearer ${options.token}` } : {}) }, body: options.body ? JSON.stringify(options.body) : undefined }); const text = await res.text(); const body = text ? JSON.parse(text) : {}; if (!res.ok) { if (options.soft) return { ok: false, status: res.status, ...body }; throw new Error(body.error || `HTMLSlide API ${res.status}`); } return body; } function readConfig() { return readJson(CONFIG_PATH, {}); } function writeConfig(config) { if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } function readJson(path, fallback) { if (!existsSync(path)) return fallback; try { return JSON.parse(readFileSync(path, "utf8")); } catch { return fallback; } } function writeSidecar(path, data) { writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8"); } function isInside(root, filePath) { const rel = relative(root, filePath); return rel && !rel.startsWith("..") && !isAbsolute(rel); } function isEmbeddable(ext) { return [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif", ".ico", ".woff", ".woff2", ".ttf", ".otf"].includes(ext); } function mimeFromExt(ext) { return { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml", ".avif": "image/avif", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf" }[ext] || "application/octet-stream"; } function titleFromHtml(html) { const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i); return match ? decodeHtml(match[1].replace(/\s+/g, " ").trim()) : ""; } function decodeHtml(value) { return value .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'"); } function stripTrailingSlash(value) { return String(value || "").replace(/\/+$/, ""); } function sleep(ms) { return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); } function parseArgs(argv) { const result = {}; const commands = new Set(["login", "list", "pull", "push"]); if (commands.has(argv[0])) result.command = argv.shift(); for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--watch") result.watch = true; else if (arg === "--api-base") result.apiBase = argv[++i]; else if (arg === "--app-base") result.appBase = argv[++i]; else if (arg === "--device-name") result.deviceName = argv[++i]; else if (arg === "--token") result.token = argv[++i]; else if (arg === "--title") result.title = argv[++i]; else if (arg === "--project-id") result.projectId = argv[++i]; else if (arg === "--out") result.out = argv[++i]; else if (!result.file && result.command !== "list" && result.command !== "login") result.file = arg; else usage(`Unknown argument: ${arg}`); } if (!result.command && result.file) result.command = "push"; return result; } function usage(message) { if (message) console.error(message); console.error(`Usage: node scripts/sync-htmlslide.mjs login [--api-base http://127.0.0.1:3001/api] [--app-base http://127.0.0.1:5173] node scripts/sync-htmlslide.mjs list node scripts/sync-htmlslide.mjs pull --title "Project title" --out path/to/ppt/index.html node scripts/sync-htmlslide.mjs pull --project-id <id> --out path/to/ppt/index.html node scripts/sync-htmlslide.mjs push path/to/ppt/index.html [--title "Project title"] [--project-id <id>] [--watch]`); process.exit(2); } function fail(err) { console.error(`[htmlslide-sync] ${err.message}`); process.exit(1); }